88 Commits

Author SHA1 Message Date
Ruben Fiszel 1fa3bf3b29 fix: show runtime-detected assets in a run's Assets tab (#10738)
* fix: show runtime-detected assets in a run's Assets tab

* fix: address review nits on run assets tab

* fix: cap the run assets list and report when it is cut

* fix: cap run assets by asset, not by row
2026-08-18 10:26:20 +02:00
Ruben Fiszel 529e960629 perf: cap resource content sent to the search modal (#10714)
* perf: cap resource content sent to the search modal

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

* fix: address review — fence the LATERAL, flag partial search, add cap test

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

* fix: pluralize the truncation notice and link the cap to its openapi doc

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:06:07 +02:00
Alexander Petric b5510333ea fix(groups): replace instance-group delta-patching with a state-based reconciler (#10686)
* fix(groups): replace instance-group delta-patching with a state-based reconciler

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* fix(groups): follow instance-group renames through workspace references

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* fix(groups): preserve historically-orphaned instance-group members on upgrade

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* fix(groups): preserve retained-group orphans too in the upgrade migration

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* test(groups): exercise the orphan-preservation migration; strip refs before converting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* test(groups): pin the migration's strip-before-convert order

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* fix(groups): make reconciliation the last locking step in every mutation path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* fix(groups): make the workspace advisory lock first in the lock hierarchy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* fix(groups): lock workspaces before membership writes in single-user paths

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* fix(groups): use the instance_group row as the group-level mutex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* fix(groups): take an exclusive instance_group table lock in overwrite_igroups

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TYi5MGYwUL6gjYMgQVY2Yx

* chore: update ee-repo-ref to af02d6bce55512b65c56adcbf69a8e15cd124d23

This commit updates the EE repository reference after PR #726 was merged in windmill-ee-private.

Previous ee-repo-ref: ec2feac82636869731666e5c6578b6c078e9aeb2

New ee-repo-ref: af02d6bce55512b65c56adcbf69a8e15cd124d23

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-14 18:43:01 +02:00
Ruben Fiszel 505815aab9 authorize GET /concurrency_groups/{job_id}/key per job (#10665)
* fix: authorize GET /concurrency_groups/{job_id}/key per job

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

* fix: answer 404 for an inaccessible and an unknown job alike

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 21:05:02 +02:00
Ruben Fiszel 9cd2bcea25 clear orphaned usr_to_group rows on service account creation (#10660)
* fix: clear orphaned usr_to_group rows on service account creation

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

* test: pin service account creation over orphaned usr_to_group rows

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

* chore: update ee-repo-ref to 60c20e686cead73ff075512b15c6e2d6232beca6

This commit updates the EE repository reference after PR #723 was merged in windmill-ee-private.

Previous ee-repo-ref: 5c2c553f960abcd7988fdac8830dd36c066160ad

New ee-repo-ref: 60c20e686cead73ff075512b15c6e2d6232beca6

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-12 14:53:47 +00:00
hugocasa 06c6b8780c fix: scope a fork's cloned app policy and custom path to its creator (#10595)
* fix: scope a fork's cloned app policy and custom path to its creator

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

* fix: gate a cloned anonymous app on the parent's own deployment rule

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

* docs: state why a cloned anonymous app is gated more strictly than create_app

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

* style: wrap an over-long comment line in clone_apps

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

* fix: clone an app's execution_mode unchanged

Forcing `publisher` on a cloned app was a speed bump rather than a boundary:
protection rules are workspace-scoped and are not cloned, so the fork's creator
can publish an anonymous app there with no rule in the way. It was also the one
policy field a deploy back to the parent carries verbatim, since `update_app`
recomputes the identity but writes the policy wholesale, so a fork's copy could
silently close the parent's public endpoint.

The identity rewrite is what closes the hole this addresses: the fork's endpoint
no longer runs as whoever the parent published it as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: ignore an app's run-as identity when comparing workspaces

`compare_two_apps` hashed the whole policy, so a fork whose apps were re-pointed
at their creator reported every one of them as changed. Nothing could clear those
entries: the deploy offers the target's current identity, the deployer's, or a
typed-in one, never the source's, so the difference survives however many times
the item is deployed. `script` and `flow` already compare no identity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:59:21 +02:00
hugocasa 330175f83b Revert "fix: scope a fork's cloned app policy and custom path to its creator …" (#10592)
This reverts commit 8e95bfe615.
2026-08-07 12:39:44 +02:00
hugocasa 8e95bfe615 fix: scope a fork's cloned app policy and custom path to its creator (#10589)
* fix: scope cloned app policy and custom path to the fork's creator

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

* refactor: share the app custom-path scoping rule across its call sites

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

* docs: tighten the cloned-app-policy comments

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

* docs: correct the execution_mode and custom-path scoping rationale

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:37:04 +02:00
Ruben Fiszel 8c6211c277 feat: offer more dev workspace environment labels (#10570)
* feat: allow custom dev workspace environment labels

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

* fix: reject dev labels that shadow a tracked branch's namespace

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

* fix: guard dev labels against a repo's assumed default branch

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

* chore: state the badge-cap rationale once and drop unenforceable openapi constraints

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

* refactor: offer a fixed list of environment labels instead of free text

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

* fix: match the accepted label set to the openapi enum exactly

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

* docs: stop describing the label set as dev/staging only

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 18:32:19 +00:00
Ruben Fiszel e203ab087a feat: allow a dev workspace to have its own dev workspace (#10534)
* feat: allow a dev workspace to have its own dev workspace

Fixes WIN-2324

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

* fix: keep every dev workspace in a chain on a distinct deploy branch

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

* fix: count a dev workspace the caller has no seat in as holding its label

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

* refactor: keep the attach form standing when a candidate takes the last label

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

* fix: keep the label toggle visible when a candidate's dev workspace clashes

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

* docs: describe the cycle guard by what holds, not by what changed

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

* fix: refuse to archive a fork-backed dev workspace that owns a nested dev

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

* fix: put the deploy target and item filters under the pairing they configure

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

* fix: refuse to archive any dev workspace that owns a nested dev

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

* docs: fix the fixture family count

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

* fix: put the deploy target with the pairing line it restates, above protections

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

* fix: name the same family head in the workspace menu and the scope picker

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

* fix: stop offering to delete a dev workspace from the sidebar settings menu

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

* docs: state the visibility boundary the lineage root actually resolves to

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

* fix: serialize dev-pairing creation against teardown of the same workspace

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

* fix: lock both sides of an attach so adjacent pairings cannot share a label

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

* fix: serialize dev pairings on one key, the invariant being chain-wide

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

* fix: scope the pairing lock to the chains an operation reads

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

* fix: hold the pairing lock across renames and re-check the cycle under it

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

* fix: hide the fork-delete action until the workspace entry has loaded

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

* fix: lock archive before it reads the pairing state it acts on

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

* docs: describe the archive lock test by what it pins, and drop an unused fixture row

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 19:07:12 +00:00
Ruben Fiszel 4fe4fac358 feat(mcp): serve the 2026-07-28 spec alongside the legacy protocol (#10535)
* feat(mcp): serve the 2026-07-28 spec alongside the legacy protocol

* fix(mcp): keep oauth discovery strict and preserve request limits

* fix(mcp): allow the protocol's own headers through CORS

* fix(mcp): expose the auth challenge to browser clients

* chore: update ee-repo-ref to c1665a881b61616f96ffe7702b44840905304660

This commit updates the EE repository reference after PR #711 was merged in windmill-ee-private.

Previous ee-repo-ref: bc1c001e3e386342415dfb8ac31c6b97f6629320

New ee-repo-ref: c1665a881b61616f96ffe7702b44840905304660

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-05 13:52:09 +02:00
Diego Imbert d63315ed35 feat(frontend): pick workspace members from a searchable instance user list (#10474)
* feat(frontend): pick workspace members from a searchable instance user list

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

* fix: exclude non-addable users in the query and keep the picker input editable

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

* fix: cancel a superseded user search so its result cannot overwrite a newer one

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

* fix: treat the typed address as the email so Add works without the dropdown

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

* fix: only submit the typed picker text when it is a whole email address

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

* fix: keep the typed address visible in the picker once its dropdown closes

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 18:27:48 +02:00
Ruben Fiszel f3e73fb006 feat: make createApp and updateApp the full-code app tools over MCP (#10510)
* feat: point the MCP app tools at full-code apps

* feat: name the full-code app tools createApp and updateApp

* fix: check the path and writer before compiling, let listApps paginate

* fix: ask the app table who may create, not a restated rule

* fix: guard duplicate mcp tool names and document the create body
2026-08-04 18:20:48 +02:00
Ruben Fiszel 81ba9611eb feat: deploy a raw app from its sources, bundling them on a worker (#10500)
* feat: deploy a raw app from its sources, bundling them on a worker

* refactor: bundle raw app sources with the wmill CLI instead of a second bundler

* fix: address review findings on the raw app source deploy

* fix: bound bundle decompression, drop the npm dependency on slim workers

* fix: stop minting jobs:run for the source deploy, share the decode budget

* feat: let an MCP token grant the scopes its selected tools require

* fix: carry a caller-held extra scope through the MCP proxy

* fix: confine the run scope to the proxied request instead of the token

* fix: mint the run scope only for a token that names the tool

* fix: require write access before compiling, and state the grant where it is granted

* fix: let the database decide write access instead of restating its policies

* fix: answer a write denial with 403, not 401
2026-08-04 14:29:13 +00:00
Ruben Fiszel d9dd036edc fix: stop app updates from silently converting an app between raw and low-code (#10495)
* fix: stop app updates from silently converting an app between raw and low-code

* fix: lock the app row for the kind guard and route MCP away from raw apps

* style: condense the restore kind-change comment
2026-08-04 10:52:20 +02:00
Ruben Fiszel f365929eaa feat(fork): merge a fork deletion on evidence, not on the counters (#10484)
* feat(fork): merge a fork deletion on evidence, not on the counters

`workspace_diff.ahead`/`.behind` record that a write happened on a side,
not what it was or who made it. That leaves one row shape undecidable: an
item the parent has and the fork does not can mean the parent added it,
the fork deleted it, or a git-sync pull reverted a deploy that had just
brought it in. #10467 kept every such row out of the merge direction,
which killed the phantom but also dropped the only way to propagate a
fork-side deletion and left a rename's old path behind in the parent.

Record the evidence instead:

- `workspace_diff` gains, per side, the last event's kind (`write` /
  `delete` / `rename_from`) and origin (`authored` / `sync`). Rows
  written before the migration have neither and keep #10467's behavior.
- The kind is probed from whether the path still holds an item once the
  write has committed; an item kind the probe doesn't map records no
  evidence rather than a deletion. Create and update are not split —
  nothing at that point tells them apart for every kind, and the
  comparison already recomputes existence per side.
- The origin comes from an `X-Windmill-Deploy-Origin` header the API
  scopes into a task-local for the request. It is the load-bearing half:
  recording `delete` alone would read a git-sync revert as a fork
  deletion and reproduce the original bug. Two clients set it — `wmill
  sync push` (which the git-sync auto-pull runs inside a job) and the
  compare page's parent→fork "Update fork". Merging the other way stays
  authored so a deletion keeps propagating up a fork chain.
- The merge direction admits a parent-only row only when the fork's last
  event was an authored delete or rename-away. Such a row stays opt-in,
  never bulk-selected, and reads "Removes in <parent>"; the update
  direction keeps offering it back as "New".

A fork deletion and a rename now merge into the parent, a rename leaves
no duplicate behind, and a fork the parent also edited surfaces in both
directions instead of the parent silently winning.

Fixes WIN-2289

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

* fix(fork): address review — detached tallies, enum wire values, doc duplication

Codex P1: a dependency job tallies its deploy whenever it happens to finish,
and the event kind is probed from the state at that moment. If anything
removed the path in between (a git-sync revert), the stale tally read that
deletion as its own and filed it as authored — handing the merge exactly the
removal this is meant to withhold. `tally_deployed_object_changes` now takes
`Option<DeployOrigin>`; `None` bumps the counter and leaves the evidence
columns as the last vouching tally left them, and the worker path passes it.
Covered by extending the removal-origin test: a detached tally after the sync
archive must not disturb `(delete, sync)`.

Also from review:
- `fork_removed_it` compares through `DeployOrigin::as_str()` /
  `DeployEventKind::as_str()` rather than repeating their wire values, so a
  renamed variant can't silently make the predicate always false.
- `deploy_origin`'s module doc no longer claims `sync` is inert: it cannot
  make the merge propose a removal, but it does drop a row out of both sides
  of the `all_ahead_items_visible` comparison.
- `WorkspaceDiffRow` says why only the fork half of the evidence is consumed.
- The delete-vs-revert rationale is stated once (the migration) instead of
  restated in eight files.
- `PATH_KEYED_TABLES` is swept by a test: its query is built at runtime, so a
  wrong table name is not a compile error and would only surface as a failed
  tally for that trigger kind in a fork.

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

* fix(fork): let only a request task vouch for a deploy event

Round 2 found the first fix incomplete. Detaching only the failed/cancelled
dependency path left the common route untouched: a dependency job that
succeeds calls `handle_deployment_metadata` from the worker, where
`deploy_origin::current()` read as `Authored`. A sync archiving the script
while its lock generation was pending then had its deletion probed on
completion and refiled as authored — the same fabricated removal, on the
path most deploys actually take.

`current()` now returns `Option`, `Some` only inside the request scope the
API always enters. Having no scope means "not the task that served this
write", which is true of every worker-side call and needs no marking at the
call site. The integration test drives the real `handle_deployment_metadata`
off a request task instead of the tally directly, and fails without this.

Two more from the same round:

- The script dependency handler passed no `renamed_from`, unlike the flow
  and app handlers next to it. A lock-generating create has no earlier
  tally, so that was the only chance for the path a rename vacated to be
  recorded at all — renames of Python/TS scripts left the old path in the
  parent, which the bash-only manual check missed.
- The tally now drops a `renamed_from` equal to the path itself. Callers
  pass the previous path whether or not the deploy moved the item, so an
  unfiltered one both counted the path twice and stamped it `rename_from`
  when nothing was renamed.

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

* fix(fork): carry a deploy's origin into the dependency job it queues

Round 3 caught the previous fix cutting too deep. Refusing a detached tally
any claim also refused its rename evidence, and a lock-generating deploy has
no other tally — so the `renamed_from` added alongside it was inert, and a
renamed flow, app or Python script still left its old path in the parent
with nothing to merge. Flows and apps always generate, so renames worked
essentially nowhere.

The two capabilities are now separate. `TallyEvidence` says whether the
tallying task served the write (`Served`, may probe what the path holds now)
or is reporting one that committed earlier (`Deferred`, may not), and each
column is written only from a source that answers for it. The origin itself
is a fact of the deploy either way, so the request stamps it into the
dependency job's args and the worker re-enters the scope with it — the last
place that knows it handing it to the only tally that will run.

Also from round 3: `WorkspaceDiffRow`'s event fields skip serializing `None`
rather than emitting `null`, matching what the schema declares (OpenAPI
3.0.3 ignores a `description` sibling of `$ref`, so those moved onto the
shared schemas).

Verified against a live worker: renaming a flow in a fork records
`(rename_from, authored)` on the vacated path and the merge offers its
removal, while the deployed path claims nothing.

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

* fix(fork): mark the CLI's parent-to-fork merge as sync

`wmill workspace merge --direction to-fork` is the CLI's "Update fork" and
deletes items in the fork, but without the marker the compare page sets. Its
deletions were recorded as authored fork decisions, so once the parent
recreated such a path the merge would offer deleting it there.

Also from review: an unrecognized deploy-origin arg now reads as no evidence
rather than as authored — strict where a request header is lenient, since an
unmarked request really is authored but an unreadable stored value is skew.
Reading the arg moved next to `stamp_origin_arg`, the half that writes it, so
the round trip a lock-generating deploy depends on is covered by one test.

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

* fix: drop the imports the shared arg reader made unused

CI compiles with `-D warnings`, so this was four red Backend jobs rather
than a lint.

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

* fix(fork): stop a stale deferred rename from restating a removed path

Nothing orders these events. A tally that served the write made its claim
inside its own commit, but a deferred one reports a write that landed at an
unknown remove. So a lock-generating rename whose dependency job finished
after a sync had removed the vacated path could overwrite `(delete, sync)`
with `(rename_from, authored)` — the path is gone either way, so the merge
would then offer removing it from the parent on the strength of the older
event.

A deferred claim now only writes where the side has none, which is the case
it exists for: a vacated path that nothing else has spoken for. The
regression asserts the ordering directly, and fails without the guard.

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

* fix(fork): record a rename's vacated path from the request that made it

The deferred mechanism could not be made correct, as round 7 showed: its
guard protected an existing row, but that row is deleted as soon as the two
workspaces agree on the path — so a rename job finishing after the
reconciliation inserted fresh, and the stale claim reappeared against
whatever the parent later recreated there. Ordering cannot be recovered
outside the row, because the row is disposable.

So the vacated path is now recorded by the request, which is inside its own
commit and whose row shares the counter's lifetime. A deploy that hands its
metadata to a dependency job — every flow and app, and any script needing a
lock — calls `tally_rename_vacated_path` once its transaction has committed;
scripts reach it through the post-commit hook they already had, which grew a
second variant rather than new plumbing.

That lets the whole deferred apparatus go: `TallyEvidence`, the origin job
arg and its round trip. `deploy_origin::current` is `Some` only inside a
request scope again, and `handle_deployment_metadata` hands `renamed_from`
to the tally only when it can answer for it — git-sync still gets it either
way, so the rename keeps naming itself in the commit message.

The vacated path's kind now reads `delete` rather than `rename_from` for
these deploys, since it is probed rather than declared. The merge treats the
two alike; only the row's tooltip is less specific.

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

* fix(fork): cover raw-app renames, and stop firing CI before the lock exists

Two things the vacated-path call broke or missed:

- `create_script` reads its third return value as "no lock generation
  needed" to decide whether the script is runnable now, and the new
  `VacatedPath` variant made that true for renames that do generate. Those
  fired dependent CI tests from the API against a version with no lockfile,
  and again from the dependency job. The variant now decides it explicitly.
- Raw apps rename through `update_app_raw`, a separate route into
  `update_app_internal`, which the new call had not been attached to. Both
  routes now go through one helper.

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

* test(fork): assert the kind only an inline rename can record

`rename_from` is what a deploy says when it knows it moved the item, which
only the path that reports both halves from its own request can. Nothing
pinned it, and that is the side the vacated-path change touched.

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

* chore: update ee-repo-ref to a45bec03922d305aad5893ed354dc029c7f97bb4

This commit updates the EE repository reference after PR #709 was merged in windmill-ee-private.

Previous ee-repo-ref: 62f494b2a51de0dfc0cfa0c3530ff19a1d32667c

New ee-repo-ref: a45bec03922d305aad5893ed354dc029c7f97bb4

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-04 00:50:27 +02:00
Ruben Fiszel 689f5d7c75 fix: stop reading a parent-only fork item as deleted in the fork (#10467)
* fix: stop reading a parent-only fork item as deleted in the fork

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

* style: condense the deploy-direction helper comments

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

* fix: keep the ambiguous half of a one-sided diff out of bulk defaults

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

* fix: disable select-all on a removal-only list and cover the hidden source-only row

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

* fix: keep parent-only items out of the fork merge list entirely

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

* fix: count the fork banner's ahead/behind with the compare page's predicate

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

* fix: open the direction the fork banner's button offers

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

* chore: cache the sqlx query for the source-only visibility test

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

* fix: don't read an unloaded comparison as nothing to deploy

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

* fix: treat an in-flight comparison as unknown in the fork banner

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 18:14:41 +00:00
Ruben Fiszel 4f03aa91a7 fix: report why a native trigger service refused instead of a 500 (#10463)
* fix: report why a native trigger service refused instead of a 500

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

* fix: keep the cause of an unreachable trigger service in the message

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

* fix: degrade a trigger read only for the service's own failures

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

* fix: tell a refresh outage apart from a rejected refresh grant

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

* fix: treat a rate-limited or timed-out service as an outage, not a refusal

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

* fix: keep a token endpoint's status out of the trigger's

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

* fix: read a refused refresh grant off the body, not only the status

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

* fix: let a throttled 403 read as an outage, not a permission refusal

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

* fix: classify a refresh refusal by its OAuth code, and Google quotas by domain

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

* fix: recognize GitHub's other wording for a throttled request

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 19:10:52 +02:00
Ruben Fiszel fb82748296 fix: make on_behalf_of control permissions for scripts and flows (#10438)
* fix: make on_behalf_of control permissions for scripts and flows

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

* fix: inherit the recorded on-behalf-of identity when a preserving deploy omits it

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

* fix: keep an omitted permissioned_as from re-versioning an unchanged script

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

* fix: derive the on-behalf-of principal from the email and reject mismatched pairs

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

* fix: stop workspace deploys from carrying a source-workspace principal

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

* docs: correct the onBehalfOfPermissionedAs param doc

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

* test: pin that workspace deploys never carry a source-workspace principal

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

* docs: correct the omitted-principal contract and refresh generated prompts

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

* fix: keep external-superadmin principals on email-only redeploys

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

* fix: scope the recorded principal to its workspace and prefer real accounts

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

* fix: carry the recorded principal correctly through drafts and set-permissioned-as

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

* fix: sweep draft identity pairs on email change and offboarding

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

* fix: leave group identities alone when sweeping a user's email

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

* fix: treat only g/ without an email as a group, and match the offboard preview

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

* fix: stop the group guard from skipping rows with no recorded principal

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

* docs: state the group guard once instead of restating it

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

* refactor: make the permissioned_as the only stored on-behalf-of identity

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

* perf: skip resolving the on-behalf-of address for sync clients that discard it

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

* fix: address the local review of the identity refactor

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

* fix: resolve the on-behalf-of identity coherently across clones, offboarding and no-op deploys

* test: pin that a fork keeps only the on-behalf-of identities that resolve in it

* fix: decide a principal prefix-first everywhere and canonicalize bare addresses

* fix: prefix a slash-containing address so a reader cannot take it for a group

* fix: read an address as a username before the group- convention

* fix: rewrite the canonical principal when an account's address moves

* fix: keep the address form of a principal to accounts without a usr row

* fix: reject an identity a job row cannot carry and read it uncached at dispatch

* fix: count characters against the job identity width and cap the backfill

* refactor: name the script/flow principal on_behalf_of, as apps do

* docs: state the caller-must-authorize contract on the identity resolvers

* fix: keep writing on_behalf_of_email until every worker reads the principal

* fix: err high on the compatibility version and document the last resolver

* fix: keep the compatibility address current through identity mutations

* fix: carry the compatibility address with the principal on every copy path

* chore: re-pin the EE ref to the companion branch merged with EE main

* fix: key the dbt retry lookup on the stored principal

* fix: keep a mixed-version address recoverable through a fork

* fix: read a round-tripped address uncached so a redeploy is not rejected

* fix: refuse an email change that would make a principal unenqueueable

* chore: update ee-repo-ref to ac3d7d015296f041ae44ab6bc4953485f44d36e4

This commit updates the EE repository reference after PR #704 was merged in windmill-ee-private.

Previous ee-repo-ref: 219b0b03905a1a0028054b3a4985724e77d09036

New ee-repo-ref: ac3d7d015296f041ae44ab6bc4953485f44d36e4

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-08-01 20:37:21 +02:00
Ruben Fiszel 032300e28e feat: run dbt projects as a first-class Windmill runtime (#10326)
* fix: mount only the engine in the dbt jail, reject shadowed and malformed args

Review round 42.

The jail mounted the whole dbt cache directory, whose siblings of the
engine are `repos/` and `packages/` — other workspaces' private checkouts
and package trees, kept apart by cache key rather than by permissions. A
jailed project could read them. It now mounts the engine's own directory,
which the provisioner names; verified from inside the jail that `repos/`,
`packages/` and `state/` are invisible while the engine stays usable.

A `{{ placeholder }}` may no longer take the name of a run argument this
runtime defines. It was silently dropped from the signature, so a
descriptor like `value: "{{ select }}"` deployed and then could not be
run at all: the built-in `select` is an array and the interpolation needs
a scalar. Refused at parse, so the deploy says so.

A `vars` override that is not an object is refused rather than ignored.
Argument-schema validation is opt-in, so a string or an array silently
ran the descriptor's own vars — against a different schema or alias than
the caller asked for. `select` and `exclude` already refused theirs.

* feat(dbt): the project is the script's module bundle, not a git checkout

A dbt script now carries its whole dbt project as its module bundle. The
descriptor is the script content; `<script>__dbt/` holds the project verbatim,
so importing an existing project is `cp -r` plus `wmill sync push`, and the
worker materialises the bundle into the job directory instead of cloning.

Backend
- `prepare_project` writes the script's modules and requires `dbt_project.yml`
  at the bundle root. `checkout`, the git-ssh command, the clone cache and the
  repository resource are gone, along with `repo`, `project`, `ref` and
  `git_ssh_identity` on the descriptor.
- Run identity and the package cache key take a `project_digest` (sorted SHA256
  over the bundle) where the commit used to sit, so an edited project cannot
  resume a previous run's `run_results.json` or reuse its `dbt_packages`.
- The per-run graph re-ingest is now gated on `vars` placeholders and `$var:`
  env alone.
- `capture_dependency_job` takes the script's modules so a dependency job, which
  has no generic module-writing step, materialises them itself.
- `dbt deps` caching strips the git remote from every package it cached, not
  just the tree root: `packages.yml` can render a token into a `git:` URL.
- `git_clone.rs` is dropped and `ansible_executor.rs` returns to its own copy of
  the clone helpers.

CLI
- `wmill sync pull` keeps a dbt script's lock beside its folder rather than
  inside it, so the folder holds nothing but the project.
- Directories dbt generates (`target-path`, `packages-install-path`,
  `clean-targets` and the usual defaults, read from `dbt_project.yml`) are
  excluded from the bundle, from the sync diff and from staleness hashing.
- A module-only edit now pushes its parent dbt script and is reported as a
  changed module rather than passing unnoticed.

* fix: keep a script's modules in the worker's file-system cache

The first fetch of a script version reads the database and carries its
modules; every later fetch imports from the worker's cache directory, whose
`RawScript::import` hard-coded `modules: None` and whose `export` never wrote
them. A worker restart therefore started running the script without its own
files, silently — for a dbt script, without its project, which fails with
"carries no project"; for any other script with a module bundle, with the
imports missing.

`modules.json` is now written on every export and required on import, so an
entry written by an older version fails to import and is refetched rather than
serving a stripped script for as long as the directory lives.

Also derives a dbt run's `project_digest` from the bundle the run actually
carries: `handle_dbt_job` was passing `None`, which collapsed every project in
a workspace onto one digest and let `dbt retry` resume a different project's
`run_results.json`.

* fix(dbt): give every phase the script's environment, bound the cache copies

`dbt deps` ran without the script's environment variables on an unsandboxed
worker, so a `packages.yml` resolving a private package URL through
`env_var()` could not see them while the package cache key was still built on
their digest. `with_invocation_env`, applied at three of the four call sites,
is folded into `dbt_command` so no phase can be added without it, and
`DBT_TARGET_PATH` is set after both environments rather than before.

The package cache copies ran through a bare `Command::output()`: the tree is
the project's, so a cancelled or timed-out job held its worker slot until `cp`
finished. Both the restore and the publish now run under the job poller like
every other phase.

* fix(dbt): only offer commands whose writes match the graph, honour packages-install-path

`dbt_command: run` is dropped from the allowed overrides. Asset dispatch fires a
script's deploy-time writes on any successful job, and `dbt run` covers models
only, so a project with seeds or snapshots notified consumers of relations the
invocation left stale. That is the same reason `test` was already excluded.
Narrowing what a run touches is `select`/`exclude`, which scope the graph too.

`dbt deps` writes to the project's `packages-install-path`, so a project that
moved it got no package cache at all: the publish found nothing at
`dbt_packages` and every job resolved its dependencies over the network again.
The path is read from `dbt_project.yml` and validated as project-relative,
since both cache copies are rooted at it.

Also states the sidecar's mutator contract at the module level: the dbt manifest
tables carry no RLS and grant `windmill_user` full access, so a user-scoped
transaction is not enforcement and every caller must have verified write access
to the script itself.

CLI: a module file is now grouped with its parent script for the push. Left in
a group of its own it got its own `alreadySynced`, so a push touching several
files of one bundle deployed the script once per file; the resulting versions
raced, and the asset graph could end up describing none of them.

* fix(dbt): seed a project for browser-created scripts, refuse a no-op retry

A dbt script created in the browser only got a descriptor, and the runtime
refuses a script whose bundle has no `dbt_project.yml`, so the advertised
Create → Deploy → Run path always failed its dependency job. New dbt scripts
now start with a project that builds: pointing `profile.resource` at a
warehouse is the one edit, and growing it is `wmill sync pull` plus a local
editor, which is where dbt development happens.

`dbt retry` builds its graph from the previous run's error, fail and skipped
nodes alone, so retrying an all-green run selected nothing and wrote nothing —
and a job that succeeds having written nothing still dispatches every
deploy-time write, waking every downstream consumer for relations no one
touched. Refused, with the reason.

CLI: a configured `target-path` or `packages-install-path` may be nested
(`build/target`), and `clean-targets` has a block form as well as an inline
one. Both are now parsed, and the exclusion compares the project-relative path
rather than the top-level segment, so a nested generated tree no longer lands
in the bundle and no longer makes a local `dbt run` look like a project change.

* fix(dbt): lock a project once, find the parent on either path separator

A dbt script's modules are its dbt project, not helper code with dependencies
of its own, so the generic per-module lock loop is skipped for it: the parent
lock already ran `dbt deps` and `dbt parse` over the whole project. Locking
each file separately re-materialised the bundle and re-invoked dbt once per
file, so a project of N files paid N project-sized passes and a large one timed
the deploy out. The 13-file fixture went from 14 relock passes to 1.

`pushParentScriptForModule` searched the raw path for `__dbt/`, so on Windows,
where the folder is spelled `__dbt\`, a module-only edit returned without
deploying its parent while the caller still recorded the file as synced. It now
goes through `getScriptBasePathFromModulePath`, which normalizes separators.

Also drops the last of the external-repository wording from the descriptor's
module docs and from the `codebase` rejection a user can hit.

* feat(dbt): infer the run form locally, keep test-only retries from cascading

`windmill-parser-wasm-yaml` 1.770.0 carries `parse_dbt`, so the browser and the
CLI derive a dbt script's run arguments from its descriptor instead of waiting
for the deploy to hand back a schema. Pins bumped in both.

`generate-metadata` was rewriting a dbt script's `lock` field on every run: a
dbt lock comes from the dependency job on a worker, so nothing generates it
locally and the resolved `!inline` reference was left inlined into the metadata
or blanked. It is restored instead, and a push straight after
`generate-metadata` is a no-op again.

A retry now needs a failed node that materialises something. `dbt retry` builds
its graph from error, fail and skipped nodes, and with `test_behavior:
after_all` a failing test is what `run_results.json` ends up describing — so the
retry reran tests, wrote nothing, succeeded, and still dispatched every
deploy-time write.

The dbt badge's destination is deterministic: writers outrank readers, and among
several writers of one relation (which the backend permits) the smallest id
wins, rather than whichever write edge arrived last.

* feat(dbt): browse the project and read a run's per-node result

Two views a dbt user expects and that the generic script surfaces do not give.

**The project.** A dbt script's editor gains a Project tab beside its
descriptor: the module bundle as the tree dbt itself expects, each file
read-only with syntax highlighting. The existing module tab strip is a flat row
built for a couple of helper files and does not survive a real project; a
13-file fixture already overflows it. Directories sort before files so it reads
like the checkout on disk, and an empty bundle explains the `cp -r` instead of
showing a blank pane.

**The run.** `DisplayResult` renders a dbt invocation's per-node breakdown above
the raw payload: totals, then a table of node, kind, target relation, rows and
time, with failures and warnings sorted first and carrying their message. The
data was already structured; it was being shown as JSON to scroll and PASS/WARN
counts to find in the log. On a failed run the same JSON rides in the error
message after the exit-status line, so it is parsed back out — that is the case
worth rendering, since the failing node is what the user came for.

* docs(dbt): say that profile.resource is what buys the asset graph

The starter descriptor described `profile.resource` as the thing rendered into
profiles.yml, with the project's own file as an equal alternative. It is not
equal: the resource PATH is the warehouse's identity in the asset graph, so a
project bringing its own profiles.yml runs fine and silently gets no assets, no
lineage and no cascade. The deploy already says so in its log; now the
descriptor a user starts from says it too, before they choose.

* fix(dbt): authorize a resource used only for asset identity, clean up after failed installs

A descriptor setting both `profile.profiles_yml` and `profile.resource` took
its connection from the project's file but returned the resource path as the
graph's warehouse identity without ever reading it. A script editor could
therefore publish `table://<any resource>/...` writes, and wake that
warehouse's subscribers, while connecting somewhere else. The resource is now
read on that path too — reading is what authorizes it — so the combination
keeps working for the case that wants it (keep your own profiles.yml, still get
lineage) and fails closed otherwise.

Provisioning cleaned up its staging directory only on the paths someone
remembered, so a run of failed or cancelled first-use installs accumulated
venvs, tarballs and installer scripts until the worker's disk was gone. All
three engines now hold their scratch paths in a guard that removes them on
drop, which is the one exit every path takes, cancellation included.

Frontend: `partial success` is dbt's word for a node that built but whose tests
failed, counted in `totals.error` and redone by a retry, so it ranks with the
failures instead of rendering green with its message hidden. And the run panel
now keys off the worker's engine discriminator rather than `{nodes, totals}`,
which is a shape an ordinary script can return. Both pinned by unit tests on
the extracted `parseDbtRun` helpers.

* feat(dbt): show a run's models on the run page

The run page is where you land on a running job, and until now it showed a dbt
run as streaming text: the per-node table only renders once the job has
produced a result, and the graph that moves per model lived on the pipeline
page you had to navigate to. A Models section now sits above the result,
scoped to the running script's own relations and its `ref()` lineage, polling
while the job is in flight so nodes move as dbt walks the DAG.

No `resolveGraph`: that merges drafts and live editor buffers into the
persisted graph, and a run page has neither.

* feat(dbt): retry failed nodes automatically, and from any worker

**Node-level retry, in the job.** `retry_failed_nodes: {attempts, delay_seconds}`
rebuilds only what a failed build left failed or skipped, before the job reports
failure. dbt confines a failure to its own subtree and `dbt retry` resumes
exactly that set, so a transient warehouse error costs those nodes rather than
the project. Doing it in-job is what keeps the state question out of it: the
previous attempt's `run_results.json` is still in the job directory, so there is
nothing to persist and no worker to land back on. This is the granularity
astronomer-cosmos gets from one Airflow task per model, without the ~6x that
per-model tasks measured.

A retry's `run_results.json` names only the nodes it redid, so it overlays the
accumulated results rather than replacing them: the job's result has to be every
node the job touched, or the nodes that succeeded before the retry settle no
materializations. Pinned by a test.

**Durable retry state.** `run_results.json` is now saved to `dbt_run_state` as
well as the worker's local cache, so an explicit `dbt_command: retry` works from
any worker of the group rather than only the one that failed. Only the results
are stored: `dbt retry` also needs `manifest.json`, roughly sixty times larger
and growing with the project (732 KB against 12 KB on the six-node fixture), but
the manifest is a pure function of the project files, vars and env, all of which
the stored identity already pins, so a worker restoring from the database
re-derives it with a `dbt parse` of about a second.

* fix(dbt): restore the sqlx cache, make retries cancellable and path-aware

**SQLx cache.** A `cargo sqlx prepare` deleted 750 entries, including the
enterprise queries CI needs under `SQLX_OFFLINE=true`, and the check that was
supposed to catch it reported zero losses because it was run from `backend/`
with a `backend/`-prefixed path, so its baseline was empty and it failed open.
All 750 are restored; the branch now adds 19 and deletes none, and
`SQLX_OFFLINE=true cargo check` passes.

**Retry backoff observes cancellation.** `canceled_by` is only written by the
job poller, which does not run between attempts, so re-reading it reported the
state as of the failed attempt and missed every cancel issued during the wait
— the whole window the check exists to cover. The wait now reads
`v2_job_queue.canceled_by` each second, and the job's deadline is honoured
before starting another dbt process.

**Retry state follows its script.** `dbt_run_state` is path-keyed like the
manifest sidecar but, unlike it, nothing regenerates it: a rename moves the row
so a resumable failure survives, while archive and delete clear it, so a script
later created at that path cannot inherit a stranger's failure and its
arguments.

**CLI.** `table` joins ducklake and s3object in the local graph's auto-trigger
kinds, matching `is_auto_trigger_kind` and the frontend's set; without it a
local graph and the generated docs omitted a cascade edge the deploy has.

* fix(dbt): carry only the project files a bundle can hold, and say what it drops

Exploring real and edge-case projects surfaced three frictions, all in the
import path a user hits first.

**A binary file broke the push, opaquely.** dbt projects carry images under
`docs/`, stray `.DS_Store` files and occasionally a parquet seed. Read as text
they become mojibake, and a NUL among them is rejected by Postgres with
`unsupported Unicode escape sequence` — which `wmill sync push` then reported as
success, exiting 0 with the script never created. Binary files are now detected
the way `git` detects them, by a NUL in the first 8000 bytes rather than by
extension, and skipped with the reason.

**The size guard the docs promised did not exist.** Now it does: 5 MB per file,
which only ever catches a committed dataset. Real dbt code is about 500 bytes
median and 1.9 KB at p90.

**Skipped files became a permanent phantom diff.** The push dropped them while
the sync diff still offered them, so every push reported changes no push could
resolve. One predicate now answers for the push, the staleness hash and the
diff.

Verified on a project with unicode filenames and content, CRLF endings, an
empty model, an ephemeral model, a disabled model, a `.md` docs block, an
extensionless README, six levels of nesting, a 7.6 MB seed and a PNG: it
pushes, round-trips byte-for-byte through pull, deploys to 7 dbt nodes and 6
`table://` assets (ephemeral and disabled correctly absent), and runs green.

* fix(dbt): resolve dbt-core against the adapter, settle partial success, unify status

**Adapters could not be provisioned.** The 1.x engine pinned `dbt-core` to a
fixed version independent of the adapter, but several adapters cap below it:
`dbt-mysql` at `~=1.7`, `dbt-oracle` and `dbt-databricks` below 1.12, and
`dbt-salesforce` has no package at all (it exists only inside Fusion). Those
projects failed at provisioning with a uv resolver dump. The install now asks
for a range and lets the adapter choose, and records what the resolver picked so
the lock pins a version that adapter can take.

The floor is the CLI this runtime invokes: resolving down to dbt-core 1.7
produced a working venv that then failed with `No such option '--target'`, which
is worse than not resolving. An adapter with no release in range now fails
naming itself and pointing at `dbt-core-2x` or `fusion`, instead of a resolver
dump. Salesforce is refused up front with the reason.

**`partial success` left a model stuck on `Running`.** It is dbt's word for a
node that built and then failed its tests, and it was already treated as a
failure when counting totals and deciding a retry — but the two sites that
settle the RELATION fell through to "says nothing", so the tailer's `Running`
was never replaced and a finished job showed a model still building. Six status
comparisons had drifted apart, two folding case and four not, while dbt-core 1.x
echoes the author's casing and 2.x uppercases; they are now one classifier.

**Agent workers.** The durable retry state and the cancellation poll both need a
database, which an agent worker reaches only through the API. The automatic node
retry is refused there rather than running a wait it could not interrupt, and
the docs say "any worker with a database connection" instead of overclaiming.

Also clears `dbt_run_state` when a path stops being a dbt script, and moves
`run_identity`'s contract onto `run_identity` from the digest helper below it.

* feat(dbt): show the transform behind a model on the run graph

The run page's graph carried a node for the script itself and drew every
relation as a bare table. Both were wrong for that page: the graph there is
already scoped to one script, so a node standing for it distinguishes nothing
(on the pipeline page it separates one project from another, which is why it
exists), and dbt's own DAG node is the model — the SQL and the relation it
writes are one thing, so a graph of relations alone leaves out what a reader
came to see.

The script node is dropped, and selecting a model now shows its SQL underneath
the canvas with its file path and materialization, read-only, the same view the
pipeline details pane gives.

* feat(dbt): move the graph with the run

The worker has always recorded a state per relation as dbt walks the DAG —
`running` when a model starts, `materialized` or `failed` when it ends — but
nothing rendered it: the graph response carries what a relation IS, not what a
particular run is doing to it, so the canvas had nothing to show and a running
job looked identical to a finished one.

`assets/run_progress/{job_id}` returns that state for one job, the run page
polls it beside the graph, and the asset node carries a spinner or its outcome.
Errors and retries need nothing extra: a failed node writes `failed`, and an
in-job retry rewrites the same row, so the node returns to `running` and on to
its new outcome by itself.

`materialized_partition` holds a relation's CURRENT state keyed by relation, so
filtering on `job_id` returns exactly what this run last touched — which is the
question a run page asks, and why a superseded older run shows nothing.

* feat(dbt): a dbt project is not a data pipeline

Deploying a dbt script marked it `auto_kind = 'pipeline'`, which enrolled it
in pipeline membership: the folder became a Pipeline entry on the home page,
the script folded into it, and `/pipeline/<folder>` opened a canvas holding
the project's whole model DAG next to the pipeline's own scripts. A folder
holding both then read as two projects in one editor, and the pipeline editor
offered to author transforms that are in fact authored in a local `dbt run`
loop and pushed as the script's bundle.

A dbt script is now never a pipeline member, and the pipeline canvas drops the
dbt script node. Its models stay, with their `ref()` lineage: the relations are
what a downstream pipeline script reads, and dropping them would break the
cascade from a dbt run — the point of giving dbt models `table://` identity.

Also drops a screenshot committed to this branch by accident.

* fix(dbt): authorize run_progress through the job, drop dbt from the local graph

`run_progress` read `materialized_partition` through `user_db` on the
assumption that RLS would scope the rows. That table has RLS disabled and no
policies, so any workspace member could pass a job id and read that run's
relation paths, row counts and error text. It now joins `v2_job`, which does
carry per-user policies, so a caller who cannot see the job sees nothing —
the same pattern `v2_job_completed` reads need. Verified as a plain member:
the old query returned 6 rows for another user's run, the new one returns 0,
while the job's owner still sees all 6.

The CLI's local graph still forced `in_pipeline` on every dbt script, so
`pipeline docs --local` and `pipeline dev` kept presenting a dbt project as a
pipeline the deploy no longer enrolls. It now skips them, matching the server.
A dbt descriptor has no asset parser locally, so nothing is lost: its models
come from the manifest the deploy derives.

Declares `run_progress` in openapi.yaml so the frontend uses the generated
client instead of a handwritten fetch; the generated `status` union also
replaces a hand-rolled string mapping.

* fix(dbt): drop the dbt node from the CLI's deployed pipeline views too

`pipeline dev` and `pipeline docs` (without `--local`) read `/assets/graph`
directly. That endpoint is asset-usage driven rather than membership driven, so
it returns a dbt script like any producer — and both commands render every
runnable, so a dbt project still showed up as a pipeline script there after the
local builder stopped emitting one.

`hideDbtRunnables` mirrors the frontend's projection of the same payload. It is
generic over the graph shape so the bounded-cascade view (`BCGraph`, a narrower
type over identical JSON) passes through without a cast.

The relations stay: they are what a downstream pipeline script reads, and the
node is what attributes them to a producer for every other consumer of the
endpoint, so the filter belongs in the views rather than the query.

* fix(dbt): narrow a selective run's cascade, settle the finished run graph

Review-round fixes.

A `select`/`exclude` run builds part of the project, but asset dispatch reads
the deploy-time write set for the whole script, so a run selecting one model
woke the subscribers of every other. Dispatch now intersects that set with the
relations the run actually recorded as materialized, scoped to dbt because it is
the only producer whose write set is decided per run. A run that recorded
nothing still dispatches everything, so an agent worker whose reconciliation
failed cascades as before. Verified both ways: `select: [extra_model]` no longer
wakes the `fct_orders` subscriber, and a full run still does.

`hideDbtRunnables` keyed its removal set on path alone while the graph keys
runnables by `(usage_kind, path)`, so a flow sharing a path with a dbt script
lost its node, edges and triggers too. Both copies now key on the pair.

The run graph never took a final reading when a job finished, so the last state
shown was whatever the tick before completion saw. Only `dbt-core-1x` streams
node events; the other engines record every relation during end-of-run
reconciliation, so their finished graph showed nothing until a reload.

`DbtNodeOutcome::Inconclusive` collapsed statuses the tally has to tell apart,
so two sites re-lowercased the status beside the classifier and `no-op` landed
in `totals.error` — a clean run reporting an error in its own result. Split into
Warn / Skipped / NoOp / Unknown so every site falls out of one match; `no-op` is
kept out of the retry set, which dbt spells as error / fail / skipped.

Also: reattach two doc comments to the items they describe, and correct the
engine-distribution table — only dbt-core-2x is baked into the images, 1.x is a
per-adapter venv provisioned on first use, and the default is compiled in rather
than an instance setting.

* fix(dbt): make the model chip inert where its project node is not on the graph

The canvas passed `onDbtSelect` unconditionally, so the chip always rendered
`cursor-pointer` and hover-highlighted — but the owner map is empty on both
graphs this feature added, since the run page carries no runnables and the
pipeline page hides the dbt node. The chip advertised a click that resolved to
nothing. It now takes its handlers only when the relation has an owner on this
graph, so it stays live on the surfaces that do show the project node.

`classify_status` and `DbtNodeOutcome` were `pub` in a private module with no
caller outside the file, unlike every neighbour.

* fix(dbt): take the cascade's write set from the run's own result

The previous narrowing read `materialized_partition`, which was wrong twice.

That table keeps one row per relation and the newest writer takes `job_id`, so
two overlapping runs over the same model erase each other's claim to it: the
earlier job would dispatch a subset of what it built, or none of it.

And an empty row set was read as "recording failed, dispatch everything" when it
is also a real answer. A `select` matching no model, or one resolving to tests
only, exits 0 having built nothing — and then woke every consumer of every model
in the project, which is the opposite of what the narrowing exists to do and is
reachable by a typo in a run argument.

The run now reports the relations it materialized in its own result, which is
immutable and per job. Absent means the producer said nothing (a job from before
the field, a non-dbt producer) and the whole deploy-time set dispatches as
before; present-and-empty means it built nothing and dispatches nothing.

Verified on all three: an unmatched selector builds nothing and wakes nobody, a
selector naming one unsubscribed model wakes nobody, and a full run wakes the
subscriber.

Also indexes `materialized_partition (workspace_id, job_id)` -- the run page
polls that shape every 2s and no existing index leads with `job_id` -- corrects
the selective-cascade section of the design doc, which still described the old
deploy-time behavior, and reattaches `buildLocalPipelineGraph`'s doc comment.

* docs(dbt): attach the CLI JSDoc to its function, correct the index rationale

The `hideDbtRunnables` JSDoc ended up documenting the type declared beneath it —
made while fixing the same mistake one function down.

The migration's comment credited the cascade with a `job_id` lookup that the
same commit replaced with a read of the job's own result. The run page's poll is
the only reader keyed on that column.

* fix(dbt): refuse graph publication for a removed script, allow test-only retries

An archived or hard-deleted script could still republish its graph: the
publication guard filtered `deleted` but not `archived`, and treated a missing
row as "nothing newer exists" rather than "nothing left to publish for". A
dependency job or dynamic run finishing after the removal put the asset,
provenance and subscription rows back with nothing left to clear them.

`dbt_command: retry` refused a run whose only failures were tests, which is
precisely what `test_behavior: after_all` produces. That restriction existed
because a successful job dispatched its whole deploy-time write set, so a
test-only retry would have woken every consumer for relations no one touched —
the cascade now dispatches what the run reports materializing, so it wakes
nobody and the restriction only blocked a legitimate retry.

* fix(dbt): gate run progress behind the job-read check, not RLS alone

The endpoint joined `v2_job` so RLS would decide visibility, which it does — but
`require_job_read_access` adds two things RLS does not: a scoped token's
`if_jobs:filter_tags` restriction, and the app-embed cutoff that stops untrusted
app JS from inheriting the viewer's broader job access. A scoped or embed token
could therefore read relation names, statuses, row counts and errors for jobs
the ordinary job endpoints deny it.

That helper is private to `windmill-api`, which depends on `windmill-api-assets`
rather than the reverse, so the endpoint moves to the job routes instead of the
check being duplicated. It is job-scoped anyway:
`/w/{ws}/assets/run_progress/{job_id}` becomes
`/w/{ws}/jobs/run_progress/{id}`, and the frontend follows the generated client.

* feat(dbt): a dbt run does not trigger downstream runs

dbt orders its own DAG, so a cascade only ever adds one thing: waking a Windmill
script that reads a mart. That edge is narrow, and only half of it can even be
expressed — nothing outside dbt can declare a `table://` write, since
`// materialize` accepts DuckLake targets only, so an ingestion script cannot
wake a dbt project.

Against that, dispatching correctly is not cheap. A run's `select` can build any
subset of the project, so the deploy-time write set is not what ran; using it
wakes consumers of relations the run never touched, and narrowing it needs a
per-job record of what was built. The per-relation state table cannot supply one
(it keeps a single row per relation stamped with the last writer), and the
result field added for it made a run's own output carry the cascade's bookkeeping.

So `asset_dispatch` returns early for `ScriptLang::Dbt`, before the producer
gate. dbt still materializes, records per-model state and publishes its graph:
models, `ref()` lineage and live run progress are unchanged, and a
`# on table://<mart>` reader still renders beside the model it reads. It simply
does not fire. Wiring it up later means deciding what a selective run should
notify, which is the actual work.

Verified: a full run of a 6-model project succeeds and starts nothing, where it
previously triggered its subscriber; the run page still reports all 6 relations
and the folder graph still carries 14 tables and 8 ref() edges.

* fix(dbt): remove the cascade surface, settle stranded models, fix nested __mod

Stopping dispatch left its surface behind. `table://` was still an auto-trigger
kind, `persist_ingest` still derived subscriptions from a manifest's reads, and
the deploy still accepted `# on table://` — so the canvas drew cascade arrows
into scripts nothing could wake. All three are gone: the kind no longer derives,
the ingest only deletes rows earlier versions wrote, and the deploy refuses the
annotation with a message saying why rather than persisting a silent no-op.
`DescriptorTriggers` went with them; every field it parsed was cascade config.

A model marked `running` by the live tailer was never settled when the run did
not finish: reconciliation only revisits nodes `run_results.json` names, and a
cancelled or timed-out run has none for the model in flight, so the finished job
showed a relation building forever. It is now settled on every exit path.
Verified by cancelling a run mid-flight: 3 models `running` before, 3 `failed`
after, none stranded.

`getScriptBasePathFromModulePath` took the first matching suffix rather than the
outermost boundary, so `proj__dbt/models/legacy__mod/a.sql` resolved to
`proj__dbt/models/legacy`. dbt owns its directory names verbatim, so a folder
ending `__mod` is legal inside a project, and a module-only sync would have
looked for a descriptor that is not there and skipped the deploy.

* fix(dbt): colour a finished run's models from its own result

`materialized_partition` keeps one row per relation stamped with whichever job
wrote it last, so reopening a run showed only the models no later run had
touched since — down to none for an old run, which reads as a broken page rather
than as stale data. Reproduced: a 6-model run reported 6 relations, then a second
run rebuilt one shared model and the first reported 5.

A finished run already carries the answer. Its result lists every node with a
status, and the graph carries each asset's dbt `unique_id`, so the two join
directly — no path derivation, nothing stored twice, and nothing a later run can
overwrite. The endpoint stays for the live window, where the result does not
exist yet, and as the fallback for a run that never produced one (cancelled or
killed, whose relations the worker settles in the table instead).

`relationOutcome` mirrors the worker's `classify_status` so the colour drawn over
a record agrees with the record: `warn`, `skipped` and `no-op` leave the relation
untouched and stay uncoloured, as do tests and analyses, which match no asset.

Verified in the browser on the run whose model had been stolen: all six
relations green again, both sources correctly uncoloured.

* docs(dbt): record why only dbt-core 1.x has live per-model progress

`emits_node_events()` reads as "the Rust engines produce no node events", which
is false and would close off the option. They produce exactly the same events;
they put them on the console and ignore `--log-format-file json`, which both
accept. Measured on 2.0.0-alpha.5 and fusion 2.0.0-preview.202: 15 node events
each on stdout, 0 in the file log, for a three-model project.

Taking them means owning the job log's presentation to work around a flag that
is documented and simply unimplemented, so the note records the measurement, the
sample event, and that flipping the predicate is the whole change once either
engine honours it.

* fix(dbt): give HighlightCode a dialect-agnostic sql language

`npm run check` had three errors the fast check does not reach: `"sql"` is not a
value `HighlightCode` accepts. Every SQL dialect it knows maps to one grammar,
but a dbt model is compiled by whichever adapter the project targets, so naming
a dialect would be a guess — `sql` is now a value in its own right.

`langOf` was typed `string` and returned `markdown`, `python` and `text`, none
of which the component accepts either, so a dbt project's YAML and Python files
rendered unhighlighted. It now returns the component's own prop type, which is
what caught them, and `undefined` for what has no grammar rather than a name
that silently means the same thing.

Verified in the project panel: SQL 22 tokens, YAML 27, where YAML was plain.

* fix(dbt): stop failing no-op models, drop table triggers client-side, keep cross-selection edges

The sweep that settles a run's stranded relations was marking `no-op`, `warn`
and `skipped` models FAILED on successful runs: reconciliation reports those
nodes without settling their record, so they were indistinguishable from a model
the run never reached. It now excludes every relation the run accounted for, so
only the genuinely abandoned ones are settled.

`table` was removed from the backend's auto-trigger kinds but left in both
client mirrors, so the editor and `pipeline dev`/`docs` kept drawing cascade
arrows the deploy will not create.

`isModuleEntryPoint` scanned for the first `__mod/`, the same bug its sibling
just had: a `legacy__mod/script.ts` nested in a dbt project — dbt owns those
names verbatim — read as that script's entry point. Both now anchor on the
outermost boundary.

A script selecting a model whose parent another script builds dropped the parent
entirely, so no `dbt_edge` could reach it and the two relations sat on the graph
unconnected. The parent is now kept as an endpoint and recorded as a READ, since
this script does not build it — splitting a project across selections only
composes if the seam still draws.

* fix(dbt): don't double-run after-all tests, count only models a script builds

An `after_all` run whose test phase failed saves a `run_results.json` holding
tests alone. Retrying it reran exactly those tests — and then the test phase ran
the whole suite again, appending a second copy of every result: duplicate ids in
the run table, doubled totals. A retry whose saved results are tests alone IS
the test phase, so the suite is not run after it, and the two phases now merge
by node id rather than concatenating.

Keeping a selection's unselected parents as nodes made them count toward the
`×N` badge, whose tooltip says "materializes N models" — a script selecting one
mart claimed the staging models upstream of it, and the number grew with the
seam. The count now comes from the relations the script writes.

That change also made the cross-selection read block dead, with a comment
asserting the inverse of what now happens; it is removed, and the test that
covered it still passes on the new arm. The test I added landed between a
neighbouring test's comment and its `#[test]`, orphaning the attribute so that
test stopped running.

Two display fixes: the run page no longer shows a relation's SQL when the
provenance belongs to another project that materializes the same relation, and
the editor no longer draws an explicit `# on table://` arrow the deploy refuses.
The starter descriptor no longer promises the removed cascade.

* fix(dbt): clear untouched models, reject unknown descriptor fields

A `no-op` model was left `running` forever on a successful run. The previous
attempt at this stopped the sweep marking such models FAILED but gave them no
terminal state instead, so they simply never settled. Reconciliation now returns
what it settled and what the run reported but did not build, and the two get
opposite treatment: a relation the run left untouched has its row DELETED, which
is what the finished run's own result says about it (`relationOutcome` colours a
`no-op` nothing), so the live and settled views agree; only a relation the run
never reached at all is failed.

The descriptor accepted unknown fields, so `selcet:` was ignored and left an
empty selection — building the whole project — and a misspelled `target` fell
back to the profile's default. It rejects them now. That immediately caught two
of our own test fixtures still passing `repo:`, a field removed with the git
path, which is exactly the class of mistake it exists to stop.

`isDbtModulePath` matched `__dbt/` anywhere in a path, the third site with that
bug: `foo__mod/vendor/x__dbt/a.ts` read as a dbt project file, and the push then
looked for `foo.script.yaml` and could skip the edit.

A verbatim dbt bundle dropped any file named `*.lock` before it reached the
module map, so an authored `uv.lock` never deployed and the unmodified-project
round trip quietly lost it. The exclusion now applies only to `__mod` bundles,
where `.lock` really is the script's own lockfile — in the walker that hashes
modules too, or a change to such a file would not register as one.

Also: `langOf` fell back to `undefined`, which HighlightCode resolves to
TypeScript rather than to no highlighting, so seeds and Markdown were coloured
as code; and four comments still gave the removed cascade as the reason for
sharing an asset node, which is now lineage.

* fix(dbt): retry failed tests too, anchor the last __dbt path check

`retry_failed_nodes` only ran after the model phase, which fails before the
`after_all` test phase exists — so a project whose models built and whose tests
failed got no retry at all, exempting exactly the failure mode that separate
phase produces. The loop is now a function, called after both phases.

`isDbtGeneratedPath` matched `__dbt/` anywhere, the fourth site with that bug:
`foo__mod/vendor/x__dbt/target/a.ts` counted as generated dbt output, so
`ignoreF` excluded an ordinary module file and a module-only edit never deployed
its parent script.

`wmill sync push` still dropped an ADDED or DELETED `.lock` three branches
before the module arm, so the earlier fix only covered a first push: adding a
`uv.lock` to a deployed project was reported as a change forever and never
applied, and deleting one left it deployed. Editing worked, which is what made
the round trip look whole.

Also removes a duplicate `#[test]` that was double-registering a test and
detaching its neighbour's comment, and rewrites seven comments that still gave
the cascade as the reason for behaviour that now serves lineage only.

* fix(dbt): bound the excluded-file read, keep the retry budget job-wide

`isBundledModuleFile` read a file in full before deciding it was too big or
binary, so a project sitting next to a multi-gigabyte parquet seed loaded the
whole thing only to reject it. It now takes the size from `stat` and reads at
most the 8 KB the NUL check needs: a 191 MB file is rejected in 0.0ms at 82 MB
RSS.

Calling the retry helper after both phases gave each its own `attempts` budget,
so a job could spend double what the descriptor asked for — the bound exists
because every attempt is a real dbt invocation holding a worker slot. The budget
is now the job's, spent across whichever phases fail, and the field says so.

Extracting that helper had also placed it between `#[allow(clippy::
too_many_arguments)]` and `run_dbt`, taking the attribute off the 12-argument
function it was written for.

* fix(dbt): actually spend the retry budget

`retry_failed_nodes` looped on `while *remaining > 0` and never decremented it,
so a failing job reissued `dbt retry` — logging "attempt 1 of 3" each time —
until the job's deadline instead of `attempts` times. The decrement existed
briefly and was lost when the function was re-extracted by hand.

Claiming and counting are now one operation, `claim_attempt`, because keeping
them apart is exactly how the bound goes missing: the loop cannot iterate
without spending the budget.

Its test is bounded by its own `for` rather than by the function under test. An
earlier version collected `std::iter::from_fn(|| claim_attempt(..))`, which
against a non-spending `claim_attempt` is an infinite iterator — it allocated
until the machine died. A test for a loop bound must fail an assertion when the
bound regresses, not consume the host: it now reports `[1, 1, 1, …]` against
`[1, 2, 3]` in 0.00s.

* fix(dbt): ask before reading, not after

Bounding `isBundledModuleFile` did nothing for the bundle builder, which read
the whole file into memory and only then asked whether to keep it — so a
multi-gigabyte seed beside a project was still loaded in full just to be
skipped. The predicate is now consulted first, and the read happens only for
files the bundle actually carries.

* feat(dbt): animate the ref() edges feeding the model being built

The nodes moved during a run but the edges did not, so the graph showed where
dbt had got to without showing it flowing there.

Reuses the canvas's existing rule rather than adding a second one: an edge
animates when it touches what is happening. For a pipeline that is the running
script; for dbt the unit of work is the model, so a `ref()` edge animates while
its target builds. Same `animated` field, same visual language, no new styling.

Verified mid-run on a 7-model project: of six `ref()` edges only the two feeding
the model then building were animated, and none once the job finished.

* feat(dbt): show what each model wrote, and say when its SQL is another project's

Three things a reader wanted from the run graph and could not get.

Row counts: the worker already records one per relation and `run_progress`
already returned it, but the graph used only `status` and dropped the number. A
model that built green having emitted zero rows is the failure that looks like a
success, so the count is on the node.

The relation's fully-qualified name, copyable: there is no table browser to open,
so the next best affordance is the exact identifier to paste into a SQL client.
It is parsed with `splitRelation`, which honours quoting the way the worker's
`split_relation` does — splitting on every period renders
`"wh"."analytics.v2"."orders"` as a relation `orders` in a schema `v2`, which
does not exist.

And when two projects materialize one relation, the graph keeps a single
provenance winner, so the losing project's node carries the other's model. The
SQL was already suppressed there — correctly, it is not this run's code — but
silently, which reads as a dead click. It now says so.

* fix(dbt): a finished run's graph is the models it built, not today's project

`/assets/graph` is the current deploy, so an old run's graph drifted with the
project: a model added after it appeared as though the run had built it, and the
older the run the wronger the picture. A finished run's node set now comes from
its own result, which named exactly what it touched.

Sources survive the filter regardless — dbt never lists them in
`run_results.json` because it does not build them, but they are the upstream the
run read, and dropping them would leave the models hanging.

The graph is still the current deploy's, so a model renamed or deleted since
cannot be drawn at all. Rather than a silently shorter graph, the count is
stated above it.

Verified by adding a model after a run: the old run renders 7 models without it,
a fresh run renders 8 with it.

* feat(dbt): preview a model's rows with `dbt show`

There was no way to see the data behind a node — only its SQL and its row count.
`dbt show` selects from a model and returns rows, and every engine ships it, so
the preview needs no adapter code of ours: no connection path, no dialect-correct
quoting, no type coercion for ten warehouses. It runs against the profile the
run already renders.

It is a `dbt_command` rather than a new endpoint, so it inherits the whole job
path — authorization, isolation, cancellation, logs, engine provisioning — and
`limit` joins the run form beside it. That the allowlist can admit it at all is a
consequence of dropping the cascade: while a successful job dispatched its
deploy-time write set, a command that wrote nothing woke every consumer for
relations nothing had touched.

Read-only, and treated as such: no graph republish, no materialization records,
no retry state, no test phase. Captured rather than streamed, like `dbt ls` —
these rows are the result, not commentary, and the job-log writer is what
`NO_LOGS_AT_ALL` discards.

Verified: `{"dbt_command":"show","select":["stg_customers"],"limit":3}` returns
three rows; a preview leaves `materialized_partition` untouched (62 → 62, 0 rows
for the job); `clean` is still refused by the allowlist.

* feat(dbt): preview a model's rows from the graph, and keep our locks out of dbt projects

The run page could show a model's SQL and how many rows it wrote, but not the
data. Selecting a model now offers "Preview rows", which runs the script with
`dbt_command: show` and renders the result as a table.

Explicit rather than on-select: a preview is a job, so it costs a worker slot
and the engine's start-up, and previewing on every click would spend both on
mere navigation. Sources are excluded — dbt shows what a model SELECTs, and a
source is not one.

Also: `updateModuleLocks` was the one module helper that never learned about
verbatim bundles, so it walked a dbt project writing `foo.lock` beside `foo.sql`.
None of those files is a Windmill script needing a lockfile, and the bundle
promises to round-trip the project byte-for-byte — our artifacts have no business
in it.

Verified in the browser: selecting `stg_customers` and previewing returns the
columns `id`/`src` and five rows from the warehouse.

* fix(dbt): keep a run's models when another project owns their provenance

Scoping a finished run's graph to the ids it named dropped relations whose
provenance winner belongs to a different project — so a run of a project sharing
a schema showed 3 of the 6 models it had built. An id that was never this run's
package cannot be judged against its result, so it is kept: the relation IS one
the run wrote, and hiding it understates the run. The same rule applies to the
"no longer in the project" count, which otherwise reported deletions that were
only provenance collisions.

Previews are now cached per model and survive the selection moving. One was
thrown away whenever the reader clicked elsewhere, which for a job costing a
worker slot and an engine start-up meant re-running it to see it again — and the
run continues in the background, so leaving and returning finds the rows there.
The spinner also never span: `startIcon` takes the icon and its classes
separately, so the animation has to be passed alongside.

How long it took is shown with the rows. A preview is a job, and its cost should
not be something the reader has to guess at.

* fix(dbt): resolve argument references, clamp the show limit, flag renamed relations

`handle_dbt_job` cloned `job.args` where every other executor calls
`build_args_map`, so a `$var:` / `$res:` / `$encrypted:` argument reached dbt as
the literal string. A placeholder holding a schema or an `enabled` flag would
then build a different slice of the project than the caller asked for.

`--limit` took any positive i64, and the worker buffers the whole of dbt's
stdout to read the rows out of it — so a caller with only run permission could
make it hold an unbounded allocation. It is clamped to a ceiling now, extracted
as `show_limit` so the bound is pinned by a test rather than inline in an async
function nothing can reach.

And a model keeps its id when its alias or schema changes, so an old run's node
showed today's relation while the run wrote another — the page asserting it had
materialized a table that did not exist yet. The run's result carries the
relation each node actually wrote, so the drift is detectable without a graph
snapshot, and the count is stated above the graph. Rendering the run's own
lineage still needs a per-job snapshot; this stops the page claiming otherwise.

* fix(dbt): stop persisting resolved secrets, bound the preview by bytes

Resolving `$var:` / `$res:` / `$encrypted:` for dbt — added in the previous
commit — meant `save_run_state` wrote the resolved PLAINTEXT into
`dbt_run_state.args` and the worker's `state.json`. The row outlives the job, so
a secret stayed in the database and a later `dbt_command: retry` replayed it
after the grant was revoked or the value rotated. The invocation now carries the
args as submitted alongside the resolved ones, run state persists those, and the
restore path resolves them again under whoever is retrying.

Clamping `--limit` bounded the row COUNT, not the size: one column can hold a
megabyte, so a thousand rows is a thousand megabytes, and `run_capturing`
buffers all of it. The captured output has a byte ceiling now.

`limit` became a built-in argument without joining `RESERVED_ARG_NAMES`, so a
descriptor writing `{{ limit }}` was silently handed the preview control's
default instead of being told the name is taken.

Two display fixes: the relation-drift banner compared a canonicalized (lower
case) asset path against the warehouse's own spelling, so it fired on every
model of every finished Snowflake run; and caching a preview's failure left
`Preview rows` dead for that model until reload.

* feat(dbt): key the graph by script version so a run renders its own project

The dbt graph was keyed by path alone, so a deploy overwrote the only copy and a
run page could only ever show today's project — an older run rendered today's
models, SQL and `ref()` lineage no matter what it had run. My previous attempt
filtered that view to the ids the run named, which stopped it lying but could not
show what was gone: the data no longer existed.

`dbt_node` / `dbt_edge` now carry `script_hash` in their primary key, so each
deployed version keeps its own graph, and the run page passes the version its job
recorded. Per DEPLOY, not per run — ten thousand runs of one version share one
graph — and a composite FK to `script (workspace_id, hash)` with ON DELETE
CASCADE means a version's graph dies with it. Nothing pruned these before,
because there was one copy per path; they would otherwise have accumulated with
no sweep.

Two deploys of one path now write disjoint rows, so the graph can no longer be
lost to a race. `claim_graph_publication` remains only for what is still
path-keyed — the `asset` usage rows — and an older deploy finishing late records
its own graph before declining to touch those, where before it published nothing
at all.

A pinned request is scoped by the version's own nodes rather than by `asset`:
that table describes the current deploy, so scoping through it would filter a
model out of the very run that built it.

Verified end to end: deployed v1 (8 models), ran it, deployed v2 with four models
removed and one rewritten. The old run renders 8 models, 6 ref() edges and v1's
SQL; a new run renders 4 and the v2 rewrite.

* fix(dbt): scope graph cleanup to one version, bound the preview capture

Archive and delete both act on a single `hash`, but the graph cleanup they
called deleted every row for the path. Now that the graph is keyed per
version, archiving an old version erased the live one's models, SQL and
lineage, and nothing repaired it. Both callers have the path in hand, so the
by-hash wrapper is gone and they use the version-scoped clear directly.

`dbt show` checked its 8 MB ceiling after `wait_with_output` had already
buffered everything, so the ceiling could not bound what the worker held.
`run_capturing` now reads both pipes incrementally against a caller-supplied
limit and kills the child on overflow. The read buffers are heap-allocated:
as arrays they were baked into the future, which the job poller boxes several
layers deep, and that overflowed the worker thread's stack — a `dbt show` run
aborted the whole worker process.

A retry's `dbt parse` ran on the arguments as submitted while the build ran on
resolved ones, so a `$var:` shaping the graph parsed verbatim. The parse moves
to the caller, after resolution.

A run that names its own `select`/`exclude` now drops the descriptor's
`selector`: dbt resolves `--selector` instead of `--select`, so passing both
made a preview of one model return another's rows.

Also: log instead of silently swallowing a `modules` column that fails to
deserialize (pre-existing, but for dbt it means running with no project at
all); keep the model SQL reachable once a preview has landed; render which
node the rows came from; stringify object-valued cells; document
`dbt_script_hash` in the OpenAPI spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: record the per-worktree dev environment and the backend-run check

Three mistakes this guidance would have prevented, each of which cost a cycle:

A worktree has its own database and ports, but AGENTS.md stated the
single-checkout defaults as facts. Pointing `DATABASE_URL` at another
worktree's database makes `cargo sqlx prepare` fail on every query touching a
table your migrations added — and it deletes `.sqlx/` before it fails, so the
cache is gutted rather than merely stale. Starting a backend on the wrong port
leaves the UI up with every call 502ing, which reads as an application bug.
Both values are now discoverable with commands that work as written.

`prepare` is also documented as the wrong tool for a removal-only change: the
cache is already complete for CI, and the only residue is orphaned entries that
can be found by text-matching against the sources without a database.

Nothing told a reader that `cargo check` does not exercise a worker path. A
read buffer declared as an array inside an async block is baked into the
future, and once boxed by the job poller it overflows the worker thread's
stack — compiling and unit-testing clean while aborting the whole worker
process at runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): scope the remaining path-wide reads and clears to one version

Four places still spoke for a whole path after the graph became per-version:

The relation-root drift check read `dbt_node` by path with an unordered
`LIMIT 1`, so with v1 at root A and v2 at root B it could answer with v1's
row, suppress the refresh v2 needed, and leave v2's graph naming relations the
run does not build. It now reads this job's version.

`dbt_dep`'s no-resource branch cleared the path, so a descriptor edited to
bring its own `profiles.yml` emptied every earlier version's graph and with it
every finished run's page. The ownership being given up is the path-keyed
`asset` usages cleared beside it; the graph clear is now this version's.

The graph was inserted before the publication claim checked the version was
still live. Archive and delete only soft-update `script`, so the foreign key
still accepted an in-flight dependency job's rows and the failed claim
committed them — and because pinned queries deliberately serve archived
versions, deleted model SQL became readable again. The write is now gated on a
`FOR UPDATE` liveness check.

`clear_dbt_run_state_by_script_hash` resolved a hash to a path and deleted the
path's saved run. `dbt_run_state` is keyed by path by design — one saved run
per script — so archiving one version discarded the live version's resumable
failure. It clears only once no live version of the path is left; `identity`
already refuses a resume whose project, warehouse or engine moved.

"Preview rows" ran `runScriptByPath` while the SQL beside it was pinned to a
hash, so an old run showed its own SQL over today's rows. Verified end to end:
with v3 deployed, the v2 run's preview runs v2's hash and returns v2's rows.

Also: keep the TAIL of a captured stderr, since dbt prints its summary last;
one `$derived` for the parsed result rather than five; collapse three
near-identical argument accessors onto one generic; fold the single-use
`copy_dir_command` into its caller; and give `parseDbtRun.ts` one status
classifier instead of spelling dbt's failure vocabulary twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(dbt): one JobCtx down the executor, one table per adapter

Two changes aimed at the operations this code will keep having: adding a
phase, and adding a warehouse.

`JobCtx` already bundled the five values every phase needs, and nine functions
took it — but the top of the executor threaded the fields apart and rebuilt the
struct at each call, so the same literal appeared eight times and each new
phase meant five more parameters. It is now built once per entry point and
reborrowed. `prepare_project` goes from 21 parameters to 17, `retry_failed_nodes`
from 15 to 11, and `run_dbt` drops below the lint threshold. The two remaining
constructions are the worker boundary, where the pieces genuinely arrive apart.

`DbtAdapter` answered five questions with five parallel matches over the same
eleven variants, plus a sixth list of adapters kept by hand in a test. The
facts now live in one `AdapterSpec` per adapter, reached through one exhaustive
match, so adding a warehouse states its name, driver, package, port, database
key and licensing together and the compiler demands the arm. Each arm spreads
from a Postgres base, which makes the inheritance visible per adapter instead
of hidden in the `_ =>` defaults `default_port` and `database_key` used to
carry. `DbtAdapter::ALL` replaces the list the test kept separately.

Verified by dumping all seven facts for all eleven adapters before and after:
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): job-keyed run progress, and stop path-wide reads and clears

Six findings from the last round, in the order they bite.

The publication liveness gate refused on `archived`, but `create_script`
archives the parent on every redeploy — so deploying v2 while v1's dependency
job was still parsing left v1 without a graph, permanently, which is the exact
case the unconditional write existed to serve. It gates on `deleted` alone now;
an explicit archive is still covered by the `FOR UPDATE` ordering.

A project-owned `profiles.yml` trusted `profile.type` instead of reading the
file. The Rust engines carry every adapter, so a CE script could declare
`postgres` over a target that is `sqlserver` and have dbt connect with the
enterprise adapter. The file is read whichever way, and a descriptor that
disagrees with it is refused.

Renaming a dbt script, or editing one so its newest version is no longer dbt,
cleared the graph for the whole path — every older version's models, SQL and
lineage, which their own finished runs still render. Neither needs it: graph
queries join on `(path, hash)` through a `language = 'dbt'` CTE, so an old
version's rows cannot attach to whatever lives at that path next.

Live progress read `materialized_partition`, whose key is the relation and
whose `job_id` is only the last writer. Two runs of one project took rows from
each other. Progress now has its own job-keyed table; the relation table is
untouched, because one row per relation is right for the pipeline canvas and
fork defer. Verified with two overlapping builds: both keep 6 rows in the new
table, while the old one attributes 6 to one run and 0 to the other.

`Scratch::drop` removed a half-installed virtualenv synchronously from inside
the job future, blocking a runtime thread; it goes to `spawn_blocking`, with a
direct call when there is no runtime to hand it to.

The E2E list asked for a `# on table://` subscription the deploy now refuses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(dbt): snapshot a dynamic descriptor's graph per run

A `{{ }}` placeholder in `vars` can enable a different set of models per run, so
those runs re-ingest the graph. Keyed by version alone, each re-ingest
overwrote the last: reopening an older run showed the newer run's project, and
a model only the older run built was gone entirely — no SQL, no lineage, and
nothing the saved result could colour, since it can only tint nodes that are
there.

`dbt_node` / `dbt_edge` gain `job_id`. A run of a dynamic descriptor writes its
own snapshot under its job id and its page reads it back; a static descriptor
writes the version's graph once, under a zero-UUID sentinel, and every run of it
reads that. The sentinel is a value rather than NULL because `job_id` is part of
the primary key and Postgres does not treat two NULLs as the same key, so each
re-ingest would add a row set instead of replacing one.

`/assets/graph` takes `dbt_job_id` and prefers a snapshot when one exists,
falling back to the version's graph otherwise — so a run page passes it
unconditionally and static descriptors are unaffected. Snapshots age out after
30 days, pruned by the runs that write them, so no background sweep has to learn
about these tables.

Verified end to end: one deploy, two runs of it with `extra=yes` and `extra=no`
gating a model's `enabled`. The version's graph holds 6 models, run 1's snapshot
7 including `opt_extra`, run 2's 6 without it; the endpoint returns each run's
own and falls back to the version's when the parameter is omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(dbt): only snapshot a run whose graph differs, and prune from every run

Two costs the per-run snapshot carried, both found by measuring it rather than
by reading it.

A snapshot was written for every run of a dynamic descriptor, but marking one
dynamic is conservative: `graph_is_per_run` is true whenever `vars` holds a
`{{ }}` placeholder or `env` holds a `$var:`, which says the arguments reach dbt
and not that they change which models exist. The usual case is a date var, whose
graph is identical run after run, so the table filled with copies of an
unchanging picture — around 1 KB per model per run, which is a gigabyte or so a
month for a 200-model project on an hourly schedule. A row set now carries a
digest of its nodes, edges and relation root, and a run whose digest matches the
version's writes nothing; the read already falls back to the version's graph, so
those pages are unchanged. Only a run whose model set really differs pays.

The prune was hung off the progress reporter, which exists only for engines that
emit node events — so a Fusion or dbt-core-2x instance accumulated snapshots and
never deleted any. Retention that stops working because of an engine choice is
not retention; it runs detached from every dbt run instead.

Verified against a descriptor with a var-gated model: the version's graph holds
8 rows, a run that resolves to that same graph stores none at all, and a run
that enables the extra model stores its own 9. Both pages still render their own
project — 6 assets without the extra model, 7 with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): scope every dbt_node join to the chosen snapshot

`job_id` joined the key, but only the scoping CTE and `dbt_edge` were taught to
filter on it. The outer node SELECT and the parent/child joins in the edge query
were not, so each model came back once per retained snapshot plus once for the
version's graph, and each edge matched every combination of the two — the model
count multiplied and the edge join fanned out quadratically. Measured against
one stored snapshot: 17 node rows where 8 are wanted, and 28 edge pairs where 7
are. The response dedup hid the edge blow-up from the payload, not from the
plan, and the run page refetches the graph every two seconds.

The progress table gained writers it was missing. `terminalize_running_relations`
settled only the relation-keyed table, so a cancelled or killed run — the case
that function exists for, since it leaves no `run_results.json` — showed every
in-flight model still spinning on the run page for as long as the row lived. An
agent worker cannot write the new table at all, having no database of its own,
so the read falls back to the relation-keyed one when a job has no rows there.

Also: a wrapped string literal missing its backslash put eighteen spaces in the
middle of the profile-disagreement error; a comment still described concurrent
runs of one dynamic version overwriting each other's graph, which is what
keying by job removed; and the `materialized_partition` index justified itself
by a run-page poll that has since moved to another table, though the closing
sweep still earns it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): give a graph snapshot a marker row, and scope what reads it

Five findings, four of which are the same mistake in different places: a
snapshot's identity was inferred from its contents.

Existence was inferred from a `dbt_node` row, so a dynamic run that disabled
every model — a legitimately empty graph — read as "no snapshot" and its page
showed the deployed models instead. The digest was a column repeated on every
node and read back with a `LIMIT 1` carrying no `job_id`, so a run could compare
itself against another run's digest and suppress a snapshot it needed. The
relation-root drift check read the same rows unscoped, so after a drift it could
find a previous run's root and conclude nothing had moved.

`dbt_graph_snapshot` holds one row per stored graph — path, version, job,
digest, timestamp. Existence is that row, the digest lives there once, the drift
check reads the deployed row explicitly, and the retention sweep deletes markers
first and then the rows no marker stands for. The digest is SHA-256 rather than
`DefaultHasher`, whose output is documented as unstable across Rust releases:
this value outlives the process that computed it, so a toolchain bump would have
silently stopped every comparison matching and quietly reinstated the duplicate
snapshots the digest exists to prevent.

`/run_progress` ignored the view token, so a share-link viewer got the graph and
was refused the progress that colours it.

A preview sent only its own three arguments, so a descriptor with a required
`{{ }}` var could not be previewed at all and an overridden one previewed a
different relation than the page was showing. The run's arguments go first now,
with the preview's three overriding.

Verified on a project whose only model is var-gated: the deploy stores a marker
with zero nodes, a run with the var set stores a marker with one, and the
endpoint answers 0 and 1 respectively rather than showing the deployed models
for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(dbt): squash the runtime's migrations into one

Ten migrations reshaping the same three tables is a history no installation
ever had. `dbt_node` gained `script_hash`, then `job_id`, then `ingested_at`,
with its primary key rebuilt twice; `graph_digest` was added by one migration
and dropped by the next after the digest moved to its own table. On a fresh
database all of that replays to arrive at a shape the schema can simply state,
and this feature has never shipped, so there is no upgrade path to preserve.

One migration now creates `dbt_node`, `dbt_edge`, `dbt_graph_snapshot`,
`dbt_run_state` and `dbt_run_progress` in their final shape, carrying forward
the rationale each of the replaced migrations recorded. The enum additions stay
in `add_dbt_lang`, since a value cannot be added and used in one transaction,
and the `materialized_partition` index stays separate because it belongs to a
table this feature did not introduce.

Verified by rebuilding: dropped the five tables, replayed from the single
migration, and confirmed the result is identical — same primary keys, the same
two composite `script` foreign keys, the same seven indexes. Every `sqlx::query!`
in the workspace then compiled against it, which checks each column's name, type
and nullability, and a deploy plus run on the rebuilt schema produced 8 nodes,
7 edges, a snapshot marker and 6 progress rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): authorize snapshot reads, bound the prune, give the marker a lifecycle

`dbt_job_id` is caller-supplied and selected straight from `dbt_graph_snapshot`,
which carries no RLS — so a caller who could see the script could read any run's
model set and relation paths, which a dynamic alias or schema can encode. Both
graph queries now require the job itself to be visible, in the authed
transaction, the same gate `raw_code` already applies to the script that
produced it.

The drift check compared against the deployed graph alone, which misses the way
back: a run at root B republishes the path-keyed `asset` usages at B, and
returning the profile to A then matches the deploy and skips the refresh,
leaving those usages at B while dbt builds A. It reads the most recent ingest
for the version instead — the one that last wrote them — ordered rather than an
arbitrary `LIMIT 1`.

The prune anti-joined every non-deployed node and edge with no age predicate, so
each run scanned the whole retained sidecar and concurrent runs duplicated it.
All three deletes share one age bound again, with the sentinel spelled as a
literal so the partial indexes apply — a bound parameter cannot be proven to
match the index predicate.

`dbt_graph_snapshot` was the one dbt table nothing in the script lifecycle
deleted: no `script` foreign key and absent from both `clear_dbt_manifest*`
sites. A marker outliving its rows is read as a snapshot with no nodes, and its
digest still answers the suppression check, so an identical run would write
nothing and then render an empty graph. It cascades like the rows now and both
clears take it.

Also: the preview cleared `exclude` rather than inheriting it, since previewing
a model the run excluded reached dbt as `--select m --exclude m`; and
`terminalize_running_relations` no longer claims to cover a killed worker, which
never reaches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): quote profile names, record where usages were published, stop polling the graph

A profile name comes from the project's own `dbt_project.yml` and a target from
the descriptor, and both were interpolated into `profiles.yml` as bare YAML —
including as mapping keys. A name like `prod # hidden` truncates the mapping and
a newline opens a sibling key of the author's choosing. Both are rendered as
quoted scalars now, as are the BigQuery keyfile's keys, with a test that asserts
the document still parses to exactly the keys we wrote.

The drift check read the most recent ingest, which latches: a run that returns
to the deployed root re-ingests but stores no snapshot (its digest matches the
version's), so the moved run's rows stay newest and every later run pays an
extra parse and ingest. The publisher now records the root it published the
path-keyed usages at, which is the only thing that answers "where do the current
usages point" — the deploy's own root goes stale as soon as a run republishes.

The run page polled `/assets/graph` every two seconds alongside progress, so it
re-sent every node's SQL for the length of a run — hundreds of KB a tick on a
real project, for a graph that a dynamic descriptor re-ingests exactly once
before the build. It fetches once more shortly after mount and then polls
progress alone.

Node results carry `outcome` beside `status`. `status` stays dbt's own word, but
dbt owns that vocabulary — 1.x and 2.x differ on casing and `no-op` arrived in a
minor release — so publishing only it would force a break or a lie the first
time it moves. `outcome` is the stable half a downstream script branches on.

Also: the worker's dbt entry points are `pub(crate)`, since nothing outside the
crate calls them and they resolve secrets and launch processes; and the snapshot
gate records that it is RLS-only where `/jobs/run_progress` also honours a
share-link token, which is a gap in what a shared page shows rather than in what
it protects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(dbt): pin the graph storage invariants against a real database

Every defect review found in this area was DB-shaped — which row set a read
resolves to, which rows a clear takes, whether a snapshot exists at all — and
none of it is reachable from a unit test on a pure function. Four rounds
established these answers and nothing guarded them, which is why each round kept
finding another.

Six cases, on the harness the repo already uses for schema-shaped behaviour:
an identical run stores no snapshot and leaves no marker; a differing run keeps
its own while the version's is untouched; an empty run graph is still a snapshot
rather than an absent one; clearing one version leaves the others whole; the
path-wide clear takes the markers with it; and the sweep ages out run snapshots
while never touching a version's own graph.

`IngestedNode` gains `Default` so a test can state the two fields a case is
about rather than the eighteen it is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* perf(dbt): bound a script's stored graphs by deploy count

Run snapshots expire on a clock, but a VERSION's graph could not: its reader is
every finished run of that version, and a run page is as old as its job. So
nothing reclaimed them — a deploy graph went only when its `script` row was hard
deleted, which Windmill does not routinely do. A CI deploying on every commit
added a full model set with SQL bodies per commit, forever: roughly 200 KB a
deploy for a 200-model project, which is gigabytes a year across an instance.

Bounded by COUNT instead of age, since age is the thing that cannot be right
here. The newest 50 deploys per path keep their graph and older ones are
reclaimed, making growth `versions x models` rather than unbounded in time.
Generous on purpose: reaching the bound empties that version's run pages, so it
exists to stop unbounded growth rather than to be hit in normal use. Ordered by
the script's own `created_at`, so a late-finishing job re-ingesting an old
version cannot promote it.

Pinned by a test that deploys past the bound and asserts both halves: the count
holds, and the newest version is always among the survivors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): let the run page know when its snapshot has landed

The one-shot graph refetch was wrong: a dynamic descriptor's ingest happens
before the build but after cloning, dependency install and parse, so a fixed
delay either fires too early — and the run page then shows the deployed models
for the whole run, never that run's own — or keeps re-sending the whole graph
for the length of it. Neither is a timing problem to tune; the page had no way
to tell "the snapshot is not written yet" from "this run has none".

`/assets/graph` answers that directly: `dbt_snapshot_job` is the job the dbt half
resolved from, when one was asked for and found. The page polls the graph until
that is its own job, and stops. A static descriptor never snapshots, so an
attempt cap ends it there rather than polling for the run's duration.

`dbt_node.relation_root` is gone. The drift check moved to the marker's
`published_relation_root`, which left the column written on every node and read
by nothing.

`outcome` was published as the stable half of the result contract, but the
in-tree consumer still ranked and coloured from dbt's own word — so the field
existed and nothing used it. `statusRank` takes it, `DbtRunResult` passes it, and
`classifyStatus` is documented as the fallback for results that predate it and
for the live event stream, which carries dbt's word alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(dbt): record what a share-link viewer actually sees

The comment at the snapshot gate said the graph "falls back to the deployed
set", which is only the rarer half of it. A share link is an extra grant for a
logged-in user who lacks access to the job, so the usual case is no read on the
script either — and then the `live` CTE matches nothing and the whole dbt half
comes back empty. A blank Models panel over working progress rows, not a
fallback.

`docs/dbt-runtime.md` now carries the analysis a follow-up needs: that relaxing
this leaks nothing, because `v2_job_completed.result` already gives that viewer
every node's `unique_id` and `relation_name` — the graph's only incremental
exposure is `raw_code`, which is gated separately on seeing the script. And the
shape of the fix: `OptViewToken` and `validate_view_token` are self-contained
enough to move into `windmill-api-auth`, which `windmill-api-assets` already
depends on, after which the gate can honour a token for that job's snapshot
alone while `raw_code` stays where it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): keep model SQL behind the scripts:read scope, and unbreak CI

`/assets/graph` is authorized as `assets:read`, and RLS decides whether the
caller can see the script that produced a node — but RLS is not a scoped
token's grants. A token deliberately narrowed to `assets:read` could therefore
read model source and repository paths for scripts outside its `scripts:read`
paths. The same `build_scope_path_predicate` the macro endpoint already applies
now gates `raw_code` and `original_file_path`; the relation's shape is
unaffected, only its body is withheld.

`DbtAdapter::ALL` exists for the tests that must cover every adapter, so it is
dead in a release build and `-D warnings` failed all four backend checks on it.
It is `#[cfg(test)]` now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): stop the graph poll at the ingest, and type `limit` in the schema

The poll's stop condition was a snapshot appearing, with a 40-attempt cap
behind it — so a STATIC descriptor, which never snapshots, took the cap every
time and re-fetched the whole graph forty times. That is most of what removing
the poll was meant to save, and static is the common case.

The ingest runs BEFORE the build, so the first model to report progress proves
it has already happened: a snapshot absent by then is one this run never
writes. Progress arriving is now the second exit, and the cap is only a
backstop for a run that reports none at all.

`limit` is declared `Typ::Int` but `dbt_arg_schema` had no integer arm, so the
run form and the generated clients saw an untyped default and offered no
numeric control for a value the worker clamps. Covered by the schema test.

`relationOutcome` still re-derived from dbt's word while `statusRank` had moved
to `outcome`; both read it now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): make a retry prove its arguments still resolve the same

The saved arguments are the ones SUBMITTED, so a `$var:` in them is re-resolved
on retry. The identity did not cover the resolved values, so a variable that
changed between the failed run and the retry was accepted — and which graph the
retry then used depended on WHERE it landed: a worker holding the local
snapshot replays the saved manifest, while a database restore reparses with the
new value. Placement decided whether the resumed failures described the
relations being built.

The identity gains a digest of the resolved arguments, and is compared in two
halves because resolution happens between them. Project, warehouse, engine and
env are checkable up front; the arguments are not, because a retry request
carries only `dbt_command` and the ones to compare are the SAVED arguments after
this caller has re-resolved them. Comparing the whole string up front would have
refused every retry — which is what the obvious version of this fix does.

A row written before the digest existed has no last segment, and still restores
rather than being refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): keep pre-upgrade retries working, and stop losing a late snapshot

Splitting the identity on its last `|` read a pre-upgrade row's env digest as an
arguments digest and left only `<run_identity>` as the prefix, so every saved
failure on an upgraded instance became unretryable — a regression the previous
commit's own test missed by using an identity with no `|` in it at all, which is
not what an old one looks like. The digest is tagged (`|args=`) rather than
positional, and the test now uses a real pre-upgrade identity.

The graph poll gave up after a bounded number of tries, but provisioning and
`dbt deps` precede the ingest and can outlast that on a cold worker — and the
engines that emit no node events never produce the progress that ends it early.
A finished run now reloads the graph unconditionally, and the poll's own exit
issues one last load: progress proves the ingest happened, not that the previous
tick saw it, and dbt's compile window is wider than one tick.

A `dbt retry` restores the failed run's arguments inside the worker and they are
never written back to the retry job, whose own args are just
`{"dbt_command": "retry"}` — so previewing a row on a retry's page ran without
the vars the run used. The result now carries the invocation's arguments as
SUBMITTED, so a `$var:` stays a reference and no resolved value is published.

The deploy-count sweep ran instance-wide on every dbt run: `FROM script WHERE
language = 'dbt'` has no index to stand on, and both orphan deletes are the
complement of every partial index here. It is scoped to the running script's
`(workspace_id, path)` — which `index_script_on_path_created_at` serves — and
the orphan deletes only run when a marker actually went.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): hide dbt from module-less pickers, and stabilise the retry digests

`processLangs` feeds every language picker, including flow steps and app inline
scripts. Those are raw bodies with nowhere to carry a module bundle, and a dbt
script IS its bundle — so choosing dbt there produced a job that could only fail
once the worker looked for `dbt_project.yml`. Those two surfaces use
`processInlineLangs`, which drops the languages that need modules; a flow still
reaches dbt the way it reaches any script, by path to a deployed one.

`graph_digest` moved to SHA-256 because it is persisted and compared by a later
worker, and `DefaultHasher` is documented as unstable across Rust releases — but
the retry identity's own digests were left on it, and they are persisted in
`dbt_run_state.identity` for exactly the same comparison. A toolchain bump would
have refused every saved failure as a different project. All three go through
one `stable_digest`, length-prefixed so no split of the same bytes collides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): enforce the tag scope on snapshot reads, reset state between runs

A tag scope is an orthogonal hard restriction: a token limited to some tags must
not read a job outside them however else it is authorized. The snapshot lookup
went through `v2_job` RLS alone, which knows nothing about tags, so such a token
could still retrieve a run's model set and its dynamic relation paths. The same
predicate `require_job_read_access` applies for the progress half of the page is
applied here — `get_scope_tags` is already public in `windmill-api-auth`, and it
is `None` for an unscoped caller, so a normal session pays nothing.

SvelteKit reuses the run graph between run ids, and `graphTries`, `polled` and
`raw` all describe the previous job: a spent retry count stopped the next run's
snapshot poll before it began, and stale progress coloured its models with
another run's statuses. All three reset when the graph key changes.

Also a wrapped string literal missing its backslashes, which put two ~22-space
runs in the middle of the retry-refusal message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(dbt): put each digest helper's rationale on its own function

Inserting `stable_digest` above `split_identity` split that function's doc, so
five lines describing where the identity divides ended up introducing the
hasher. Each is back on the function it describes, stated once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(dbt): read the run-pinned graph through the job, not the asset graph

Pinning the asset graph to one run is job-scoped data, but `dbt_job_id` sat on
`/assets/graph`, authorized as `assets:read`. The job-read contract —
`require_job_read_access` — is five parts that pull in opposite directions (tag
scope restrictive, `created_by` permissive, app-embed restrictive-overriding,
view token permissive, RLS underneath), so plain RLS is neither a stricter nor a
looser approximation of it. Restating the parts near the graph query kept leaving
one out: first the job check entirely, then the share-link asymmetry, then the
tag scope, and the app-embed restriction was still missing and failing open.

The helper cannot be called from `windmill-api-assets`, because `windmill-api`
depends on that crate. So move the read instead of the check: the run-pinned
graph is now `GET /w/{w_id}/jobs/dbt_graph/{id}` in `windmill-api`, on the same
gate as the `run_progress` it colours, and `/assets/graph` has no `dbt_job_id`
parameter at all.

- `asset_graph_for` takes the job as an argument from an already-authorized
  caller; the route handler passes `None`.
- Extract the graph response into a `AssetGraph` component schema, now that two
  paths return it.
- The run page fetches the job route when it has a job id.

* fix(dbt): charge assets:read on the run-graph route, trust the job gate in SQL

Round 13 findings on the route moved last commit.

The scope domain comes from the URL segment, so putting the read under `/jobs`
asked a scoped token for `jobs:read` alone while returning asset-graph data that
`/assets/graph` charges `assets:read` for. A token narrowed to polling run status
could read workspace topology, and the missing-job fallback made it cheaper still
— any random UUID skipped the job gate. Both scopes are now required: the job
gate reaches this run, `assets:read` reaches asset data at all.

The `chosen` CTE re-decided job visibility under plain RLS after the caller had
already passed `require_job_read_access`. It could only disagree, and did so
silently by falling back to the deployed graph — a share-link viewer entitled to
the run was shown a different run's model set. Dropped; the contract is that a
job reaching `asset_graph_for` is already authorized.

Also: the flow editor's `+` insert menu still offered dbt (the third
`processLangs` caller, missed when the other two moved to `processInlineLangs`),
the docs still described the deleted `dbt_job_id` parameter, and the new handler
had again been inserted between `get_run_progress`'s doc comment and its
function.

* fix(dbt): resolve a pinned run's version from the job row, not script RLS

Local codex review of the branch.

A share-link viewer is entitled to the run and usually has no grant on the
project — that is what the link works around. The graph's `live` CTE resolved the
version by selecting `script` inside the viewer's RLS transaction, so it answered
for their access to the project rather than for the run they were given: the
Models panel came back blank beneath working progress rows.

A pinned run now takes its path and hash from the job row the handler already
read after authorizing the job, so `live` does not consult `script` at all.
`raw_code` keeps its own `EXISTS` against `script`, so the model bodies stay
behind access to the project. Verified under RLS as an unprivileged role: the
shape query goes 0 rows -> 1, the `raw_code` gate stays 0.

Taking the version from the job also means a caller can no longer pin one
project's version while naming another's run, since `dbt_script_hash` is ignored
when a job is given.

The run page's graph fetch is a raw `fetch`, which bypasses the interceptor that
adds `X-View-Token` to generated-client calls, so a shared page was refused
before any of this mattered; it goes through `appendViewToken` now.

Also trims three comments to the AGENTS.md limit, dropping drafting-history
rationale that belongs in docs/dbt-runtime.md.

* fix(dbt): snapshot vars-overridden runs, carry the pinned version everywhere

Second local codex pass.

A `vars` run argument overrides the descriptor's, and vars drive `enabled`,
alias, schema, database and materialization — so such a run builds relations the
deployed graph does not describe. It now snapshots under its own job id, which
per-job keying makes safe: the version's graph stays for runs that did not
override. The old comment claimed gating on it would strand the override's graph
for the next default run, which was true only when the write went to the
deployed slot.

Two sites still read the caller's `dbt_script_hash` instead of the version
resolved from the job, so `/jobs/dbt_graph/{id}` without that redundant
parameter dropped models the run's version had and a later deploy removed.

The `dbt_snapshot_job` marker re-checked `v2_job` under RLS — the recheck the
graph query itself drops. A share-link viewer got the right graph and a null
marker, so the run page refetched it 40 times before giving up.

`wmill script preview` read the bundle with the generic `__mod` suffix and
script-module parsing, so previewing a `.dbt.yaml` omitted the project and failed
on the missing `dbt_project.yml`. It uses the same suffix and verbatim read as
deploy.

* fix(dbt): key retry state by principal, not by script path alone

`dbt_run_state` held one row per (workspace, script path), and a retry replaces
the caller's arguments with the saved ones. Anyone able to run the script could
therefore retry whoever ran it last, replaying that run's literal `select` and
`vars` against the warehouse and publishing them as their own job's
`invocation_args`. Running the script was already theirs to do; seeing another
principal's arguments was not.

`permissioned_as` joins the key, so a retry resumes only state written under the
same authority. Two runs sharing an authority can already act for each other, so
this is the boundary that matches the rest of the job model.

* test(dbt): pin what a caller without access to the project sees of its run

The share-link case had no regression guard, and every fix in this area touched
one of its two halves: the graph's SHAPE has to survive a caller who cannot read
the script, and the model SQL must not.

Two cases against a real database, calling `asset_graph_for` as a member with no
grant on the project's folder: pinned to a run, the models render and `raw_code`
is withheld; unpinned, the same caller sees nothing of it, so making the first
work did not relax the second.

Both assertions were checked by mutation — reverting the `live` bypass empties
the graph, and dropping the `raw_code` script gate leaks `select 1` — so neither
passes on the code it is meant to catch.

* fix(dbt): key the worker-local retry cache by principal too

Keying `dbt_run_state` by `permissioned_as` left its worker-local twin keyed by
workspace and script path alone, so the boundary held only where the database row
was consulted. An agent worker never reads that table — `Connection::Http` leaves
`latest_job` as `None` — so there the local cache was the whole boundary and it
had none: the next principal to retry the script on that worker restored the
previous one's `select` and `vars`.

Also records the sqlx `--all-targets` trap in the update-sqlx skill: it is needed
for queries inside tests, and in a CE checkout it aborts on `tests/otel.rs`
(EE-only `otel_ee`) after having already emptied the cache.

* fix(dbt): log a dropped retry-state save, correct the run-progress contract

Saving retry state is best-effort — losing it costs a retry, not the run that
just finished — but `.ok()` dropped the reason too. The only symptom was `dbt
retry` reporting nothing to resume, which reads as a bug in retry rather than a
failed write. Found by running a real failing build against a worker whose
binary predated the `permissioned_as` column: the insert violated NOT NULL and
said nothing.

The run-progress endpoint's OpenAPI description promised an empty list for a
caller who cannot see the job. It is refused instead; an empty list means the job
recorded nothing yet or is unknown here.

* fix(dbt): return the retry-state write failure the warning was added to report

`save_run_state` discarded the insert result, so the caller's warning could never
fire and a lost retry row stayed silent — the symptom being `dbt retry` finding
nothing on another worker.

The error is held rather than returned at once: the worker-local copy is what an
agent worker resumes from, so a failed insert must not cost that too. Every exit
after it surfaces it, including the ones that give up on the local save.

* fix(dbt): decide a retry's graph from its restored args, keep local state in step

Three from the seventh local review.

A retry submits only `dbt_command`, so the vars-override check ran against an
empty argument set and left `graph_is_per_run` false. The failed run's arguments
are restored afterwards, and those are what the retry builds with — an overridden
one wrote no snapshot for its own job and its page fell back to the deployed
graph, showing the wrong enabled models, aliases and schemas. The decision is
re-asked once the restore has happened.

A failed durable write no longer publishes the worker-local generation either.
`restore` accepts a local generation only when the database row names it, so
publishing one the database never recorded made this worker reject its own newest
state and resume the previous run's — its selection and vars, or "nothing to
retry" if that one had succeeded. An agent worker attempts no durable write, so
it keeps its local copy as before.

A rename that also converts away from dbt moved the old path's retry state onto
the new one, reinstating what the conversion had just cleared and leaving one
user's arguments and results under a path no dbt script occupies. It moves only
while the destination stays dbt, and clears the source otherwise.

* fix(dbt): drop retry state when a run produced none, let module pushes fail loudly

Three from the eighth local review.

A run that never wrote `run_results.json` — cancelled, timed out, or dead before
dbt got there — left the PREVIOUS run's state authoritative in both the database
and the local pointer, so a later `dbt retry` resumed that older invocation's
failed nodes. Producing nothing resumable now clears both copies, so neither can
answer for the other.

`wmill sync push` wrapped the descriptor lookup and its deployment in one
try/catch meant for a missing parent. Any API failure or invalid descriptor was
reported as "no parent found" and swallowed, so a module-only push exited zero
with the remote project unchanged. Only the lookup is tolerated now.

Also condenses a comment that narrated how earlier status comparisons behaved.

* fix(dbt): forget retry state on pre-build exits too, drop cascade claims

A dynamic run whose pre-build `dbt parse` or graph ingest fails returns before
the save that clears stale state, so the previous run stayed authoritative in
both the database and the local pointer and `dbt retry` resumed ITS failed nodes
— writing relations the run that just failed never touched. Both exits now
invalidate, through one helper shared with the no-artifact case.

Two frontend comments described dbt producer rows as driving cascade dispatch.
The executor returns before dispatch for every dbt job and deployment rejects
`table://` subscriptions, so they promised behaviour that cannot occur; they
describe the lineage and ownership that is actually retained.

* fix(dbt): let the database decide retry state where it is reachable

A SQL worker treated "no `dbt_run_state` row" as no opinion and accepted any
worker-local generation. But no row is the authoritative answer that the last
invocation left nothing resumable, so a local pointer that outlived it — an
unlink that failed, a process killed between the delete and the removal, a stale
cache — resurrected a replaced run and let `dbt retry` write relations it never
touched. An agent worker keeps accepting its local copy: it has no authority to
consult.

Invalidation failures are logged rather than dropped, since a silent one is
exactly what leaves the pointer behind.

* docs(dbt): record how to run an agent worker locally, keep archived graphs

Every step of standing one up fails as something else: a normal build cannot
start one at all, the server's routes need a separate feature, and all three
token mistakes surface as a bare 401 on the agent with the reason only in the
server log. Written down with the error each produces.

Also keeps a dbt script's graph when it is ARCHIVED rather than deleted. The
pinned read resolves versions through a CTE that already skips archived rows, so
clearing bought nothing and emptied the Models panel of every completed run of
the project. Deletion still clears it.

* feat(dbt): let an agent worker publish its graph, through one endpoint

An agent worker was refused any dbt script whose profile comes from a Windmill
resource — the common case — because it could neither read the stored relation
root to check for drift nor re-ingest a corrected one.

Those look like two needs but collapse into one: verification exists only to
decide whether the stored graph still describes reality, so a worker that can
PUBLISH never has to ask. It stores what it just parsed.

`POST /api/agent_workers/dbt_graph/{workspace_id}` is the whole addition. It
wraps the same `replace_dbt_manifest` the SQL path calls, so digest suppression,
the marker write and retention cannot drift between the two transports, and it
refuses a job the token's tags do not cover. `IngestedManifest`/`IngestedNode`
gain Deserialize to cross the wire.

Two guards go, both now false: the pre-build refusal, and the `Connection::Sql`
gate added earlier to stop a `vars` override grounding an agent run.

Live progress stays SQL-only — that is a per-model event stream, and routing it
through the API would mean a round trip per node.

* docs(dbt): warn that a differing cargo feature set swaps the shared binary

* docs(dbt): record the verified agent-worker behaviour and the tmpfs quota trap

An agent worker now runs a dbt job end to end, retries, and publishes its graph
— confirmed with a dynamic descriptor whose per-run snapshot came back through
the new endpoint. The doc said it was refused; that was true before the endpoint
existed.

Also `WINDMILL_DIR`: on a dev box the job dies with `Disk quota exceeded (os
error 122)` writing the project's files while `df` shows free space AND free
inodes, because /tmp is a tmpfs carrying a per-USER quota. Point the worker at a
real disk rather than trying to clean up beneath it.

* chore(dbt): pin the EE revision carrying the agent graph endpoint

* fix(dbt): bind the published graph to the job, break the completed-page poll loop

Five from the thirteenth local review.

The EE endpoint took `script_path` and `script_hash` from the payload and checked
only that the supplied job carried one of the agent's tags, so an agent holding
any matching-tag job could name another script and replace its graph. Both are
read from the verified queue row now and the request carries only the job id. A
raw preview has no version, so it no-ops rather than 422ing before dbt runs.

`IngestedManifest`/`IngestedNode` take `#[serde(default)]`: they were
serialize-only, and a field the serializer skips made the whole manifest
unparseable on the receiving side.

A completed run page fetched the graph forever — `load()` assigns `raw`, which
recomputes `settled`, which re-entered the same effect. The final fetch is keyed
to the job by a plain (non-reactive) variable, and `settled` is read untracked.

The pin now names a revision that compiles: the previous one still called
`authed.tags()`, a method that does not exist, because both that fix and the JSON
response landed after it was committed.

* fix(dbt): keep a run snapshot out of the script's deployed ownership

Everything `persist_ingest` writes after the manifest is keyed by PATH — one row
set per script, describing what is deployed there. A run snapshot was still
reaching it, so a one-off `vars` override republished that invocation's relations
as the script's ownership and the workspace graph stayed on the override's
schemas and aliases: an ordinary run of a static descriptor never ingests again
to correct it, so only a redeploy would. A snapshot now stops after recording its
own rows.

The row preview also selected a bare model name, which dbt resolves across every
installed package while `show` takes a single node — a project model sharing its
name with a package's was previewed wrongly or refused. It selects the
package-qualified FQN.

* fix(dbt): forget stale retry state when preparation itself fails

`prepare_project` runs before every path that could clear it, and it fails for
reasons unrelated to the saved run — a profile that stopped resolving, a
provision cancelled, packages that will not install. The invocation still left
nothing resumable, so the previous one must not stay authoritative: a repaired
project would otherwise let `dbt retry` rebuild an older run's selection and
write relations the latest invocation never reached. A retry is exempt, since it
is trying to use that state and failing to prepare says nothing about it.

Also corrects the runtime doc, which still described agent workers as unable to
run dynamic descriptors or Windmill-resolved profiles. They publish their graph
through the API now; what they do not get is live progress and a durable retry
row, and the doc says so.

* fix(dbt): spell the whole FQN for preview, bound retained retry generations

The FQN selector added last commit was `<package>.<name>`, but a dbt FQN is the
resource's path within its package and the matcher must consume the selector and
end on equal lengths — so it matched nothing for a model under `models/marts/`,
which is the layout most projects use and the one this repo's own complex fixture
has. The middle segments come from `original_file_path`, whose first element is
the resource root the FQN excludes. Without a path it falls back to the bare
name: ambiguous across packages, but a selector dbt resolves rather than rejects.
Tested on a nested model, which is the input that separates the three spellings.

Superseded retry generations were removed only when a later run published one,
and never inside the hour-long grace period — so a burst left a manifest and a
results copy per run with nothing afterwards to collect them. At most four now
sit in the grace window, oldest evicted first.

* fix(dbt): scope preview state to the run, seed the project on a language switch

Previews are keyed by `unique_id`, which is the same string for the same model in
every run, and the run-change effect reset only the graph and progress. Opening a
second run of one project therefore showed the previous run's rows immediately,
and `runPreview` treated them as cached and refused to fetch. A generation
counter also drops a preview that resolves after navigation, which the reset
alone cannot catch.

The dbt project was seeded only by the empty-script bootstrap, but dbt is in the
ordinary language picker: reaching it by switching a draft produced a script with
no `dbt_project.yml`, which the runtime refuses to deploy or run. Both entry
points seed now, and neither touches modules that already exist.

* chore(dbt): cache the agent graph endpoint's query for the EE offline build

* fix(dbt): publish the graph a moved profile built

A run snapshot stopped before everything `persist_ingest` keys by PATH, which is
right for a one-off `vars` override and wrong for the other two reasons a run
re-ingests. `graph_is_per_run` was one bool for all of them, and the profile
drift check both sets it and reads back what the publisher recorded: a profile
moved A->B was detected by every run forever, each paying a `dbt parse` for a
snapshot nobody reads while the asset rows went on naming schema A.

The reason is carried now (`GraphRefresh`), and it decides both writes. Drift is
the version's own move, so it rewrites the VERSION's graph and republishes the
ownership that ends the drift; a dynamic descriptor snapshots under its job id
and still publishes; anything the CALLER scoped — an overridden `vars`, a
narrowed `select` — snapshots and publishes nothing, so one invocation's subset
can neither stand as what the script owns nor drop the models it left out from
the version's graph. Where they meet the caller wins, and the next ordinary run
settles the drift.

A restore also rebuilt `run_results.json` by copying the generation directory a
second time, so a burst of saves pruning it mid-restore left `dbt retry` with
nothing to resume and a job that reported success. It is written from the bytes
the restore already read; a manifest that went the same way falls back to the
parse a database restore pays anyway, and a generation that vanished before
either read falls back to the database's row for that same run instead of
reporting there is nothing to retry.

* fix(dbt): select a row preview by package, not by file path

The preview built dbt's FQN by dropping one segment of `original_file_path`,
which assumes the model root is `models/`. A project setting
`model-paths: ["src/models"]` turned `src/models/marts/orders.sql` into
`pkg.models.marts.orders`, and dbt's matcher — equal lengths, compared from the
front — resolves that to nothing: the preview came back empty for every model in
the project.

It selects `<name>,package:<pkg>` instead. The comma is dbt's intersection
operator, so this names the node by its own name and the package it belongs to,
which is what the FQN was reaching for and needs no knowledge of the resource
root. Verified on dbt-core 1.12, dbt-core 2.0.0-alpha.5 and fusion
2.0.0-preview.202, including a package shipping a model whose name the root
project also uses.

* fix(dbt): refuse a lockfile version that is not one, keep a named selector

Two things a preview reaches that a deploy does not vouch for.

A raw preview submits its own `lock`, so `engine_version` arrives from the
caller and was interpolated straight into the engine cache path — `../..` in it
made the download, extraction and rename land anywhere the worker can write,
and provisioning runs on the host rather than inside the dbt jail. Both it and
`adapter_version` (a pip requirement) are now accepted only as a plain version
token.

`effective_selector` also read any submitted `select`/`exclude` as an override
of the descriptor's named selector. The generated run form posts a default back
for every field the caller left untouched, and a selector descriptor's `select`
default is `[]` — so pressing Test, saving a schedule or firing a webhook built
the WHOLE project instead of `--selector nightly`. An override is now one that
DIFFERS from the descriptor's own value; a run that wants the whole project
despite the selector asks with `["*"]`.

* fix(dbt): let a moved profile settle, from the runs that actually happen

Two ways the drift check could never come to rest, both verified against a real
run of a real project on a normal worker.

`add_caller_args` read any submitted `select`/`exclude` as a caller's narrowing.
The generated run form posts a default back for every field left untouched, so
every run from the UI, a schedule, a webhook or a flow step carried them and was
marked caller-scoped: with the profile moved A->B, each one stored its models
under its own job id and left the workspace graph — and the root the check reads
back — at A. Since no UI run omits the field, the "an ordinary run settles it"
escape hatch was unreachable. Both this and `effective_selector` now ask one
question, `selection_is_overridden`: DIFFERENT from the descriptor's, not merely
submitted.

The root was also recorded beside the path-keyed publication rather than beside
the graph it describes, so a version that cannot claim the path — an older one
run by hash, a deploy overtaken by a newer one — rewrote its graph at the moved
root and recorded nothing. Its next run then compared against a root that was
absent or two moves stale and skipped the refresh its own run page needed. It is
written wherever the deployed row's graph is.

Verified end to end: same UI-shaped arguments before and after, the moved
profile now republishes (asset rows and version graph both move to the new
schema), a second run detects nothing and re-parses nothing, and moving the
profile back settles it again.

`prune_dbt_run_graphs` also ran from runs alone, while a deploy writes a whole
node set of its own, `raw_code` per model included: a project redeployed on
every push by CI and run nightly kept one full graph per push until the next
run, and one deployed but never run kept them for good.

* fix(dbt): drop a self-dependent effect in the run graph

`previewGen` was `$state` written by the effect that also reads it, three lines
under a `finalLoadFor` that is a plain `let` for exactly that reason. Nothing
reactive reads it — the only reads are inside `runPreview`, a plain async
function — so it becomes a plain `let` too.

* fix(dbt): pin a retry to the engine versions it resolved

`run_identity` carried the engine KIND but not the version it resolved, nor the
dbt-core 1.x adapter's. Redeploy an unchanged project after a release and it
locks a newer dbt or adapter while the saved `run_results.json` still passes the
check, so `dbt retry` feeds one version's artifacts to another — the exact
reproducibility the lockfile exists to hold. Both resolved versions are in the
identity now; a real failure and retry still resumes.

Also drops three comments that outlived what they describe: two said `[]`
clears a descriptor's selector, which `selection_is_overridden` reversed, and
one pointed at an agent-worker guard that no longer exists — the agent path
reaches the ingest deliberately and publishes through the API.

* fix(dbt): discard a run graph the page has already navigated away from

The component is reused across runs, so a slow `/jobs/dbt_graph` or progress
response could land after the reset and put the previous run's models, statuses
and failure state on the current run's page, where nothing would fetch again to
correct it. Every response is now checked against the generation it was
requested under — the counter the preview path already used, renamed for what
it means.

The graph poll also backs off. Neither of its stops is reachable for a whole
class of runs — `dbt_snapshot_job` never matches a static descriptor, and
`polled` stays empty for the engines that emit no node events — so an ordinary
run walked to the cap, re-sending every model's SQL 40 times in two minutes.

* fix(dbt): forget the previous run when the durable save fails, keep quoting

`save_run_state` returns the database error when its upsert fails, which leaves
run N-1's row and local generation in place: same project, same arguments, so a
`dbt retry` matches them and resumes an older attempt's failed nodes against
this checkout — the outcome the no-results branch twelve lines above calls
`invalidate_run_state` to prevent, reached by another door. It now goes through
the same call. Best effort, since the delete goes to the database that just
refused a write, but the local pointer is what a retry landing back here reads.

The run page also rejoined a relation's parts after `splitRelation` stripped
their quotes, so the one name the button exists to paste —
`"wh"."analytics.v2"."Order Items"` — was copied as something no client
resolves. It copies `relation_name` verbatim.

And a source on a finished run was called another project's: the check that
guards against two projects claiming one relation asks whether this run executed
the node, and a run executes no sources — they appear in no `run_results.json`.
Nothing materializes a source, so that warning could never be true of one.

Docs: the `vars`-override paragraph still said such a run does not refresh the
graph, which the table above it contradicts — it refreshes under its job id and
publishes nothing.

* fix(dbt): keep the asset rows and the version's models describing one graph

The workspace graph takes an asset's relations from the path-keyed `asset` rows
and its models, SQL, tests and lineage from the version's `dbt_node`/`dbt_edge`.
A dynamic descriptor published the former while storing the latter under its own
job id, so a placeholder that moved an alias or a schema left the current graph
with assets no model stands behind — nothing dbt contributes to them survives.
Ownership is published exactly when the VERSION's graph was written now, which
is the only state in which the two agree. Two cases are settled elsewhere by
design: an override's relations are a one-off, and a dynamic descriptor at a
moved profile keeps the deploy's ownership until a redeploy — its runs each show
their own models and it re-parses regardless, so the undetected drift costs it
nothing it was not already paying.

An agent worker has no durable row, so its local `current` pointer is the whole
of what a retry reads — and every local publication failure returned success
with the PREVIOUS run's pointer still in place. Where a row exists that is
harmless (`restore` takes a local generation only when the row names it), so the
abandonment is scoped to the agent case.

A failed `dbt show` also cleared the retry state: the preparation-failure exempts
`retry` but not a read-only command, and the run page's row preview is exactly
that, run as the principal the state is keyed by — so a preview that could not
provision took the retry away from the run being looked at.

Frontend: the run-change reset left `loading` and `failed` behind, so the gap
before the next run's answer rendered "no models in the asset graph" — a claim
about the descriptor — over a project that is fine. And three derivations argued
from "the graph is the current deploy", which the pinned endpoint made untrue;
each is still needed, for the version-graph rewrite and retention reasons now
written down.

* fix(cli): let --skip-scripts cover a script's module files

The module shortcut in `elementsToMap` maps the file and `continue`s before
every skip filter, and a module is deployed as part of its parent script — so
`wmill sync push --skip-scripts` still pushed the script whenever one of its
modules changed, and pull still overwrote them locally. Harmless while a module
was a rare helper file; every file of a dbt project is one of these now.

* docs(dbt): a dynamic descriptor's ownership stays the deploy's

* fix(dbt): read the run out of a failure whose message has braces of its own

`parseDbtRun` anchored on the FIRST `{` in the error message and parsed
everything after it. The worker appends the structured result after the error
text, and dbt's errors carry braces — a Jinja template, the compiled SQL, an
adapter's own JSON — so the failures most worth reading were the ones whose
summary and per-node outcomes the run page dropped. Every brace is tried now,
bounded, and the first that parses as a run wins.

Pins the EE revision that gives the agent publish endpoint the deleted-version
guard the SQL path takes: deletion is soft, the foreign key still accepts graph
rows, and the pinned graph query serves non-live versions, so an agent finishing
during a delete put a deleted project's model SQL back on screen. The query is
byte-identical to `persist_ingest`'s, so the offline cache already covers it —
verified with a full-EE `SQLX_OFFLINE=true` check.

* fix(dbt): seed a project when a modular draft switches to dbt

`seedDbtProject` returned whenever the draft carried any module at all, so a
modular script holding a `helper.ts` reached dbt with none of what dbt needs:
the project view is read-only, and the worker refuses a bundle without
`dbt_project.yml`, so that draft could neither run nor deploy. Keyed on the
project file now, and the seed goes in under whatever is already there — the
previous language's helpers are inert to dbt and the user's to remove.

Also records this runtime's schema in `backend/summarized_schema.txt`: the
`table` asset kind, the `dbt` script language, the five dbt tables and the
`materialization_status` enum the progress table uses.

* docs(dbt): move the pipeline-membership rationale out of the deploy path

* fix(dbt): gate a pinned run's model SQL on the version it belongs to

The `EXISTS` against `script` is the only thing standing between a share-link
viewer and the project's source, and it matched the workspace and path alone.
`extra_perms` is a grant on a ROW: archive a version that granted someone
access, recreate the path with narrower permissions, and that stale grant
satisfied the probe while the query returned the NEW version's `raw_code`. Both
probes name the hash now. The regression test drives exactly that shape and
fails without it, returning `select 2` to a caller granted only on the archived
version.

* fix(dbt): keep a delimiter an identifier escaped by doubling

Every dialect these relations come from escapes its own delimiter by doubling
it, and both split functions closed the quoted section on the first half and
reopened on the second: `"schema"."a""b"` came out as `a.b`. The manifest keeps
the real spelling, so the run wrote its per-model status and row counts under an
asset path no graph node has — the node simply never moves, which is the failure
mode this splitter exists to prevent.

Fixed in the worker and in its frontend mirror, which have to agree, with a case
per delimiter on both sides.

* fix(dbt): key retry state by the caller, not only by the principal it runs as

An `on_behalf_of` script executes every caller's job as its owner, so
`permissioned_as` names one principal for all of them and the retry state — the
durable row and the worker-local generation both — collapsed onto a single
entry. After one caller's run failed, the next could submit `dbt_command: retry`
and resume it: their arguments replayed against the warehouse, and handed back
through `invocation_args`. Nothing else separated them, and on an agent worker
the local directory is the whole boundary.

`created_by` joins the key in both places. For an ordinary script it changes
nothing — `permissioned_as` is already that caller — and a run that was itself
superseded was never resumable anyway.

Includes the offline cache for the four changed queries and the three the
pinned-graph regression test added last commit, which had none: `prepare`
without `--all-targets` does not compile test targets, so CI's
`SQLX_OFFLINE=true ... --all-targets` would have failed on them.

* fix(dbt): compare the schema too when reporting a relation that moved

`relationDrift` compared the leaf name alone, and the move it exists to report —
a profile repointed at another schema, which a later run then writes into the
version's graph — leaves every model's name exactly where it was. So the one
case that reliably produces a graph naming relations this run did not write was
the one case the notice stayed silent for.

The schema segment joins the comparison, qualified against qualified: an
unqualified one means the target's own database, which the relation names
anyway, so comparing that would report a move on every node.

* fix(dbt): bound the retry state now that it is keyed per caller

Keying by `created_by` fixed one caller resuming another's run and created a
growth problem doing it: a shared `on_behalf_of` script kept one row and one
worker directory for everyone who had ever run it, and the generation prune only
bounds files INSIDE a directory.

Three bounds, none of them new machinery. A run with nothing failed or skipped
saves nothing — `dbt retry` builds from those nodes alone, so that state could
only ever be refused — while still clearing what the previous run left, since
its failures are no longer what last happened here. The rows expire on the same
30-day clock as a run snapshot, swept per path by the prune every dbt job
already spawns. And the worker-local directories are swept there too, by the age
of the pointer a save rewrites, because their digest names neither the script
nor the caller.

* docs(dbt): the retry state is worker-affine only on an agent worker

* fix(auth): only the server may set a token label that names a user

`create_token_internal` wrote `NewToken.label` verbatim, and the auth layer reads
some labels as an IDENTITY: `username_override_from_label` maps
`ephemeral-script-end-user-<name>` to exactly `<name>`, which then becomes
`created_by` on every job that token pushes. The label is free-form request
input, so any member could mint a token that speaks as somebody else — the shape
`require_job_read_access` already works around when it refuses to trust
`username_override` and falls back to an RLS probe, and the one that made dbt's
retry-state key (`created_by`) forgeable for an `on_behalf_of` script.

The labels are refused where request input enters: the member-facing
`tokens/create`, and `impersonate`, which names its subject in
`impersonate_email` and has no business renaming the caller too. The legitimate
producers are unaffected — a job's own token comes from `create_token_for_owner`
in the worker, and native triggers and app-embed tokens build their labels
themselves rather than accepting one.

`Ephemeral lsp token` stays allowed: its override is the fixed sentinel `lsp`,
not a name the caller chose, and the editor mints exactly that label through this
endpoint for its language server. The test pins the two lists together, so an arm
added to `username_override_from_label` that lets a label choose a name fails
until it is reserved too.

* Revert "fix(auth): only the server may set a token label that names a user"

This reverts commit efaa498d82.

* fix(auth): only the server may set a token label that becomes a bare username

* revert(dbt): key retry state by the execution principal again

Reverts the per-caller key and the retention it needed. `created_by` cannot
carry an isolation boundary: it is `display_username()`, which a token LABEL
supplies, so two callers can share one value and — before the guard two commits
back — one could name a third person. GHSA-8x8x-88qc-qp4r settled that class by
refusing to trust the name for authorization, and keying on it here was the same
mistake in a new place. It also cost a migration, a PK column, two sqlx cycles
and a retention sweep to defend.

Back to `(workspace, script_path, permissioned_as)`, which is derived from the
authenticated username and cannot be chosen by a request. What that boundary is,
and the one case it does not cover, is now written down where the retry is
specified rather than left to be re-derived: anyone entitled to run the script as
that principal may resume its last failure — the same capability as re-running
that job, since running it requires the read access that already shows them the
run and its arguments — except for a run pushed `invisible_to_owner`, whose
arguments a retry still returns. Closing that means having a retry NAME the job
it resumes and authorizing it as a job read, which is a change to the run
argument, not to the key.

* fix(dbt): give a caller's own selection a graph, and load a finished run once

A run that overrides `select`/`exclude` was marked caller-scoped but not
per-run, so it ingested nothing and its page fell back to the deployed graph.
That is only right when the override NARROWS the descriptor's selection —
`["*"]`, or any model outside it, builds relations the deployed graph never had,
and those are exactly the ones whose progress, SQL and lineage had nothing to
draw. It ingests its own graph now, still caller-scoped, so the subset stays out
of what the script owns.

`relationDrift` also read a schema whose own name contains a period as a move: a
segment holding one is ambiguous — an overridden database, or a schema really
called `a.b` — so either spelling now counts as unmoved.

And a finished run fetched the whole graph twice on mount, every model's SQL
included: the reset effect and the finished-run effect both fired in the same
tick. The reset only loads while the run is in flight; the other owns the
finished case, because a snapshot can land after the run ends.

* fix(dbt): classify a restored retry by its resolved arguments

`add_caller_args` was handed `raw_args` on the retry path — the arguments as
SUBMITTED, where a `select` spelled `$res:` is still a string. `arg_list` then
refuses it as "must be a list of strings" and the retry dies before parsing,
for a reference that resolves to the very list the failed run built with. The
resolved map decides it now, matching the selection resolver and the build;
`raw_args` stays what is persisted and published, so no resolved secret outlives
the job. Verified against the shape it breaks on: a `select` from a resource,
failed, then resumed.

Two docs that outlived their code: `dbt_script_hash` is described as a fallback
for a job naming no deployed script rather than as the pinning mechanism, since
a script job's version comes from the job row and the query value is ignored
(spec plus the generated client, which carries the same sentence); and
`GraphRefresh`'s fields no longer claim a caller's selection only ever narrows,
which is the premise the previous commit disproved.

* fix(dbt): a hidden run keeps no retry state

The state is keyed by the execution principal, which every caller of an
`on_behalf_of` script shares, and a retry publishes the arguments it restored —
so for a run the other callers cannot read, that retry was the one way to see
them. The equivalence the key rests on ("resuming it is the same capability as
re-running it") holds only while the run IS readable, and exactly one run is not:
one pushed `invisible_to_owner`. Those now save nothing, and clear whatever the
previous run left, since this invocation happened. A hidden run therefore cannot
be resumed by anyone, its author included — the cheaper half of the trade, and
the reason having a retry NAME its source job is the design that would give it
back.

Verified: a visible failure still saves and resumes; a hidden one leaves no row,
and the retry after it refuses without returning the hidden `vars`.

Also collapses `GraphRefresh::caller_scoped`, which stopped distinguishing
anything once a selection override became per-run: both writers set both flags,
so `snapshot_job` and `publishes_ownership` are now one field and its negation —
which is what lets the agent payload carry a single `per_run` bit, recorded
there. `is_reserved_token_label` says why `ephemeral-` is reserved (a
client-chosen system label is a token its owner cannot list or revoke) rather
than restating a username rule its second prefix does not follow. And the
module-cache import comment states the invariant once, without the shape of the
cache that preceded it.

* fix(dbt): a pre-build failure leaves the saved run alone

Three exits cleared the retry state before `dbt build` ever ran: a preparation
failure, a failed `dbt parse`, a failed ingest. None of them touches a relation,
so the warehouse is exactly what the previous run left and its failures are still
the accurate description of it — clearing there just costs a resumable failure,
and a resume that no longer fits is refused by `run_identity` and the arguments
digest regardless. The reachable one is a cancellation during provisioning. Only
an interrupted BUILD invalidates, which the save at the end of the job already
decides.

Verified: a failure saves state, a run whose selection matches no node fails in
the ingest and leaves it, and the retry after that still resumes the original
failure.

Agent-published graphs also had no sweep — `Connection::Http` snapshots every run
and spawns none — so the EE endpoint prunes too (pinned at f58bf06), and
`prune_dbt_run_graphs` now names every writer rather than only the runs.

Drops `ephemeral-webhook-` from the reserved token labels: it yields the label
verbatim, not a bare username, so the check now matches the rule its doc and its
test state, and the reason given for it was wrong about what `is_user_token`
costs an owner. And the result contract lists `invocation_args` — the field most
needing it, being another invocation's arguments on a retry.

* fix(assets): decode a doubled delimiter when canonicalizing a table key

`split_relation` decodes it on the worker side, so the canonicalizer had to as
well: a relation whose identifier escapes its own delimiter — `"sales""east"` —
was filed by the ingest under `sales"east` and by a hand-written `table://`
annotation under `sales""east`. Two nodes for one table, no edge, which is the
exact split this key exists to prevent (decision 11). Fixed for both the name and
each half of a database-qualified schema segment, with a case per dialect.

`invalidate_run_state`'s contract also still promised the pre-build behaviour the
previous commit removed; it now names the cases that do invalidate — an
interrupted build, and a run hidden from the script's owners.

* fix(dbt): reclaim package trees no project asks for any more

The cache key covers the whole project digest — a `local:` dependency's content
is in no manifest, so nothing narrower is safe — which means every edit of a
project that declares packages publishes another full dependency tree, and
nothing ever removed one. A worker that lives through a hundred deploys held a
hundred trees until an operator cleared the entire cache by hand, and the disk
that fills fails every job on that worker, not only dbt's.

Swept by last USE, not by publication: a hit dates the tree, so a project
unchanged for months is not evicted from under the jobs still running it. The
marker is a sibling rather than a file inside the tree, which the restore copies
into the project. Staging directories go after a day — one belongs to a single
job and is removed when it ends, so an older one is from a worker that died
mid-publish. Verified by planting a 30-day-old tree and a stale staging dir: one
run reclaimed both and left a fresh tree alone.

Narrowing the key to the declared `local:` paths would fix the churn as well, and
is deliberately not done here: getting it wrong runs a project against another
revision's packages, which is worse than the disk it saves.

* docs(api): declare the dbt half of the asset-graph response

`AssetGraph` is the response of both `/assets/graph` and the new
`/jobs/dbt_graph/{id}`, and the spec described neither `assets[].dbt`,
`runnables[].dbt`, `dbt_edges` nor `dbt_snapshot_job` — so every client generated
from it saw a graph with no dbt metadata, no `ref()` lineage and no snapshot
marker, which is why the run page reaches the endpoint with a raw `fetch` and a
hand-written cast. `frontend/src/lib/gen` is gitignored, so the spec is the only
tracked description of this surface.

Declared, and the client regenerated from it now carries all four (verified: the
regen is otherwise a byte-for-byte no-op).

* fix(dbt): stop the package sweep from racing the restore it protects

Three faults in the sweep the previous commit added.

The `.last_used` marker was written AFTER the copy, and the tree it matters for
is the one at the retention edge — where the first use in a fortnight is also
when the sweep fires. So the source looked stale for the length of the restore
and a concurrent job's sweep could remove it mid-copy, failing a run that should
merely have refetched. Marked before the copy, and a restore that loses the race
anyway is treated as a cache MISS: `dbt deps` resolves the tree again, costing a
fetch rather than the run.

The sweep also sat inside `if let Connection::Sql`, though it is a walk of the
worker's own disk that needs no database — so an agent worker, which has none,
published a tree per project edit and reclaimed nothing. That is the state the
doc claimed was fixed, and the same argument the graph sweep makes two lines
above: retention that depends on the connection is not retention.

And the canonicalizer's two halves disagreed after `5890436`. `unquote_identifier`
requires the quote at both ends, so a lone `"` in an already-decoded name
survives; the schema walk treated one as opening a quote and dropped it, filing
the ingest's `sa"les` under `sales` while an annotation's `"sa""les"` decoded to
`sa"les` — the split this canonicalization exists to prevent, in the function I
had just touched. The halves share one rule now, and the test drives the decoded
spelling against the quoted one rather than asserting only the annotation's.

* fix(cli): track a dbt project's parent descriptor for every authored file

`buildTracker` decides whose top hash `wmill-lock.yaml` refreshes, and it reached
the module branch only for files matching a Windmill script extension. A dbt
project is mostly files that are not: `dbt_project.yml`, `packages.yml`, schema
YAML, seed CSVs. Editing any of them left the descriptor untracked and its
module-inclusive hash stale, so the lock disagreed with the bundle that was
pushed. Module paths are now handled ahead of that gate.

The parent was also derived by searching the RAW path for `__dbt/`, which finds
nothing on Windows, where the folder is spelled `__dbt\` — so even a model edit
was skipped there. It goes through `getScriptBasePathFromModulePath`, which
normalizes separators, and which the sibling helpers already used for this exact
reason.

The regression test drives all three shapes. With the old gate reinstated it
fails on the non-script files and on the backslash path, and passes only for
`.sql`.

* fix(dbt): re-resolve unlocked dependencies, and reclaim retry state nobody writes

A project with no checked-in `package-lock.yml` asked dbt to RESOLVE its ranges
and mutable git revisions, and dbt does that on every run. A cache hit skipped
`dbt deps` entirely and every hit refreshed the marker, so the first resolution
was pinned for as long as the project stayed in use — a version range that moved
was never picked up. An unlocked tree is now a miss once it is a day old; a
locked project keeps its tree, since the lock is in the key and pinning is what
it asked for.

Retry state: `invalidate_run_state` removed the pointer and left the generations,
and the whole state root was swept by nothing after `a236972` — so a script that
stops running, or a principal who stops running it, kept its last generations
(a `manifest.json` each) for good. Forgetting the state now prunes its
generations, and a 30-day sweep of the state root runs beside the package sweep,
for the reason that one already gives: retention that runs only for the script
being run is not retention for the ones that are not. This restores one half of
what `a236972` reverted — the local sweep, not the per-caller key or the row
retention — because it never depended on that key.

The graph orphan sweep also committed its marker delete separately from the two
deletes it gates, so an error or a restart in the gap left graph rows whose
marker was gone — and since the sweep only runs when a marker went, every later
call skipped it and those rows were unreachable for good. One transaction now.

`parseDbtRun` scans from the LAST brace: the payload is appended, so backwards
finds it immediately where forwards parsed the whole message once per brace in
the error text. And `reject_reserved_label` moved into `create_token_internal`,
the one path every caller-supplied label reaches, so the invariant sits where a
new route would break it.

* fix: undo two of my own regressions, and agree with the backend everywhere

`2e34dd48` hoisted the module check above the extension gate, which is right, but
did not bring the entry-point rule with it: a folder-layout script's METADATA is
`<base>__mod/script.yaml`, an entry-point path, and pushing it as a content file
makes the metadata pass ask for the language of `.yaml` and abort the whole
command. Reached by editing the summary of ANY modular script — pre-existing,
nothing to do with dbt. Metadata resolves to its content file now, with a test.

`d3716a09`'s unlocked-dependency refresh is reverted. It measured staleness
against the last-USE marker, which every hit rewrites, so an actively used
project never reached the threshold — and when it did fire, the re-publish
`rename` cannot land on a populated directory, so the freshly resolved tree was
discarded and the stale one re-stamped: a network resolve thrown away per idle
day. Doing it properly needs a publication timestamp and a publish that can
replace a tree; the limitation is recorded where the cache is keyed instead of
half-implemented.

`parseDbtRun` no longer counts braces in either direction. The payload is
appended pretty-printed, so its `{` is the only one at column zero — forwards
blew the cap on an error full of braces, backwards on one `{` per node, which a
few hundred nodes reaches. Test drives a 400-node failure.

And `parsePipelineAnnotations` was the third copy of the quote rule, still
splitting a doubled delimiter: the live canvas keyed `"sales""east"` as
`saleseast` where the deploy keys `sales"east`, so an annotation pointed at a
node the deployed graph does not have. All three agree now, including on a lone
delimiter in an already-decoded name.

* chore(dbt): leave cache retention and the migration's comment out of this PR

Two deliberate subtractions, so what ships is the set that has been verified
rather than the set that was written.

The package-tree and state-directory sweeps are gone (recoverable on
`dbt-cache-retention-followup`). They delete directories on a worker, they were
the newest code here, and one of them already needed a second pass for racing an
active restore — while what they buy is disk hygiene, not correctness: today an
operator's `cache_clear` reclaims these caches, exactly as it does for every
other language. Both limitations they addressed are now recorded where the cache
is keyed. `invalidate_run_state` still prunes the generations it orphans, since
that reuses the existing pruner and its grace window rather than sweeping a root
by age.

And the migration is back to its pre-PR bytes. The only change left in it was a
corrected comment, which every database that already applied this migration would
have paid for with a checksum mismatch at startup — the explanation lives in
`prepare_project` and in the design doc, which is where it is read.

* fix(dbt): pin the resolved package tree to the deployed version

A cache hit skipped `dbt deps`, so an unlocked range or mutable git revision
stayed on whatever the first worker resolved, and a retry could feed one
resolution's run_results.json to another. The deploy now records the digest of
the generated package-lock.yml, the package cache is keyed by it, and it joins
the run identity; a worker that resolves anything else is refused rather than
run.

Also carries the agent-wire round trip test for an ingested manifest, and the
EE pin for the agent publish endpoint.

* docs(dbt): state the dependency contract the deploy pins

The design doc still described the package cache as keyed by the project digest
alone, and carried an open question about refreshing unlocked dependencies that
the deploy-time pin answers: resolution happens once, at deploy, and every run of
that version installs it or is refused. Records what that costs and buys —
committing package-lock.yml makes deploys cache-hit, deploying again is what
picks up a newer range — and that these worker-local caches are reclaimed by
cache_clear, as every other language's are.

* fix(dbt): four findings from the review round

- An interrupted retry republished the results it had only restored. A retry
  starts with the previous attempt's run_results.json in place; cancelled before
  dbt rewrites it, the save dated those failures to this job, so the next retry
  rebuilt nodes this one had already redone. Verified by A/B: without the check
  the row survives, carrying the cancelled retry's id.

- The deploy refused a committed package-lock.yml that dbt itself updates, which
  it does whenever the sha1_hash it recorded for packages.yml no longer matches.
  Only a run has a resolution to be held to; the deploy establishes one.

- A second graph load for the SAME run could land out of order, leaving a
  finished run showing the deployed fallback for good.

- Directory nodes in the project tree carried no path, so two of one name at the
  same depth shared a collapse key and folded together.

* fix(dbt): name every adapter, and stop guessing which project owns a model

The invalid-adapter message listed four of the eleven accepted spellings.

And a relation may have several script producers, none of which the provenance
record identifies — prefixing the first one names a __dbt folder that does not
exist, so an ambiguous relation now shows the path inside the project alone.

* refactor(dbt): name the graph-snapshot column for when it is written

`published_relation_root` described a design persist_ingest does not use: the
root is recorded by every ingest that is not a run's own snapshot, including
one that publishes no ownership — deliberately, since a version that cannot
claim the path would otherwise record nothing and compare against a stale root
forever. The column comment said the opposite and the name followed it.

Safe to edit the migration in place because this PR introduces it: no database
outside a checkout of this branch has ever applied it. One that has needs

  UPDATE _sqlx_migrations SET checksum = decode('<sha384 of the file>','hex')
   WHERE version = 20260725084314;

alongside the column rename, or a fresh database.

* style(dbt): bring comment blocks within the four-line guidance

AGENTS.md asks for each invariant at the place someone would break it, in at
most four lines. The new dbt files carried 60 inline blocks past that, several
of them three rationales deep at one site.

Nothing durable is dropped: what covered several constraints at once is split
to the lines it constrains, and eight comments that had drifted above the wrong
test — six stacked over one profile test, two over the wrong executor test —
are reattached to the tests they describe.

* docs(dbt): carry the snapshot-column rename into the summarized schema

The rename reached the migration, both queries, the sqlx cache and the design
doc, but not the compact schema summary — which is the file agents read instead
of the migrations, so it was the one copy that could mislead silently.

* docs(dbt): bound the retry-state and package-pin residuals precisely

Two claims in the design doc were true but not precise enough to act on.

Retry state: the equivalence between resuming a failure and re-running the job
holds because folder read grants job read, which is also what grants execution.
It does NOT hold for a u/<owner> script shared through extra_perms, where no
folder policy applies and a grantee cannot read even their own on-behalf run.

The package pin: the refusal is per worker. A ranged dependency with no
committed lock reproduces its resolution only from a cache hit, so it keeps
running where the tree is warm and fails on the first cold worker.

* fix(dbt): refuse a retry of a run its caller cannot read

Retry state is keyed by execution principal, so every caller of an on_behalf_of
script shares one saved run. Under a folder that matches job visibility exactly,
but a u/<owner> script shared through extra_perms has no folder policy: a
grantee can read neither the run nor their own, while a retry published its
arguments.

The restore now applies require_job_read_access's rule — you can always read a
job you launched, otherwise the row must be visible under your own RLS.
Deliberately NOT job_perms, which carries the identity the job runs AS: for an
on_behalf_of script that is the owner, and probing with it authorizes everyone.

Verified with two members on one shared principal: bob is refused alice's run
on the u/ path and her marker never reaches his result, alice resumes her own,
and bob still resumes alice's run of the same script under a folder.

* fix(dbt): close the retry check's own gaps

The check landed with three holes and four rough edges, all found by review:

- A pruned source run was allowed. dbt_run_state outlives job retention, so
  that handed the last failure's arguments to whoever asked next. It now fails
  closed, and says so rather than claiming the run was someone else's.
- An agent worker skipped the check entirely: it reaches no database. The saved
  generation now records who launched it, and an agent authorizes the half it
  can prove — the launcher may resume. A generation written before this field
  is not resumable there.
- The row was authorized on one read and restored on another, so a run
  completing in between was restored unauthorized. restore_from_db now takes
  the job it was authorized against.

Also: restore_run_state's doc comment had drifted onto the new helper, the
not-a-member comment described the saved run rather than the caller, and the
refusal message carried a run of spaces from its line continuation.

* fix(dbt): keep the descriptor in --json sync, and fail closed on a stateless row

A dbt script's content is `<name>.dbt.yaml`, and `elementsToMap` drops every
.yaml when metadata is JSON — so a --json workspace tracked the metadata, the
lock and the whole project bundle but not the descriptor: a descriptor-only edit
pushed nothing and a fresh pull wrote a script with no source. Pinned by a test
that fails without the exemption.

And the retry check read job_id flattened, which merged "no row" with "a row
naming no job" and skipped authorization for the second. Unflattened, the row
that cannot be authorized is refused.

Also graph_digest: serde rather than {:?} for edges, and delimited parts.

* docs(dbt): state that retry authorization is by identity, not token scope

The section claimed the read-access equivalence was enforced. It is, for
identity — but the worker never sees the submitting token, and neither v2_job
nor job_perms records a scope, so a token denied jobs:read can still resume its
own principal's last failure. What it gets is the arguments as submitted, so a
reference is re-resolved under whoever retries rather than disclosed.

Also names the cases the check refuses outright, which were spread across three
commit messages and nowhere a reader would look.

* chore(dbt): repin EE after rebasing the agent-graph branch onto EE main

* fix(dbt): say why a test-only run has no models, instead of blaming the profile

A selection of tests alone builds nothing, and a test is an assertion rather
than a relation, so the ingest keeps nodes with no asset to hang on and the
graph comes back empty. The run is correct and its results render — but the
empty state named the one cause it is not, a project with no warehouse
identity, and sent the reader to their profile.

The graph itself is unchanged: retaining a selected test's attached model would
have a test-only script claim a read on every model it asserts against, which
is an asset-graph semantics change and wants its own review.

* fix(dbt): authorize a retry by RLS alone, and eight review findings

Retry authorization drops the created_by grant that mirrored
require_job_read_access's "you launched it". That name is a display name derived
from a token label, so a worker cannot tell a launcher from a collision — the
objection Codex raised on four heads. Visibility under the caller's own RLS is
the whole rule now. An on_behalf_of script under u/<owner> shared by extra_perms
is resumable by the owner alone; under a folder every caller keeps the resume.

Also, and each verified against the code first:

- The local generation is revalidated after the file work, so a newer run
  publishing state mid-restore no longer lets the superseded one resume.
- Both dbt zip lookups in sync normalize the OS separator, as the resource
  lookup beside them already did: on Windows a pulled dbt project was laid out
  as __mod, the one layout dbt cannot run from.
- A resource storing port as a string no longer silently connects to the adapter
  default; a value that is not a port is refused.
- An oversized but readable project file fails the deploy instead of shipping an
  incomplete project that compiles and fails at run time.
- cache_clear does NOT reach the dbt caches: it clears cache/ while these live
  under cache_nomount/, as bun's do. The doc said the opposite, and that claim
  was the justification for dropping the retention sweeps.
- A dbt unit test counts as a test for the empty-state message, as it already
  does for the worker's test-phase detection.
- Per-node results keep the catalog segment when rows disagree on it.
- The project panel copies through the app's clipboard helper, .dbt.yaml
  resolves back to dbt in EXTENSION_TO_LANGUAGE, and two test blocks clean up
  their temp directories.

A comment describing a race against a sweep this PR does not ship is gone.

* fix(cli): keep the oversized-file check to a bounded read

The refusal statted the file and then read it whole to tell text from binary,
which is the cost the two comments above it and isBundledModuleFile exist to
avoid: a multi-gigabyte seed would be loaded just to be refused. Same 8 KB head
read as that predicate.

* revert(dbt): accept the retry residual instead of gating it

Resuming grants no capability a caller lacks: they may already run the script as
that principal, and a plain run builds a superset of what a retry rebuilds. The
one thing a retry adds is information -- the result echoes the resumed run's
submitted arguments, for the row preview -- and in almost every shape the caller
could already read that run: a folder grants job read alongside execution, and an
ordinary script's runs are keyed under the caller's own principal. It takes a
user-path script AND extra_perms sharing AND on_behalf_of for the two to diverge.

Gating it needed an identity the worker does not have. created_by is
display_username(), which a token label supplies: trusting it authorizes a
collision, and resolving it as a username denied every labelled token -- a CI
token is `label-<name>`, no workspace member, so its own retry was refused. That
regression cost more than the exposure.

Kept, because none of it is about the caller:
- a cancelled retry no longer republishes the results it only restored,
- the restore is pinned to the row it chose rather than re-reading it,
- a newer run publishing mid-restore no longer lets the superseded generation
  resume.

The three sqlx entries the gate needed are gone with it.

* fix(dbt): select unit tests, and say why the state key is the boundary

`dbt ls` enumerated five resource types and omitted `unit_test`, which dbt treats
as its own type rather than a flavour of `test`. A descriptor selecting
`test_type:unit` or a unit test by name therefore resolved to the empty set,
which the deploy refuses outright with "matched no dbt nodes". Verified both
ways on a fresh path: the deploy fails without this and succeeds with it. All
three engines list `unit_test` among the accepted values.

The restore's state lookup now carries the reason it keys on the principal and
not the caller. That decision was in the design doc, the commit messages and a
PR thread — everywhere except the line someone would change to "fix" it, which
is where AGENTS.md asks for it and where I twice went wrong myself.

Also: the `GraphRefresh` doc claimed a dynamic descriptor publishes its graph as
the script's ownership, while `per_run_models` snapshots it under the job id and
`publishes_ownership()` returns false — only a moved profile republishes. And
`fn n` in dbt_profiles.rs had no callers left after `port_of` replaced it.

* feat(dbt): offer the resume on a failed run, where the choice is relevant

"Run again" prefills the arguments the run just used, so the obvious action after
a failure rebuilds the whole project while the cheap resume stays invisible
unless the reader knows dbt_command has a retry value.

A finished run with failed or skipped nodes now says how many, and links to its
own run form with the command already switched. A link rather than a submission:
the caller still presses Run, so nothing about permissions or arguments changes.

Deliberately not automatic. A resume is not a re-run — models are usually
rebuilt because upstream data moved, not because they failed — so a schedule
that resumed after a failure would leave every model that succeeded carrying
yesterday's data while reporting success.

* feat(dbt): put the command first, and offer the resume where the rebuild is

Three things made rebuilding the whole project the path of least resistance
after a failure, when resuming its failed and skipped nodes is what dbt offers:

- `dbt_command` was LAST in the generated signature, under `limit`. The schema's
  `order` drives the run form, so the argument that decides what the run does sat
  below the ones that only narrow it. It leads now, and the signature test
  asserts the order rather than the set, with the reason it is pinned.
- "Run again" prefills the arguments of the run it came from, so for a failed run
  it reproduces the rebuild. It now carries `dbt_retry_hint`, and the form shows
  what resuming would do instead -- without choosing it, so the caller decides.
- Its dropdown offers "dbt retry with same args" above the plain re-run, for a
  failed dbt job only: a run that succeeded has no saved failure to resume.

Verified in the browser: both dropdown labels render unclipped, the alert appears
only on that path and disappears once the command is switched, and the schema of
a redeployed script lists dbt_command first.

* feat(dbt): describe the run arguments, and hide the ones a command ignores

The form offered six fields at once with no indication that most of them apply
to one command each. `retry` reuses the arguments of the run it resumes, so
every override is ignored; `limit` belongs to `show`; `full_refresh` to `build`.

Each argument now carries what it is for, and a `showExpr` for when it applies,
which SchemaForm honours by hiding the field AND dropping its value from the
payload. Selecting `retry` leaves the command alone on the form.

Not modelled as a `oneOf`, which is the more precise shape: Windmill renders
that as a variant selector whose value is a nested object keyed by a
discriminator, so the arguments would stop being flat -- and the worker reads
them by name, as do schedules, webhooks, the CLI, saved inputs and the retry
state's own copy of them.

* fix(dbt): a retry may name the run it means to resume

The retry actions I added an hour ago appear on any failed run, but the saved
failure is keyed by script and principal rather than by job — so from an older
run's page they resumed whatever failed most recently, under a label promising
"same args". Both now pass `dbt_retry_job`, and a retry naming a run the state
no longer holds is refused with the id it does hold.

Verified: with failures A then B saved, a retry naming A is refused and names B
in the message, while a retry naming B resumes.

Also from the same review: the `select` help advertised `state:modified`, which
needs a comparison manifest this runtime supplies no `--state` for, and the
openflow note still described a dbt script as a descriptor naming a git repo
rather than the module bundle this ships.

* fix(dbt): make both retry actions actually name their run

`dbt_retry_job` is not a form field, so the banner's link to the run form dropped
it and the retry resumed whatever failed last — the bug the argument exists to
prevent, reintroduced by the affordance meant to use it. The banner submits the
run itself now, as the "Run again" dropdown item already did.

And an agent worker never applied the check at all: its `latest_job` is always
None, so the local generation was accepted unconditionally. Both reviewers found
this independently. The decision moved into `chosen_generation`, which the agent
path shares, with the refusal naming the run the worker does hold.

Pinned by a test that fails without the check, on the connection kind where it
was missing rather than on the one that already worked.

* fix(dbt): let an unchanged push be a no-op, and stop advertising a retry the form cannot aim

Two of a dbt script's fields are DERIVED by the server after the no-op check
runs: the lock, which only a dependency job can produce, and the schema, which
comes from the descriptor and which no client can derive (windmill-parser-wasm
has no dbt arm). The check compared what arrived instead of what would be
stored, so every unchanged `wmill sync push` created another version and another
dependency job -- the sync churn skip_if_noop exists to prevent. Both are now
compared as stored, with the parent required to hold a lock so a deploy whose
dependency job failed can still be retried by pushing again.

Verified by A/B on a faithful payload: without this the same unchanged push
creates a version, with it the existing hash comes back and the count holds.

The run form's retry hint is gone. `dbt_retry_job` is not a form field, so a
retry started there resumes the last failure of the script rather than the run
the message pointed at -- the two buttons name their run, that path could not.
Better to not offer it than to offer it wrong.

Also reunites a comment with the derivation it describes, 40 lines below where
an earlier edit of mine left it.

* docs(dbt): an unchanged push no longer re-resolves dependencies

The section told the reader that a byte-identical deploy re-pins a ranged
dependency, which was true only because no-op detection was broken for dbt. Now
that an unchanged push is skipped, moving a pinned resolution takes an actual
change.

* feat(dbt): offer the resume only on the run it would actually reach

* fix(dbt): a refusal names both the run asked for and the one held

* docs(dbt): say that show previews one node when several are selected

* feat(dbt): a retry names the run it resumes, and the form can fill it in

* refactor(dbt): make the run's command a oneOf carrying its own overrides

* fix(dbt): refuse a command block that names no command, and keep state off retired paths

* fix(assets): let copilot and the assets filter see warehouse tables

* fix(dbt): drop the unused db handle from the retry-principal lookup

* fix(dbt): stop a preview poll when the run page moves on

* fix(dbt): refuse an escaping packages-install-path, re-arm the graph poll on navigation

* docs(dbt): concurrent runs of one script are the script's to serialize

* fix(dbt): bound what the log tailer holds in the worker process

* fix(dbt): a stale prefill answer must not aim another script's retry

* fix(dbt): a rename leaves the old path archived, so state must not be rewritten there

* fix(dbt): keep a retry's routing inputs, and stop an app-embed token probing resumability

* docs(assets): state the authorization the graph helper expects of its callers

* fix(dbt): merge the retry into the fetched arguments, not the too-big placeholder

* fix(dbt): the command block's label decides the command, not map order

* fix(cli): an oversized dbt project file must not read as generated output

* fix(dbt): the prefill names a run only when the caller may read it

* fix(cli): refuse an oversized dbt file before anything reads its body

* fix(dbt): fold table paths as the deploy does, and preview with fetched args

* fix(dbt): stream an engine archive to disk instead of holding it in the worker

* refactor(assets): the dbt asset kind is dbt://, keyed on the relation

* chore: regenerate the auto-generated prompts for the dbt asset kind

* fix(assets): rename the kind at the callers that request it, and refuse an unknown one

* feat(dbt): show the project's models under the run form on the script page

* fix(dbt): the rename missed the dbt-edge membership keys, so the DAG lost every edge

* fix(dbt): a preview must run the dbt writer, with the form's arguments

* feat(dbt): the models graph takes the bottom of the script page, as a flow's does

* fix(dbt): an unknown outcome is not a pass, and a preview keys on its arguments

* feat(dbt): a warehouse is configured on the workspace, named like the lake

* feat(dbt): asset identity keys on the warehouse name, resolved for agents too

* feat(dbt): warehouses are configured in workspace settings

* feat(dbt): the descriptor lives in the project and is optional

* docs(dbt): the warehouse is a workspace setting and the descriptor is optional

* fix(dbt): the warehouse route names its capture, and the editor stores the map

* test(dbt): pin the warehouse setting round-trip and the job-scoped route

* fix(dbt): a project-owned profile must authorize the warehouse it names

* fix(dbt): the settings tab imports TextInput from where it lives

* fix(dbt): a warehouse name is validated wherever one enters, and an absent descriptor is not a diff

* fix(dbt): the settings inputs pass their placeholder the way TextInput takes it

* fix(dbt): pushing a project that has no descriptor is not an error

* chore(dbt): keep the module suffix private to its module

* fix(dbt): warehouses survive a fork or rename, and an empty descriptor is never a file

* fix(dbt): a workspace rename carries its dbt graph with it

* fix(dbt): a rename takes the run state with it, not only the graph

* chore(dbt): drop the query-cache entries the settings edits orphaned

* docs(dbt): state the three rules that keep an absent descriptor absent

* fix(dbt): an unreadable descriptor is an error, and an emptied one is a deletion

* feat(dbt): the warehouse is unpermissioned, like the workspace bucket

* feat(dbt): an agent worker's run reports its per-model state too

* docs(dbt): how an agent worker reports what it ran

* feat(dbt): a project-owned profile reports its database, so its models share nodes

* style(dbt): rustfmt the files this branch touched

* fix(dbt): drop the job id write_profiles no longer reads a resource with

* fix(dbt): a preview reads the graph's edges and sends the model, not the cache key

* fix(dbt): a deleted producer explains itself instead of a bare 404

* fix(dbt): one warehouse resolution path, interpolated against the job

* fix(dbt): a project with no descriptor pulls and opens in dev mode

* feat(dbt): one file tree, editable, and Test builds the model you have open

* fix(dbt): a preview asks the writer's own graph, and lfs keeps its omission

* fix(dbt): metadata stays beside the project, and only a model narrows a build

* fix(dbt): a descriptor-less project previews, and dev reloads the whole bundle

* fix(dbt): forks carry the graph, and a vanished project stops the push

* fix(dbt): no-auth resolves warehouses, and a preview runs the version it read

* fix(dbt): a retry recognizes its own run when the profile carries a job token

* fix(dbt): the project marker is not deletable and the descriptor path is reserved

* fix(dbt): a templated adapter type is refused, and a removed project archives

* perf(dbt): the manifest lands in batches, and .py models are creatable

* fix(dbt): only a 404 excuses a failed archive, and edges are covered

* fix(dbt): a python model narrows the build, and a failed removal is not silence

* fix(dbt): a settings name may be any word, and the reserved path is stated once

* fix(dbt): an absent descriptor needs its project, and the form matches the run

* fix(dbt): a bundle is dbt by language, and both job routes agree who may call

* docs(dbt): every identity doc names the warehouse, as the code does

* perf(dbt): an agent posts its run's progress once, and show cannot touch a seed

* fix(dbt): bound what a warehouse may be named, and say who may ask

* fix(dbt): a run's graph withholds what the project's author wrote, and dbt_project.yml is rendered before it is read

* feat(dbt): withhold dbt from the language picker until it has its own editor

* fix(dbt): refuse a path claimed by both a dbt project and an ordinary script

* test(dbt): the bundle assertion is a set, not an order

* fix(dbt): a project's generated dirs are re-read when its config changes

* test(dbt): assert the synthesized descriptor without assuming a separator

* fix(dbt): both push paths refuse a shared path, and every writer sweeps its progress rows

* fix(dbt): preview only what dbt show can select, and a retry keeps its command block

* fix(dbt): an identifier carrying a path separator gets no asset key

* fix(dbt): no engine ships in an image, and show may name only one node

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:58:05 +00:00
Ruben Fiszel bd7156682d fix: keep native triggers attached when a runnable is renamed (#10432)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:44:25 +02:00
Ruben Fiszel dda59767c2 feat: stamp webhook trigger_kind on token-driven job runs (#10431)
* feat: stamp ui vs webhook trigger_kind on direct job runs

* fix: gate ui trigger kind on min worker version and dedupe display names

* docs: state that the ui trigger kind attributes rather than proves

* refactor: fold the trigger fallback into one trigger_or_fallback helper

* feat: hold trigger_kind as a tolerant label on the worker paths

* chore: refresh the sqlx offline cache for the trigger_kind label queries

* chore: update ee-repo-ref to 7de7daff5eed410e0c815ad6b292d2b4303f02f2

This commit updates the EE repository reference after PR #700 was merged in windmill-ee-private.

Previous ee-repo-ref: 974ab910d9a30c5565e1198ee312acc6d11239f3

New ee-repo-ref: 7de7daff5eed410e0c815ad6b292d2b4303f02f2

Automated by sync-ee-ref workflow.

* fix: keep the API job structs tolerant of unknown trigger kinds too

* chore: point ee-repo-ref at the merged EE main

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-31 14:46:08 +00:00
Ruben Fiszel 02c4a9e515 fix: carry the token label into job-run audit rows (#10433)
* fix: carry the token label into job-run audit rows

* docs: state the audit end-user precedence at the push signature

* chore: point ee-repo-ref at the companion branch

* docs: state the username/end_user split at the push signature

* feat: keep the audit caller searchable when a token label takes end_user

* fix: skip the caller parameter when it repeats the end user
2026-07-31 16:13:12 +02:00
Ruben Fiszel 3716a71fd7 fix: credit the token owner instead of the token label in the audit trail (#10423)
* fix: credit the token owner instead of the token label in the audit trail

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

* fix: address review findings on token-owner audit attribution

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

* fix: carry token-label provenance explicitly instead of inferring it

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

* chore: point ee-repo-ref at the companion branch

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

* fix: trust only non-forgeable token labels to name the acting entity

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

* fix: reject reserved system-token labels at token creation

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

* fix: narrow the token-label guard to server-minted namespaces

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

* fix: add the provenance field to the remaining ApiAuthed literals

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

* fix: stop trusting the email- label, which no mint produces

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:21:14 +02:00
Ruben Fiszel 61f2d8dc6a feat: let the merge UI target an arbitrary workspace (#10417)
* feat: let the merge UI target an arbitrary workspace

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

* fix: keep the target picker reachable when a comparison fails

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

* fix: address review findings on the arbitrary merge target

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

* fix: collapse app/raw-app conversions and offer a comparison retry

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

* fix: make a re-scan replace the candidate set and keep retry reachable

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

* fix: drop destructive rows from the selection when a recompute flips them

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

* fix: keep bulk selection and refreshes out of the removal opt-in

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

* fix: serialize a full scan against dev attachment on the same pair

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

* fix: keep the compare view reachable from drafts and prune stale selections

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

* fix: make the destination badge the target picker and reorder the settings

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

* fix: render the destination trigger as the same badge as the source

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:46:59 +02:00
Ruben Fiszel 81b23a2ba0 feat: make the fork lineage the only deploy relationship (#10410)
* feat: make the fork lineage the only deploy relationship

`workspace_settings.deploy_to` (2023) and `workspace.parent_workspace_id` (2025)
both expressed "which workspace does this one deploy into". Fork creation and
dev-workspace attach seeded both, but nothing kept them in agreement, so every
reader picked one and they disagreed.

Drop `deploy_to`. A migration folds surviving pairs into the lineage: a sole
claimant on a target with no dev workspace becomes that target's dev workspace
and keeps its own job tags, while many-to-one pairs become plain forks. Pairs
that the lineage cannot express -- dangling target, self-reference, chain,
mutual -- are reported and left unlinked.

Job tags were never lineage-aware: `per_workspace_tag` mapped any parented
workspace to its parent while `$workspace` interpolated the raw id, so a fork
running a script tagged `<tag>-$workspace` produced a tag no worker serves and
the job queued forever. Both paths now resolve to the nearest ancestor whose id
an admin would provision workers for.

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

* fix: preserve unconvertible deploy links and sweep tag caches on reparent

Review findings on the deploy_to unification:

- convert chains instead of discarding them, and keep whatever the lineage
  cannot express in workspace_deploy_to_unmigrated so the down migration can
  restore it
- ignore soft-deleted workspaces when choosing between a dev workspace and a
  plain fork; an archived claimant was demoting live pairs
- mirror attach_dev_workspace's git-sync strip, which the migration skipped
- sweep the tag cache over whole subtrees on rename and delete: tag resolution
  now walks ancestors, so a nested fork kept a tag nothing serves
- call a dev workspace a dev workspace in the settings copy
- redirect a root away from ?tab=deploy_to instead of rendering an empty target

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

* fix: detect lineage cycles and record archived links in the deploy_to migration

Second review round on the unification:

- detect cycles over the lineage as it would exist after conversion, not over
  the deploy_to graph alone: a root whose target was one of its own forks
  closed a loop that no deploy_to edge revealed
- record an archived source's link instead of filtering it out entirely, which
  dropped it with the column
- treat a fork whose deploy_to merely repeats its parent as redundant rather
  than reporting every pre-existing fork as unmigrated
- read the row count from the lineage update rather than the git-sync one
- sweep the tag cache when archiving a dev workspace, the last site that
  mutates is_dev_workspace without one

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

* fix: resolve $workspace on preprocessed flow tags regardless of $args

Third review round on the unification:

- a flow tag containing only `$workspace` skipped interpolation entirely on the
  preprocessed path, because the branch that ran it keys on `$args`. The raw
  tag was written back and named a queue no worker serves. Resolve `$workspace`
  before the branch and leave `$args` to it.
- record the new table's foreign key in the schema summary
- describe what the archive tag sweep actually does: the dev flag is cleared for
  any archived workspace, which is why it is unconditional

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

* fix: keep the deploy_to leftovers table only when it holds something

* fix: sweep tag caches on archive only where the dev flag actually changes

* feat: broadcast lineage changes and walk ws_specific ancestors only

- propagate tag-cache invalidation across processes over notify_events: the
  cache is per-process, so replicas kept resolving stale lineage for the TTL.
  The listener clears the whole cache rather than tracking ids, since a single
  mutation invalidates an unbounded set of descendants and lineage changes are
  rare admin actions.
- narrow list_ws_specific_versions to ancestors: walking down as well made a
  root fan out over its entire live fork subtree, and each member costs an
  identity lookup plus an RLS switch and probe. Ancestors are bounded by the
  fork depth limit.
- probe the leftovers table unqualified so rollback restores on a PG_SCHEMA
  install, where search_path is not public
- drop the nativets client method for the removed edit_deploy_to endpoint

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

* fix: let a prod see its dev workspace in ws_specific, and stop the walk oscillating

Descending into plain forks made a root fan out over its whole live fork
subtree, but a dev workspace is the paired editable environment rather than a
throwaway copy, so a prod should still see it. There is at most one per parent
and attach rejects nested dev chains, so that edge stays bounded.

The edges run both ways, so the recursion never converged: it bounced
parent<->dev until the depth cap on every call, 33 rows for a two-member set.
A visited-path guard ends the walk when nothing new is reachable.

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

* fix: keep dev pairings unnested, gate the delete broadcast, cover the ws_specific walk

Fifth review round:

- a root that already owns a dev workspace no longer converts: linking it under
  its deploy target would leave that dev nested beneath a fork, the shape
  attach_dev_workspace refuses to create. The link is preserved instead.
- broadcast a lineage change on delete only when descendants are orphaned.
  Deleting a leaf, which ephemeral fork churn does constantly, changes nobody
  else's resolution and was making every replica drop its whole tag cache.
- call list_ws_specific_versions in a test. plpgsql defers everything past a raw
  parse to the first call, so replaying the migration only proved it parses.
- use unwrap_or_default for the descendant sweeps, which run after the
  transaction has committed; a transient failure must not fail the request
- trim the traversal comment to the four-line limit

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

* fix: cache the renamed tally query and clear instance alerts on conversion

The integration test's query was never cached: `cargo sqlx prepare` without
--all-targets skips test targets entirely, and renaming its fixture workspace
changed the query text. Regenerated with --all-targets --features
all_sqlx_features,private, which is what lets the EE-gated otel test compile.

Also from review:
- clear error_handler_fallback_to_instance_alerts on converted workspaces.
  Dispatch ignores it once a parent exists, but the settings page keeps
  submitting the stored true, which the API rejects on a fork.
- restore the schema summary row to the file's name: columns format and put it
  back in alphabetical order

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

* fix: never cache an unresolvable tag workspace, and unadvertise the removed endpoint

- lookup_tag_workspace cached a "no row" result as self-resolution. A rename
  resolves the new id before its row lands, so a fork could be pinned to its own
  wm-fork-* id -- which nothing serves -- for the whole TTL, and its schedules
  kept re-pushing onto that dead tag. Fall back for the call without caching,
  matching how the error path already behaved.
- change_workspace_id swept its children but never itself. Sweep the new and old
  ids and broadcast unconditionally, since a rename always changes lineage.
- openapi-deref.{json,yaml} are served to clients via include_str!, so they were
  advertising edit_deploy_to after it started 404ing. The audit-action enum
  keeps the entry: historical rows still carry it.

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

* fix: align the served YAML spec with the JSON one and correct two comments

- the YAML deref lost the removed path but kept deploy_to on get_settings,
  so the two served specs disagreed. Both are now identical.
- the rename-sweep comment blamed cached-unresolvable lookups, which the same
  commit stopped caching. The real reason is that workspace ids are
  reclaimable, so a new id can carry a previous occupant's resolution.
- the instance-alert comment claimed the settings page submits the stored true
  and gets a 400. It hides the option on a fork and sends false; the hazard is
  the value outliving the pairing and re-enabling alerts after a detach.

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

* chore: update ee-repo-ref to 82da6cb2bafeda18acd6b70c599013a12117ecb0

This commit updates the EE repository reference after PR #694 was merged in windmill-ee-private.

Previous ee-repo-ref: f9ddf6a75aa13d1c13a3d7216a361a96f75ca435

New ee-repo-ref: 82da6cb2bafeda18acd6b70c599013a12117ecb0

Automated by sync-ee-ref workflow.

* fix: grant the deploy_to preservation table to the windmill roles

* test: drop the one-shot migration tests, keep the ws_specific execution guard

The two conversion tests replayed the migration against the fully-migrated
schema, which is not how it runs -- in production it runs mid-sequence against
the schema as of that point. A later migration touching workspace or
workspace_settings would break them without breaking anything real, and sqlx
checksums already freeze a released migration. They earned their keep finding
the archived-claimant and nested-dev cases during development; there is nothing
left for them to guard.

list_ws_specific_versions is different: it is live, no caller exercises it, and
plpgsql only parses a function body until first call.

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

* fix: invalidate a reclaimed fork id cluster-wide without flushing every entry

Gating the delete broadcast on orphaned descendants stopped leaf churn flushing
every replica, but fork ids are reclaimable: the deleting process invalidated
locally while every other replica kept the old parent for the TTL, so a job
pushed in a recreated fork routed to the previous parent's tag.

The broadcast payload now carries meaning. A workspace id drops that one entry,
used for leaf deletion where exactly one id changed what it denotes. The `*`
sentinel drops everything, used for attach, detach, archive, rename and
deletions that orphan descendants -- reshaping a subtree no single id names.

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

* docs: name the right broadcast for each invalidation case

* docs: attach does invalidate the tag cache; the resolver walks the whole chain

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-30 14:20:27 +00:00
Ruben Fiszel f9a547b8b8 fix(forks): record fork changes that never reached the diff tally (#10403)
* fix(forks): tally fork changes when a deploy's lock job fails

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

* fix(forks): tally fork changes even when deploy_to is unset

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

* chore: bump ee-repo-ref to the fork tally companion commit

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

* fix(forks): never tally a dependency deploy twice

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

* test(forks): pin the ahead tally for a fork with no deploy_to

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

* test(forks): cover the dedup case and commit the offline query cache

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

* test(forks): let the tally settle before asserting the dedup count

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

* fix(forks): key the ahead tally on the fork lineage, not deploy_to

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

* chore: update ee-repo-ref to caf6abc45afd910620ef66f7550c076c18ca4589

This commit updates the EE repository reference after PR #693 was merged in windmill-ee-private.

Previous ee-repo-ref: d5c6ee8b774993aa341196746d141107aa4567d1

New ee-repo-ref: caf6abc45afd910620ef66f7550c076c18ca4589

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-30 08:46:25 +02:00
Ruben Fiszel f9d5da11b7 feat: allow changing an account email in the superadmin settings (#10355)
* feat: allow changing an account email in the superadmin settings

* fix: cover slack_email and usage rows, and scope job rewrites to the queue

* fix: compare the destination email case-insensitively

* fix: only warn about the consequences once the email is edited

* docs: warn that changing an account email is a last resort

* fix: repoint app policies and raw-email permissioned_as, reject self-change

* fix: repoint folder default rules and guard the varchar(55) job column
2026-07-27 14:54:23 +02:00
Ruben Fiszel 4d3ff0299f feat: mark failed jobs as resolved so handled failures stop showing red (#10319)
* feat: mark failed jobs as resolved so handled failures stop showing red

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

* fix: constrain auto-resolve to the proven retry chain and honor resolved filter everywhere

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

* fix: apply resolved filter to queue-union, concurrency and delete paths, bound note

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

* fix: sweep resolutions on workspace delete, verify helper args, enforce UI limits

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

* fix: count resolution note in characters on both sides of the API

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

* fix: skip the queue lookup for cancel-all under the resolved-only filter

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

* fix: converge retry auto-resolution from either commit order, keep notes on re-resolve

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

* docs: correct the idempotency claim on the retry auto-resolve sweep

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

* feat: gate resolution notes and attribution behind enterprise, add note popover

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

* fix: hide resolution from operators, exclude flow steps, enforce EE licence at runtime

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

* docs: add job_resolution.automatic to the summarized schema

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

* fix: preserve stored attribution when re-resolving without a valid licence

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

* docs: condense the attribution-preservation comment to four lines

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

* fix: validate resolution notes by code point instead of a UTF-16 maxlength

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

* fix: keep the resolution popover open when a note is rejected

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

* feat: offer to resolve the original failure after a successful re-run

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

* fix: verify supersession server-side and stop re-runs overwriting notes

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

* fix: apply tag scope to the superseding run

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

* fix: exclude obscured cross-workspace runs from resolution actions

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 09:36:27 +02:00
Ruben Fiszel f00fcb2d1b fix: show scheduled singlestepflow runs in flow history sidebar (#10312)
* fix: show scheduled singlestepflow runs in flow input history

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

* fix: only match flow-wrapped singlestepflow rows in flow history

singlestepflow wraps either a script or a flow; a script and flow may share
a runnable_path, so filter flow history to flow-wrapped rows via the wrapped
module type. Extends the regression test to cover the same-path collision.

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

* fix: surface scheduled singlestepflow runs in script history too

Scheduled scripts with a dynamic-skip handler or native retry also run as
singlestepflow. Include that kind for ScriptPath history, filtered to
script-wrapped rows so a same-path flow run does not leak in. ScriptHash is
untouched (these wrappers carry no runnable_id). Test covers both directions.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 18:12:09 +02:00
Ruben Fiszel 717e38a0c6 feat: let a workspace fall back to the instance critical alert channels (#10292)
* feat(alerts): let a workspace fall back to the instance critical alert channels

A workspace with no error handler had no way to surface failed jobs, and the
instance critical alert channels a superadmin already configured (Slack, Teams,
email) were unreachable from a workspace: the workspace Slack error handler
posts with the workspace's own bot token, not the instance one.

Adds an opt-in workspace setting that reports failed jobs to those channels
when, and only when, no workspace error handler is configured. The report is
send-only: it skips the `alerts` table so workspace job failures never flood the
instance-wide feed superadmins triage.

Rejected on cloud (the channels belong to the instance operator, who is not the
tenant) and on fork workspaces (throwaway copies of a parent's runnables).
Settable from workspace settings and from the new-workspace screen.

The opt-in and the existing `mute_critical_alerts` flag are folded into the
query already behind WORKSPACE_ERROR_HANDLER_CACHE, so a failed job costs no
extra round trip, and workspaces with neither a handler nor the opt-in return
before the per-runnable mute lookup.

* chore(sqlx): add offline query cache entries for the new settings queries

* refactor(alerts): make instance alerts a destination tab and address review

Instance alerts are a fifth error-handler destination rather than a separate
toggle: the backend already treats them as mutually exclusive with a handler
script, so one "where do failures go?" control matches the semantics and drops
the inert-while-a-handler-is-set state. The tab is offered on the workspace
error handler only, not on schedules or triggers.

Review fixes:
- the fork boundary is enforced at dispatch (join on parent_workspace_id), so a
  workspace attached as a fork/dev after opting in stops reporting; attaching
  also clears the stored flag, and the settings page never selects a tab it does
  not render, which would have submitted a value the API rejects on a fork
- mute_critical_alerts no longer gates this path: it is the UI-feed mute, and
  this path writes no feed entry
- cancellations are not reported: they are a human action, and this destination
  has no per-workspace mute of its own
- per-workspace throttle with a rollup count, so a flapping runnable cannot turn
  into unbounded Slack/SMTP traffic on channels shared by the whole instance
- log the dispatch, audit the flag, name the columns in the rename INSERT, drop
  the generated migration placeholders

* chore(alerts): state the fork/cloud invariant on canUseInstanceAlerts

* chore(sqlx): cache the attach_dev_workspace settings update
2026-07-24 09:58:27 +02:00
Ruben Fiszel d7a0078b58 fix(resources): apply resource_type changes on update (git-sync pull) — Fixes GIT-932 (#10277)
* fix(resources): apply resource_type changes on update (git-sync pull)

The OpenAPI spec and generated CLI client both declare `resource_type` on
EditResource, but the backend's EditResource struct omitted the field, so
serde silently dropped it and update_resource never changed a resource's
type. Switching a resource between two types in git and pulling back into
the workspace therefore left the workspace out of sync with git (GIT-932).

Add resource_type to EditResource and persist it in the UPDATE.

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

* test(resources): trim resource_type regression comment to durable rationale

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 10:19:30 +02:00
hugocasa b070f56c5e feat: detect server-handled git-sync so CLI picks git push vs wmill sync push (#10201)
* feat: detect server-handled git-sync so CLI picks git push vs wmill sync push

Add a non-admin GET /w/{w}/workspaces/git_sync_deploy_mode endpoint returning
{configured, deploy_on_push}, so any workspace member (not just admins, who
alone can read get_settings) can tell whether pushing to the git remote deploys
via server-side auto-pull. Surface it through `wmill gitsync-settings status`
and align the deploy guidance/skills to prefer git push when the repo deploys on
push, falling back to `wmill sync push` otherwise.

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

* fix: address review — clean JSON output, warn on git_sync parse failure

- gitsync-settings status --json-output now uses console.log so the JSON pipes
  cleanly to jq (log.info wraps it in ANSI color codes)
- get_git_sync_deploy_mode logs a warning on git_sync deserialize failure instead
  of silently reporting configured=false, and documents why it is not EE-gated

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

* fix: address codex review — license-gate and branch-match deploy-on-push

- get_git_sync_deploy_mode now reports deploy_on_push only on Enterprise-licensed
  instances (auto-pull can't run on CE/downgrade) and returns auto_pull_branches
  so the client knows which tracked branches actually deploy on push
- gitsync-settings status matches the local git branch against auto_pull_branches
  before recommending git push, so an untracked branch falls back to wmill sync push
- add an integration assertion for the endpoint's default (no git-sync) shape

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

* docs: point per-topic skills at the canonical Deploying section

The git-push-vs-wmill-sync-push decision lives in core.ts (AGENTS.wmill.md),
which is already in context. Have the per-topic skills reference the Deploying
section instead of re-encoding the detection, so there is one source of truth
and no drift (the compressed version also wrongly implied `gitsync-settings
status` detects the CI-workflow path, which only core.ts's filesystem check does).

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

* fix: match remote+branch server-side for deploy-on-push detection

Codex flagged that a workspace-level auto-pull signal recommends `git push` even
when the local checkout is a different repo/branch than the one that auto-deploys.
Match precisely instead, without exposing anything sensitive:

- git_sync_deploy_mode takes optional remote+branch query params. The backend
  normalizes each auto-pull repo's URL to host/path (dropping embedded
  user:token credentials by rebuilding from parsed components, never scrubbing
  the string) and compares to the caller's remote; deploy_on_push is true only on
  a licensed instance where an auto-pull repo matches that remote and tracked
  branch. The response is two booleans — no repo URLs or branches leave the server.
- Branchless (default-branch) and fork/sync_forks repos stay a safe fallback to
  `wmill sync push` rather than a wrong git-push recommendation.
- CLI status sends `git remote get-url` + current branch (new getGitRemoteUrl
  helper, --remote flag) and reports the matched result.
- Unit-test the URL normalization/credential-stripping directly, since a
  regression there would be a token-handling bug.

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

* fix: address codex security findings in deploy-mode detection

- Strip credentials from the local remote client-side before sending, so a
  token embedded in the URL never reaches the server's request-URI logs
- Fetch the remote via spawnSync arg array (not an interpolated shell string),
  removing a command-injection path from a caller-supplied --remote value
- Use the remote's push URL (`git remote get-url --push`) and recommend the
  qualified `git push <remote> <branch>`, so the pushed target matches the one
  the server checked
- Keep the port in remote normalization so different services on the same host
  don't collide into a false match
- Unit-test credential stripping (CLI) and port distinctness (backend)

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

* feat: resolve $var repos and fork sync_forks in deploy-mode detection

- Interpolate $var:/$res: references in the repo url/branch the same way the
  auto-pull poller does (system context, cached, only when a field is a
  reference), so variable-backed git URLs match instead of falling through
- For a fork workspace, evaluate the root ancestor's git-sync settings and treat
  its wm-fork/<base>/<id> branch as deploying when the root repo has
  auto_pull.enabled && sync_forks and its base matches the tracked branch
- Read settings/resources on the plain pool (a fork member may not belong to the
  root workspace); only booleans are returned
- Unit-test the fork/branch matching (base + sync_forks + workspace-id suffix)

A blank tracked branch (repo default) still needs a network ls-remote to resolve,
so it stays a safe fallback to `wmill sync push` rather than a wrong git push.

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

* refactor: share one git-repo resolver between poller and deploy-mode

The auto-pull poller and the deploy-mode endpoint both resolved a git-sync repo
resource (system context, $var:/$res: interpolation) with duplicated boilerplate.
Extract windmill_store::resources::resolve_git_repository_resource and have both
call it, so the interpolation lives in one place. Drops the endpoint's local
resolve_repo_url_branch helper and its raw SQL query (and cache entry).

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

* fix: address codex review — fork false-positives, shell-safety, auth contract

- Fork deploy detection now mirrors reconcile_fork_branch_pull: the wm-fork branch
  must route to this workspace (first existing of the id candidates) and the repo
  must be in the fork's own inherited settings, so a multi-repo root or an
  ambiguous id can't produce a false deploy_on_push
- Recommended deploy command is shell-quoted (branch/remote names may contain
  metacharacters and the output is agent-executed)
- Remote normalization folds only the host; repo paths stay case-sensitive
- Document the system/RLS-bypassing contract on the shared resolve helper and
  restore the head-fetch doc; fix the overclaiming integration-test comment
- Dev-workspace label and default-branch cases remain documented safe fallbacks

Also restores 5 sqlx cache entries an earlier cleanup dropped.

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

* fix: require a runnable auto-pull delivery path for deploy_on_push

enabled auto-pull alone doesn't mean a push deploys: a webhook-only repo with no
active hook (failed registration), or a repo that only polling could serve on an
SSH URL (the poller rejects SSH), delivers nothing. Gate deploy_on_push on an
actual delivery path — active webhook, or a pollable non-app HTTPS repo — per the
repo's auto-pull mode. Unit-tested across modes/webhook/URL-scheme/app.

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

* fix: read fresh repo config for on-demand deploy-mode detection

resolve_git_repository_resource took an implicit allow_cache=true (right for the
poller loop). An on-demand status could then match against a stale url/branch
cached by an earlier poll. Make allow_cache a parameter: poller keeps true, the
deploy-mode endpoint passes false so it reflects the current git-sync config.

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

* fix: defer the deploy fallback instead of assuming wmill sync push

When backend auto-pull doesn't match the checkout, `status` no longer flatly
recommends `wmill sync push` — a CI workflow may still deploy on push. It now
reports the backend signal and points at the Deploying guidance (check CI → git
push, else wmill sync push; record the choice as a `Deploy mode:` line in
AGENTS.md). deploy_command is null in JSON when undetermined. This resolves the
CI-backed false recommendation without the CLI re-implementing CI detection.

Also fix two review nits: restore the deploys_on_push_branch doc comment (it had
drifted onto has_runnable_delivery) and correct the app-repo comment (their
exclusion from the poll path is a conservative safe under-report, not "can't be
polled").

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

* refactor: drop the ambiguous-fork-id disambiguation from deploy-mode

The existence-query resolution guarded a very narrow case (a suffix owned by both
a coexisting wm-fork-<suffix> and <suffix> workspace, queried from the wrong one).
Not worth the per-fork query; keep the cheap candidate-family check plus the
inherited-repo membership test, which already close the real fork false-positive.

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

* refactor: drop remote-URL matching; disambiguate deploy-mode by repo count

Matching the caller's git remote URL against each repo dragged in the whole
remote-URL surface (sending the URL, credential stripping, shell-safe remote
handling, fetch/push URL, port/case normalization) — and the risk that came with
it. Replace it with a simpler rule that fits the actual question:

- deploy_on_push is true only when exactly ONE licensed, deliverable auto-pull
  repo tracks the pushed branch. With a single synced repo the local checkout is
  unambiguously it; with several we can't tell which is the caller's, so we
  return false and the CLI asks the user.
- The endpoint takes only `branch` (no `remote`); status no longer reads or
  sends the git remote.
- On the fallback, status now tells the agent to ASK the user how the repo
  deploys (CI git-push vs wmill sync push) and record it in AGENTS.md, instead of
  assuming wmill sync push. Guidance updated to match.

Removes normalize_git_remote (+url dep), getGitRemoteUrl, stripGitRemoteCredentials,
shellQuote, the --remote flag, and their tests.

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

* refactor: share fork-branch routing between reconciler and deploy-mode

Deploy-mode detection was re-deriving fork/dev routing (root walk, wm-fork/dev
branch parsing, descendant resolution, inherited-repo check) that the auto-pull
reconciler already owns — the source of repeated edge-case bugs. Extract it into
windmill_common::workspaces::resolve_fork_branch_target and have both the endpoint
and reconcile_fork_branch_pull (EE) call it, so they can't drift and dev
workspaces are handled by construction.

Endpoint now resolves the root via the canonical cached fork_ancestor_chain
(dropping a duplicate CTE) and routes forks/dev workspaces through the shared
resolver. The .sqlx cache is unchanged (the moved queries already existed).

Bumps ee-repo-ref for windmill-labs/windmill-ee-private companion.

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

* fix: exclude archived roots and frame deploy_on_push:false as unconfirmed

- deploy_on_push now requires the root workspace to be live; polling and webhook
  delivery both exclude deleted roots, so an archived root (or anything beneath
  one) with retained git-sync no longer reports deployable
- status and the OpenAPI now describe false as "not confirmed" (it also covers
  ambiguity and conservative false-negatives), not a definite no — the CLI asks
  the user rather than asserting the push won't deploy

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

* chore: bump ee-repo-ref for EE branch merge of main

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 21:44:24 +02:00
Ruben Fiszel 97f4477069 feat(forks): let a fork's creator manage developers on it without being an admin (#10166)
* feat(forks): let a fork's creator manage developers on it without being an admin

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

* fix(forks): bind the fork-creator grant to the member's parent username and lock the delete

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:13:36 +02:00
Ruben Fiszel 92b7f375a9 fix: replicate all secrets on fork when external backend is configured (#10060)
* fix: replicate all secrets on fork with external backend (WIN-2161)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: add Azure KV fork secret-replication reproduction (WIN-2161)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: condense clone_variables invariant comment (WIN-2161)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: drive real create_fork handler in Azure KV repro (WIN-2161)

Replace the windmill-common test that mirrored clone_variables' loop with an
end-to-end test in windmill-api-integration-tests that exercises the real
migration, create_fork and variable-read endpoints against a local Azure KV
emulator. Verified it fails (404 "not found in Azure Key Vault") without the
fix and passes with it; unique per-run ids keep it robust to the emulator's
persistent state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 10:19:20 +02:00
Ruben Fiszel 7363d2c217 fix(forks): require admin of both sides for the compare visibility guard (#9869)
The blast-radius guard added in #9866 forced `all_ahead_items_visible` true for
any fork/target admin. But `filter_visible_diffs` keeps a modified/conflict row
(one that exists in the source AND the fork) only when the caller can see it on
both sides, so an ahead diff can be dropped for a source-side visibility gap even
when the caller is a fork admin. Forcing the flag on fork-admin alone then wrongly
reported "all ahead items visible", letting the UI enable deployment from an
incomplete comparison.

Gate the guard on admin of BOTH the source and the fork (superadmin satisfies
both), which is what actually guarantees full visibility of every item on every
side. Adds a regression test where a fork admin who is only a plain member of the
parent (no access to the item's folder) must still get `all_ahead_items_visible
= false`, plus the superadmin sanity path.

Also restores the SQLx offline cache entry for the phantom-trigger test INSERT
that #9866 landed without (CI/`SQLX_OFFLINE=true` builds failed on it), and adds
entries for the new test's all-literal queries.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:29:53 +02:00
Ruben Fiszel 6a6f12960e fix(forks): reset diff tally on trigger delete + guard compare visibility for admins (#9866)
Deleting a trigger left a stale `workspace_diff` row: `delete_trigger` (the
generic TriggerCrud handler) was the only delete path that never called
`handle_deployment_metadata`, unlike every other kind. Because
`compare_workspaces` trusts a cached `has_changes=true` row for non-script/flow
kinds and the visibility filter then drops it (the trigger no longer exists), a
deleted trigger became a phantom "ahead" item that flipped
`all_ahead_items_visible` to false — hiding the deploy button and showing a
"changes not visible to your user" warning that even a superadmin could not
resolve (`reset_diff_tally` doesn't clear a `has_changes=true` row either).

- delete_trigger now re-tallies via handle_deployment_metadata, so the next
  compare re-evaluates and corrects/removes the row (matches resource/variable/
  folder/schedule deletes).
- compare_workspaces forces the visibility flags true per side for anyone who
  sees that side in full: target/fork admin (or superadmin) for ahead items,
  source/parent admin (or superadmin) for behind items. The flag is a pure
  visibility guarantee — the deploy itself is authorized separately — so for
  such users a dropped diff is provably a phantom, never a permission gap.
- Add a regression test asserting a phantom trigger diff row no longer blocks a
  superadmin while still (conservatively) warning a partial-context user.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:36:55 +02:00
hugocasa cbf54d4eb4 fix: preserve fork parent linkage on workspace id change (#9716)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:54:07 +00:00
Ruben Fiszel 8a3f69dda8 fix(backend): purge workspace_diff cache on workspace delete (#9627)
* fix(backend): purge workspace_diff cache on workspace delete

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

* chore(backend): add sqlx cache for workspace_diff regression test queries

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

* fix(backend): clear stale fork diff state on fork creation and backfill

Purge inherited workspace_diff/skip_workspace_diff_tally rows when a fork is
created (reused ids would otherwise leak a prior occupant's cached diff state),
and extend the cleanup migration to drop live-pointing stale skip rows that
short-circuit compare_workspaces before the has_changes reset.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:22:39 +02:00
Diego Imbert 1fc355709c feat: Db-backed user drafts (#9351)
* Db draft removal

* refactor: drop unsaved-changes confirmation modal from editors

* fix: remove nodraft from flow row edit link

* fix: remove nodraft from app and raw app edit buttons

* fix: remove nodraft from all edit links

* fix: merge backend defaults into legacy autosaves to avoid spurious restore toast on raw apps

* feat: add username column to draft table for user-scoped drafts

* feat: add sync_drafts and list_users_with_draft_on_path endpoints

* feat: add UserDraftDbSyncer service for bi-directional draft sync

* feat: wire UserDraft.save through DbSyncer + conflict modal

* refactor: gate useLocalStorageValue nested-update effect behind opt-in flag

* refactor: move sync force flag from request-level to per-entry

* feat: sync all userdraft kinds, switch draft owner to email FK, add id PK, scope draft list to readable paths

* refactor: route draft permission check through authed.folders + RLS, drop client-supplied email

* feat: support draft deletion via sync (value: null) with same conflict semantics

* feat: surface other users' drafts in editors with diff+fork action

* refactor: unify draft schema migrations and type kinds via DRAFT_KIND enum

* perf: add (workspace_id, email, created_at) partial index for sync hot path

* chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd

This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private.

Previous ee-repo-ref: 55c19293232be379a3044eb78f677b545882ffd6

New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd

Automated by sync-ee-ref workflow.

* fix(userdraft): trigger sync on deep mutations via readFieldsRecursively

* Rollback UserDraft

* remove queuing logic

* pushDrafts

* refactor: remove draft sync layer and conflict modal

* feat: add save_draft, list_drafts, get_draft routes

* feat: add get_draft overlay to getScriptByPath

* feat: extend get_draft overlay to flow, app, resource, variable, schedule, triggers

* feat: support null value in save_draft for deletes

* readLastSyncMap

* feat: redirect /add pages to /edit/draft_uuid with new_draft flag

* fix: inline get_draft query field instead of flattening

* fix: drop dangling nobackenddraft assignment in flows edit

* feat: include user drafts in list endpoints with is_draft flag

* fix: prefix draft paths with u/{user} and seed editor state on new_draft

* fix: route draft-only deletes through UserDraftDbSyncer on home page

* feat: delete user drafts when their underlying item is deleted

* fix: empty path seed on new_draft so friendly auto-name fires

* feat: re-add Draft and Draft only badges on home page rows

* fix: synthesize value wrapper on draft-only raw_app response

* fix: tolerate missing latest-version on draft-only flow reload

* fix: skip first observable change in DB sync effect to match LS persist

* fix: remove URL-hash sync from script editor (already marked TEMP)

* refactor: drop localStorage layer from UserDraft

* refactor: drop vestigial LS-era code from UserDraft

* feat: migrate localStorage drafts to DB on layout mount

* fix: migrate session runtime + script view to per-user draft API

* feat: add 'Reset to deployed' action on draft-loaded toast

* feat: hide 'Reset to deployed' action when no deployed version exists

* createCoalescingKeyedRunner

* example ts doc

* createDebouncerByKey

* refactor: drop await on draft-delete in reset flows, refetch deployed directly

* fix: bridge saved-draft shape to wire shape in apps/resources/variables loaders

* feat: route UserDraftDbSyncer.save through debouncer + coalescing runner

* feat: add immediate-save bypass that cancels pending debouncer + runner tasks

* fix: seed UserDraft cell from spec defaultValue on acquire

* fix: redirect /add routes at load phase to eliminate white flash

* fix: drop +page.js files in /add routes that conflicted with +page.ts

* refactor: send draft as separate .draft field instead of deep-merging onto deployed

* feat: surface draft path in home list when user typed one different from URL

* feat: add UserDraft.stopSync/restartSync, wire on script + low-code app /add init

* fix: thread URL path into ScriptBuilder.stopSync (was using empty initialPath)

* fix: also stopSync in route's new_draft branch + queue pre-acquire suspensions

* feat: add AutosaveIndicator backed by reactive UserDraftDbSyncer.getState

* refactor: drop draft-loaded toast in non-route editors, banner now compares draft vs deployed

* fix: gate per-user draft-only rows in listings on include_draft_only flag

* feat: flush pending draft saves via keepalive fetch on tab hide / pagehide

* autosave indicator nits

* fix: route create-vs-update on /add deploys; seed policy.execution_mode; sync script template

* chore: add [draft-sync] console logs to trace script bootstrap autosave

* fix: seed auto-generated path in script new-draft route to suppress Path widget's autosave-triggering mutation

* fix: defer script restartSync until script.path lands (Path widget gated on $userStore + $workspaceStore)

* fix: poll script.path via tick() until Path widget settles before restartSync

* chore: log inferArgs underlying error on deploy to diagnose 'Could not parse code' toast

* fix: wait for script.path to stabilize across two ticks before restartSync

* revert: drop unsuccessful path-stabilization heuristics + leftover [draft-sync] logs

* fix: seed new-draft script schema as emptySchema() so inferArgs doesn't trip on undefined properties

* fix: heal legacy drafts with schema={} (no .properties) on deploy

* autosave indicator

* refactor(editors): drop UnsavedConfirmationModal mount + Show diff button

* feat(drafts): collaboration banner, cross-tab conflict detection, raw app template picker

- Other-users-drafts banner (Modal2): the deployed-overlay response now
  carries `other_drafts_users` (workspace usernames only, never emails);
  each row offers View JSON + Fork. Drops the standalone
  `listUsersWithDraftOnPath` endpoint; `getDraftForUser` now takes a
  workspace `username` query param (resolved to email server-side).
- Cross-tab/browser save conflict detection: the syncer attaches
  `last_sync` to every save (defaults to non-force); on a `conflict`
  response it parks a snapshot in a reactive map. Each route mounts a
  `DraftSyncConflictModal` and seeds the per-tab `last_sync` via
  `recordRemoteSync(query, draft_saved_at)` on every `get_draft` load.
  Keepalive flush also respects optimistic concurrency.
- Raw app template picker re-added after the /add ⇒ /edit refactor:
  framework (React 19 / 18 / Svelte 5), data table + schema config, and
  optional AI prompt — extracted into `RawAppTemplatePicker.svelte` and
  driven by `new_draft=true` on the edit route.

* fix(drafts): suppress autosave during /add template seeding on script + raw app editors

- ScriptBuilder: delay `restartSync` 500ms past `initContent` + stores-
  ready so the Path widget's `$workspaceStore && $userStore`-gated
  `initPath → reset → onMetaChange → bind:path` cascade lands inside
  the suspension window. Two `tick()` waits weren't enough — the
  bind:path mutation fired ~100ms after the prior `restartSync` and
  posted as a "user edit".
- apps_raw route: suspend autosave on `new_draft=true` and resume only
  after the framework picker closes (via `onStart` or X dismissal),
  with a two-tick settle so the picker's seeded
  `files/runnables/data/policy` mirror to `draftHandle.draft` observably
  advances `lastSerialized` before sync re-arms.

* fix(drafts): land /add redirects on the real workspace username, not "me"

The `/add` → `/edit/u/{username}/draft_{uuid}` redirects ran during
SvelteKit's load phase, BEFORE the (logged) layout's async `getUserExt`
populated `userStore`. `get(userStore)?.username` returned undefined and
fell back to the `'me'` placeholder on every fresh nav, producing
`u/me/draft_{uuid}` paths instead of the user's real namespace — broke
ownership checks against `authed.username` and silently scoped autosaves
under the wrong path.

Layout now persists `username` to localStorage on every successful
`getUserExt`, and `getUsernameForNamespace` (new shared helper, used by
all four `/add/+page.ts` files) reads the live store first, falls back
to the cached value, and only then to `'me'` for true first-ever loads.

* fix(drafts): key low-code app autosave on the URL path, not the empty string

`AppEditor` keyed its `UserDraft.use` handle on `newApp ? '' : path` —
a legacy leftover from when `/apps/add` was its own URL (no path). With
the `/add` ⇒ `/edit/u/{user}/draft_{uuid}` redirect, `newApp=true` made
autosaves land on the `('app', '')` row instead of the URL path:
  - The `apps/list?include_draft_only=true` query joins drafts onto
    `app.path`, surfacing drafts at the URL path. The empty-path row
    didn't match the user's URL so the draft never appeared in the home
    list.
  - Refreshing `/apps/edit/u/{user}/draft_{uuid}` re-fetches at the URL
    path with `?get_draft=true`, finds nothing, and 404s.

Drop the ternary so the handle always uses `path` — the same as
scripts/flows/raw_apps. The route's `?new_draft=true` branch already
seeds the empty-template baseline, so there's no longer a "the
draft sits under '' until first save" race to worry about.

* fix(raw_app): propagate template picker X / Esc dismissal so autosave resumes

The picker mounted `<Modal kind="X" open ...>` (one-way prop, not
`bind:open`). When the user dismissed via X / Esc / click-outside, the
inner Modal flipped its own local `open` to false (hiding the UI) but
never wrote back to the picker's `open` $bindable. The route's
`templatePicker → false` watcher — the one that calls `restartSync`
two ticks after the picker closes — never fired, so autosave stayed
suspended and the user's edits after dismissal were silently dropped.

Switch the inner Modal to `bind:open` so the dismissal bubbles all the
way up to the route's state. "Start without AI" already worked because
its `onStart` handler explicitly sets the picker's `open = false`.

* nit unused

* fix(drafts): make the home-page View/Edit JSON action work on draft-only apps

The "View/Edit JSON" entry on the home page called `AppService.getAppByPath`
without `get_draft=true`, so for draft-only items at `u/{user}/draft_{uuid}`
the backend 404'd with "App not found at path …". Pass `get_draft=true`
and render the synthesized stand-in's editable shape:

- App drafts come back as `{summary, value, path, policy, ...}` — `value`
  is the App definition the editor was working on; show that.
- Raw-app drafts come back as the flattened
  `{files, runnables, data, summary, policy, ...}` with no nested `value`;
  show the whole shape.

On save, draft-only items can't go through `updateApp` (no deployed row).
Route the edit through `UserDraftDbSyncer.save` (with `immediate: true`
so `await` resolves after the POST lands) and relabel the button
"Save draft" + Save icon. Deployed items keep the existing "Deploy"
flow unchanged.

* fix(drafts): render the right shape in View/Edit JSON for draft-only items

The previous fix landed `fapp.value` into the editor, but the
deployed-overlay flattens the bare editable shape into `inner`/the
top-level response — drafts have no nested `.value`. So:

  - App drafts (`{grid, breakpoints, hiddenInlineScripts, …}`) rendered
    as empty (`fapp.value` was undefined).
  - Raw-app drafts 404'd outright: `get_draft=true` with no `rawApp` flag
    can't tell which draft kind to look up, defaults to `app`, doesn't
    find one.

Thread the row's `raw_app` flag from AppRow → `appExport.open(path,
rawApp)` → `getAppByPath({..., rawApp})` so raw-app drafts resolve to
the right `UserDraftItemKind`. Read `fapp.draft` (the bare editable
shape from `fetch_draft_only`) into the JSON editor for draft-only
items — clean payload, no `is_draft` / `no_deployed` / overlay noise.
Save the same bare shape back through the syncer so the regular
editor reads it unchanged on the next mount.

* fix(drafts): skip public-secret-URL fetch in the Deploy drawer for draft-only apps

Opening the Deploy drawer on a `/edit/u/{user}/draft_{uuid}` app fired
`AppService.getPublicSecretOfApp` immediately because the gating effect
only checked `appPath != ''` + `savedApp`. The `/secret_of/{path}` route
plain-SELECTs `app.id`, so a draft-only path 404'd with
"App not found at name …" and the public-URL ClipboardPanel spun
forever waiting on `secretUrl`.

Thread the existing `newApp` signal (already on `AppEditorHeader` /
`RawAppEditorHeader`) into `AppEditorHeaderDeploy`, gate the fetch
behind `!newApp`, and render the existing "Deploy this app once to get
the public secret URL" placeholder instead of the spinner for
draft-only items.

* fix(drafts): disable Diff button on draft-only items across the 4 editors

Diff has no baseline to compare against on draft-only items — the
button used to be gated by the pre-PR `/add` route's own state, but the
`/add → /edit` redirect landed everything under the regular `/edit`
page where the gate was missing.

- ScriptBuilder: gate the topbar Diff on `savedScript.no_deployed`;
  seed `no_deployed: true` on the route's `new_draft` empty NewScript
  so the gate fires before the first deploy.
- FlowBuilder: gate the topbar Diff on `newFlow` (route already sets
  it from `backendFlow.no_deployed` and the new-draft branch).
- AppEditorHeader: gate both the "Diff" dropdown action and the
  Deploy-drawer's "Diff" button on `newApp`.
- RawAppEditorHeader: gate the topbar Diff + the Deploy-drawer's "Diff"
  button on `newApp`.

Each gate also rewrites the tooltip ("Deploy this … once to compare
against the deployed version") so the hover state explains why.

* fix(drafts): disable the "No login required" toggle on draft-only apps

Flipping the toggle called `setPublishState`, which POSTs the new
`policy` through `AppService.updateApp` — that handler's
`UPDATE app ... RETURNING path` finds nothing on a draft-only path
and `not_found_if_none` 404s with "App not found at name …"
(apps.rs:1975). Gate the Toggle on `!newApp` too so the user has to
deploy once before configuring the publish state.

* refactor(drafts): drop dead draft_path field from list responses

The draft-only listing branches in scripts/flows/apps computed a
`draft_path` from the draft JSON (when the user-typed path differed from
the URL's autogenerated `u/{user}/draft_{uuid}`), and `{Script,Flow,App}
Row.svelte` preferred it over `path` for the row title. In practice
that path is never written: the app, raw-app and flow editors all warn
"Deploy the X to make the path change effective" — the rename only
lands on deploy, never in the draft. So the field is always None and
the home rows always show the autogenerated slot anyway.

Drop the field from the three `Listable*` structs, the three draft-only
push sites, the three OpenAPI response schemas, and the three frontend
row components. Client regenerated.

* fix(drafts): seed a friendly name on /flows/add

The flow route passed `initialPath={page.params.path ?? ''}` to
FlowBuilder, so on the `/flows/add → /flows/edit/u/{user}/draft_{uuid}`
redirect the Path widget's `initPath` saw a non-empty `initialPath` and
skipped the `reset()` branch that auto-generates the friendly
`<random_adj>_flow` name. The other three editors all clear
`initialPath` in their `new_draft` branch for exactly this reason.

Track `initialPath` as route-owned state (defaults to the URL path) and
clear it to '' inside the `new_draft` branch, then bind it through to
FlowBuilder so any post-deploy update from the editor still propagates.

* feat(drafts): render friendly user-typed path on home list for all 4 kinds

Reinstate `draft_path` on `Listable{Script,Flow,App}` so the home rows
prefer the user-typed name over the autogenerated `u/{user}/draft_{uuid}`
URL slot, with two source rules — one per how each editor wires the
Path widget:

- Scripts already work: `ScriptBuilder` binds the Path widget directly
  to `script.path`, so the typed path round-trips through the draft
  JSON's own `path` field. Backend extracts `v["path"]` when it differs
  from `row.path`.

- Flows / apps / raw apps don't write the typed path into the
  autosaved value (`Flow.path` is one-way-bound to `$pathStore`; the
  bare `App` / raw-app value has no `path` field at all). Introduce an
  explicit `draft_path` field on the draft JSON, written by the editor
  ONLY when the typed path differs from the deployed/seeded
  `savedX.path`:
  - FlowBuilder: $effect on `$pathStore` mutates `flow.draft_path`.
  - AppEditorHeader: $effect on `newEditedPath` mutates `$app.draft_path`.
  - RawAppEditorHeader: $effect surfaces `pendingDraftPath` up via the
    bind chain (RawAppEditor → route); the route's draftHandle.draft
    spread includes `draft_path` when set.
  Backend extracts `v["draft_path"]` and `None` when unchanged or after
  deploy (deploy clears the whole draft, so the field naturally
  disappears post-deploy without bookkeeping).

Flow route's `new_draft` branch now stops sync around the Path widget
cascade, with a 700ms scheduled `restartSync` (mirrors the existing
scripts/apps/raw_apps stoppers) — the new draft_path mutation lands
inside that window so `/flows/add` no longer fires an autosave before
the user's first edit. openapi/sqlx regenerated.

* fix(drafts): preserve the user-typed draft_path on reload of draft-only items

The flow / app / raw-app editors all dropped the saved `draft_path`
back to the URL's `u/{user}/draft_{uuid}` slot the moment the user
reloaded a draft-only edit page: the route sourced the Path widget's
initial path from `page.params.path` instead of the previously-saved
`draft_path`, and the first user edit then mirrored that URL path
back into the autosaved draft — silently overwriting the friendly
name in both the row and the editor.

- Flow route: after computing `effectiveFlow`, override `flowInitialPath`
  with `effectiveFlow.draft_path` when set.
- App route: pass `newPath={(app.value as any)?.draft_path ?? app.path}`
  through to `AppEditor`; AppEditorHeader's `newEditedPath` default now
  prefers a non-empty `newPath` over the random `<adj>_app` seed (the
  `newApp && !newPath` branch keeps the `/apps/add` friendly auto-name).
- Raw-app route: surface `savedRawAppDraft.draft_path` onto `backendApp`
  so the `extractRawApp` path seeds `newPath` with the friendly name.

Reload + a subsequent edit now leaves `draft_path` intact for all three
kinds; verified end-to-end via the `/drafts/get_draft/...` endpoint.

* fix(ui): default Modal2 target to 'body' so omitting the prop doesn't throw

Modal2 defaulted `target = ''` and forwarded it to `Portal`, which calls
`document.querySelector(target)` — an empty selector throws
"Failed to execute 'querySelector' on 'Document': The provided selector
is empty" and the modal silently fails to mount.

That's why `OtherUsersDraftsModal` (and `DraftSyncConflictModal`) never
appeared on editors where another user had a draft — both omit the
`target` prop. Other Modal2 callers (StorageSettings, CriticalAlert,
CustomInstanceDbWizardModal, …) pass an explicit `target="#content"`
and were unaffected.

Match Portal's own default of `'body'` so omitting the prop is now a
no-op rather than a runtime throw.

* fix(drafts): Reset to deployed no longer resurrects the draft

The toast's "Reset to deployed" callback POSTed `value: null` to the
syncer, then handed control to the route's `onResetToDeployed` (which
wipes the in-memory handle and reloads the deployed payload via
`getDraft: false`). Both writes flowed through the reactive sync
effect: the wipe scheduled a delete, the reload scheduled a re-save of
the deployed value as the new draft. Coalescing collapsed them and the
draft came back — making the "discard" action effectively a no-op.

Wrap the whole callback in `UserDraft.stopSync` / `restartSync`. The
explicit `value: null` POST still goes through (it's a direct
`UserDraftDbSyncer.save` that doesn't depend on the reactive effect),
the route's wipe-then-reload mutations advance `lastSerialized` silently
under suspension, and the next user edit (after two ticks past the
deployed-seed write) is the first real save again.

* ui nit

* feat(drafts): autosave-indicator popover with Reset-to-deployed action

Click the cloud icon → popover with "All changes are saved as a draft on
the server. The draft is per-user — your teammates' editors keep their
own." When the editor isn't on a draft-only path AND the user has a
draft (UserDraft.has returns true), a "Reset to deployed" button
mirrors the load-time toast action — stops sync, POSTs `value: null`,
runs the route's reload-without-draft callback, restarts sync past two
ticks so the deployed-seed write doesn't resurrect the draft.

Threaded `onResetToDeployed` from each route down to its builder
(ScriptBuilder / FlowBuilder / AppEditorHeader / RawAppEditorHeader)
and into the indicator. `draftOnly` is wired from `savedScript.no_deployed`
/ `newFlow` / `newApp` so the action hides where there's nothing to fall
back to. The indicator's trigger now has a hover affordance + matches
Portal's default target ('body') via Modal2's earlier fix.

* fix(drafts): wait for the fork POST to land before navigating

OtherUsersDraftsModal's Fork action called UserDraft.save, which routes
through the autosave debouncer (1500ms). The subsequent goto fired
within the same tick, so the destination editor's get_draft=true read
ran before the POST landed and 404'd — refreshing worked because by
then the debounced save had fired.

Call UserDraftDbSyncer.save with immediate: true and await it. The
syncer cancels any queued debouncer task for the key and resolves the
promise only after the POST completes, so the route load can find the
forked draft on the first try.

* fix(drafts): conflict detection — keep last_sync map tab-local instead of in localStorage

Two tabs editing the same draft both load with last_sync = T0.
Tab-1 saves; the server accepts, returns T1, and the syncer wrote T1
into localStorage. Tab-2 then tries to save: it reads the SHARED
localStorage map, sees T1 instead of its own baseline T0, sends
last_sync = T1, and the backend's WHERE clause (`created_at <=
last_sync`) is true → tab-2 clobbers tab-1's edit without ever seeing
a conflict.

Move the map to tab-local memory (`new Map<string, …>`). Reload of the
tab now starts with an empty map; that's fine because the editor's
load path calls `recordRemoteSync(query, draft_saved_at)` right after
`get_draft=true` returns, reseeding from the authoritative server
timestamp before any user edit could fire a save.

* fix(drafts): OtherUsersDraftsModal — close on Fork, don't leak clicks through nested JSON

Two bugs in the per-editor "another user has a draft" banner:

- Fork landed the immediate save but didn't close the banner before
  navigating. Svelte hadn't torn down the previous route's components
  by the time goto returned, so the banner lingered on top of the
  destination editor. Comment the explicit isOpen=false on the
  happy path so it's clear it MUST run before goto.

- Clicking anywhere on the screen while the View JSON drilldown was
  open closed the underlying banner too. Modal2's clickOutside
  action fired on every Modal2 instance — both the JSON modal and
  the underlying banner — because both attach their own listener at
  the document level. Add `closeOnOutsideClick` opt-out on Modal2
  and pass `closeOnOutsideClick={!jsonOpen}` to the outer modal so
  clicks outside the JSON drilldown only close the drilldown.

Drive-by: Modal2's keydown handler now ignores Escape when its own
isOpen is false (was a no-op closer that would still preventDefault
on every key press, swallowing key events for any siblings).

* fix(drafts): conflict modal wording — drafts are user-scoped, not teammate-scoped

* fix(drafts): defer reset-to-deployed restart until first user interaction

Two-tick `restartSync` was too aggressive: editor remounts emit a tail
of cascading writes (Monaco setValue acks, schema re-infer, UI Builder
iframe handshakes, schedule-config recomputes, …) that land well after
two ticks and would clobber the just-deleted draft with an upsert of
the deployed value — making "Reset to deployed" a no-op in practice,
the user kept seeing the draft come back.

Centralise the suspension lifecycle in a new `runResetToDeployed`
helper. It stopSyncs around the reset, POSTs the explicit delete, runs
the route's wipe-and-reload, and then arms a one-shot listener on
document keydown / input / pointerdown that restartSyncs on the user's
next real interaction. A 5-second fallback re-arms sync if the user
walks away without touching the editor, so suspensions don't leak.

Use it from both the load-time toast (`notifyDraftLoaded`) and the
autosave-indicator popover so the two stay in sync — fixes both
entry points.

* indicator ui nits

* fix(drafts): split tab-switch and unload flushes — kill self-conflict on visibility change

The single keepalive flush bound to both `visibilitychange → hidden`
and `pagehide` self-conflicted on tab switch: visibilitychange fires
on every tab/app switch with the page still alive, the keepalive POST
advanced the server's `created_at` to a fresh `now()`, the client
discarded the response (no listener), the local `lastSync` stayed at
the old value, and the next foreground autosave sent that stale
timestamp → server saw `created_at > last_sync` → conflict modal for
the user's own background-tab write. A still-pending debouncer task
made it worse: it fired a second runner POST after the keepalive with
the same stale `last_sync`, the second self-conflicted too.

Split into two paths:

- `visibilitychange → hidden` → `flushOnVisibilityHidden`: route
  through the normal runner pipeline. The page is alive, so the
  response can land and `setLastSync` keeps the baseline current. Call
  `debouncer.cancel(key)` first so a queued keystroke can't double-fire
  with the same stale `last_sync`.

- `pagehide` → `flushOnPageHide`: keep the `keepalive: true` raw fetch
  for the genuinely-going-away case (the JS context is torn down, the
  response is necessarily discarded). Same `debouncer.cancel(key)`
  guard. On the next mount, the route's `recordRemoteSync(query,
  draft_saved_at)` reseeds `lastSync` from authoritative server state
  before any user edit can fire a save.

* fix(drafts): drop the visibilitychange flush — debouncer keeps running on hidden tabs

Tab switching just hides the page; the JS context survives and the
debouncer's `setTimeout` keeps counting down. When it fires, the runner
POSTs normally and the server's response updates `lastSync`. There's
nothing left for a visibilitychange-driven flush to do that the
ordinary pipeline doesn't already handle, and adding one only creates
extra POSTs to reason about.

`pagehide` remains the single trigger for the keepalive flush — that's
the case where the JS context is actually being torn down and the
runner's pending fetch would otherwise be killed mid-flight.

* nit

* refactor(drafts): drop LS-era pipeline; backend is canonical on load

The PR's iteration left behind a meta/staleness pipeline carried over
from the localStorage era — per-rev tracking, a LocalDraftStaleModal, a
'Restored from local storage' toast, and a localDraft-vs-backend
comparison branch in every editor loader. With drafts now living in
the DB and the optimistic-concurrency lastSync check handling
divergence, that whole stack is dead weight.

Worse, the comparison branch caused 'Load from server' in the conflict
modal to do nothing: the loader preferred the in-memory cell over the
backend, so the user-clicked 'load from server' just re-displayed the
local edits AND fired two confusing toasts (Restored from local
storage + Loaded your saved draft).

The rip:

* userDraft.svelte.ts: drop UserDraftMeta, StoredDraft.meta,
  checkStaleness, UserDraftStalenessCause, normalizeForCompare,
  localDraftDiffers, saveMeta, getMeta, setDraftAndMeta, setMeta,
  handle.meta/setDraftAndMeta/setMeta, force option. Handle is now
  just { draft }.
* userDraftToast.ts: drop notifyRestoredFromLocal +
  RestoreFromLocalActions. Update copy.
* LocalDraftStaleModal.svelte: deleted.
* AppEditor.svelte: drop initialRevs prop and the firstMirror
  wipe-then-restore dance (it existed only to consume the meta-mismatch
  skip slot).
* All 4 editor routes: backend is canonical on load — the in-memory
  cell is overwritten with the deployed+draft overlay, the syncer's
  seed guard swallows the first write so we don't POST it back.
* VariableEditor / ResourceEditor: drop the staleness pipeline + rev
  bookkeeping; backend wins on open.
* useTriggerDraftSync.svelte.ts: inline the JSON-normalize + deepEqual
  utility as a private cfgDiffers helper (kept for the form-vs-deployed
  dirty check, which is a genuine semantic compare, not LS legacy).
* copilot core.ts / userDraftAdapter.ts: drop meta argument from
  saveAppDraft, loadAppDraftValue, write*Draft. Test assertions on
  getMeta dropped.

Net: -22 typecheck errors, fewer moving parts, conflict modal works.
EOF
)

* refactor(drafts): remove dead endpoints + UserDraftDbSyncer.getLastSync

The list_drafts and get_draft (own) routes were added during PR
iteration and never wired up to any frontend caller — the editor
overlay path uses the per-kind get-by-path getDraft query parameter,
and the home page lists drafts via the per-kind list endpoints, not
via /drafts. Drop both routes (+ sqlx caches + OpenAPI entries).

UserDraftDbSyncer.getLastSync was a peep-hole for callers that never
materialised — the per-tab lastSync map is only ever read by postSave
internally, where the bookkeeping already lives inline.

* refactor(drafts): extract DraftEditorModals trailer block

The four editor routes (scripts/flows/apps/apps_raw) mounted an
identical pair of trailer modals — DraftSyncConflictModal +
OtherUsersDraftsModal — wrapped in the same guard chain and {#key path}
remount. Lift the markup into one component; routes thread their
itemKind, path, editPathFor, and loader callback.

Pure markup extraction, no state ownership change. Drops the unused
userStore import where the trailer was the only consumer.

* refactor(drafts): UserDraft.useReactive — kill array-of-one boilerplate

The script + flow routes both wanted a handle that re-keys when the URL
path changes. UserDraft.use() can't do that (its opts getter is
untracked), so each route hand-rolled the same useMany-array-of-one +
proxy idiom:

  const handles = useMany(() => [{ kind, path: reactive }])
  const handle = { get draft() { return handles[0]?.draft }, ... }

Add UserDraft.useReactive(getSpec) that internally wraps useMany with a
single spec and returns the stable proxy. Callers collapse to one line.

* refactor(drafts): unify bootstrap suspension via armRestartOnFirstInteraction

The flow and raw-app routes each rolled their own end-of-bootstrap
resume: a 700ms setTimeout for flows and a templatePicker watcher with
double-tick gating for raw-apps. Both are timing-fragile (the comments
admit it) and drift from each other.

armRestartOnFirstInteraction already existed in userDraftToast.ts for
reset-to-deployed: keydown/input/pointerdown listeners (capture phase)
that fire restartSync on the first real user touch, with a 5s
belt-and-braces fallback. Export it and use it everywhere we'd previously
have picked a magic number.

For raw-apps this is a tiny behavioural change: the user's template
choice now POSTs immediately (the pointerdown that picks the template
also resumes sync, so the picker's onStart write rides the wake-up).
Previously the choice only persisted on the user's NEXT edit. That's
strictly better — navigating away preserves the choice now.

* refactor(drafts): type App.draft_path; drop the as-any cast

The audit asked for the three editors to converge on one draft_path
injection pattern. For App and Flow, the in-builder $effect-mutates-
the-store idiom is wedged into a shape that doesn't natively own the
field — App's editor type genuinely has no draft_path so the writer
had to cast through `as any`, and consumers downstream did the same.

The minimum viable fix: declare draft_path on the local App type
(it's already a field on the autosaved JSON). Lifting the writes
upward into a route-side merger would mean restructuring the
AppEditor mirror $effect and the FlowBuilder pathStore plumbing —
larger change for the same shape, deferred to a follow-up.

Flow already has the typed cast localised at one site. Will get the
OpenAPI-level draft_path field as part of task 47 (drop as-any
casts on backend overlay reads).

* refactor(drafts): extract makeDraftAddLoad helper

Four identical /add/+page.ts files differing only by the edit-route
prefix. Lift the redirect into a factory, slim each entry point to
two lines.

* refactor(drafts): type UserDraftOverlay.other_drafts_users in the OpenAPI

The backend response carried other_drafts_users on every get-by-path
that supports the draft overlay, but the OpenAPI schema didn't declare
the field. Each route had to cast the typed response to `any` to read
it (and the sibling draft_saved_at), which obscured the real shape from
the type system and rotted the discoverability of the draft surface.

Add it to UserDraftOverlay. Frontend casts collapse to plain property
reads in the three editor routes.

* feat(drafts): list & open draft-only items for variables, resources, schedules, triggers

For scripts/flows/apps the list and get-by-path endpoints already
surface per-user drafts that have no deployed counterpart — that's
what gates the home page from 404'ing on an AI-agent-created draft.
Extend the same support to the other UserDraftItemKinds:

Backend (list endpoints):
- Add include_draft_only to ListVariableQuery, ListResourceQuery,
  ListScheduleQuery, StandardTriggerQuery (the latter covers the
  11 trigger kinds via the generic TriggerCrud).
- Append per-user draft rows whose path has no deployed row. Same
  gate as scripts/flows/apps: non-operators, page 0, no narrowing
  filters. Synthesis is per-kind: ListableVariable/Resource get
  field-for-field synthesis; ScheduleLight reads NewSchedule shape;
  Trigger<T> uses a best-effort JSON merge + serde_json::from_value
  (rows skipped on deserialize failure rather than failing the list).
- Add draft_only: Option<bool> with sqlx(default) to each row type
  so it serializes as the column is opt-in.

Backend (get-by-path endpoints):
- get_variable, get_resource, get_schedule, get_trigger<T> fall back
  to fetch_draft_only when the deployed row is missing and the
  caller passed get_draft=true. Mirrors scripts/flows/apps.

OpenAPI:
- Shared IncludeDraftOnly parameter under components/parameters,
  wired into the 11 trigger list endpoints + listRawApps. Inline
  declarations on listVariable / listResource / listSchedules /
  listAzureTriggers.
- draft_only field on ListableVariable, ListableResource,
  Schedule, TriggerExtraProperty.

Frontend:
- variables, resources, schedules, and the 10 trigger list pages
  (routes + 9 *_triggers) pass includeDraftOnly: true on the
  initial fetch and render <DraftBadge draft_only> on synthesized
  rows. Trigger pages got a sed/perl bulk update — pattern is the
  same across kinds.

* fix(drafts): swap crypto.randomUUID() for the project's randomUUID helper

crypto.randomUUID() is gated on a secure origin (HTTPS or localhost).
Self-hosted Windmill instances often run on a bare HTTP origin or a
LAN IP where the WebCrypto API is unavailable, so the /add redirect
would throw before issuing the 307. Use the existing RFC4122 v4 helper
in FlowChatManager that the rest of the codebase already imports for
this exact reason.

* fix(editor): leading-edge fire + max-wait cap on Monaco debounce

The Editor debounced `onDidChangeModelContent` purely on the trailing
edge — every keystroke rescheduled a 500ms timer, and uninterrupted
typing held the bindable `code` prop stale until a pause. Stacked
behind our 1.5s autosave debouncer that meant our clock didn't even
start ticking until 500ms after the user paused, and the `code`
binding never updated mid-burst for downstream consumers (lint,
live preview, change listeners).

Switch to leading + trailing + max-wait:

* First keystroke of a burst fires `updateCode` synchronously, then
  stamps a wall-clock chain start.
* Each subsequent keystroke (re)arms a trailing timer at
  `min(now + changeTimeout, chainStart + maxChangeTimeout)` — the cap
  is what makes continuous typing materialize at least once per
  maxChangeTimeout window instead of indefinitely.
* When the trailing fires it resets the chain so the next keystroke
  after a pause is a fresh leading fire.

New prop `maxChangeTimeout` (default 1000ms) sits next to the
existing `changeTimeout` (default 500ms). Dispose path clears the
chain stamp alongside the timer.

* feat(drafts): wire Ctrl/Cmd+S to flush the pending autosave immediately

Each builder already had a Ctrl/Cmd+S keybinding routed through a
saveDraft() no-op left over from the LS-era — the comment said
"persistence happens via the page-level UserDraft autosave" but the
shortcut was the user's only way to actually force a save without
waiting for the 1.5s debounce. Restore the intent.

* UserDraftDbSyncer.flush({ workspace, itemKind, path }) — new method
  that re-submits whatever's queued in pendingSaveOpts with
  immediate: true. No-op when nothing's pending.

* Editor.svelte.flushPendingChanges() — exposes a synchronous
  updateCode() with chain reset, so callers can drain Monaco's own
  trailing debounce before asking the syncer to flush. Without this
  step a Ctrl+S within ~500ms of typing would POST the pre-burst
  content.

* ScriptBuilder.saveDraft() — editor?.flushPendingChanges() →
  await tick() → UserDraftDbSyncer.flush(). Toast on result.
* FlowBuilder.saveDraft() — no direct Monaco ref (flows have many
  per-module editors); just flushes the syncer. Editor.svelte's new
  1s max-wait cap means at most the last <1s of typing in a module
  Monaco won't be in this POST; it follows in the next autosave
  round.
* RawAppEditor.handleKeydown — adds a 's' case that flushes before
  the focus guard, so the shortcut fires regardless of where focus
  is in the editor pane.

* fix(drafts): low-code apps — drop spurious autosave on /edit + remount on Load from server

Two bugs in low-code app editor (raw apps use a separate code path):

1. Every /edit visit looked like an autosave because loadApp() called
   UserDraft.discard('app', path, undefined). The comment claimed
   "this load doesn't POST" but discard always POSTs value: null
   server-side — that surfaced as a DELETE-my-draft on every page
   load AND a flash in the AutosaveIndicator.

   The discard was originally intended to wipe the in-memory cell so
   AppEditor remounts "fresh". But the path-change $effect upstream
   already sets app = undefined before each loadApp, which unmounts
   AppEditor and releases the handle's entry — so a remount via
   app = backendApp naturally starts with an empty handle. Drop the
   discard.

2. The conflict modal's "Load from server" called loadApp() but
   didn't remount AppEditor. Since AppEditor's stateApp is captured
   once at mount and doesn't react to prop changes, the editor kept
   showing the conflicting local edits even after a successful reload.
   Wrap the onLoadFromServer to await loadApp() then bump redraw to
   force a fresh mount.

* feat(drafts): home-page Draft badge — show user-initial circles, drop the '+'

The home-page Draft badge previously showed '+Draft' as a flat label.
Add per-user awareness: up to 3 user-initial circles render to the left
of the label, ordered alphabetically; with 4+ users we collapse to the
first 2 + a '+N' overflow circle so rows stay compact.

Backend:

* New `DraftUserRef { username: Option<String> }` in
  windmill-types::user_drafts, re-exported from windmill-common so the
  list endpoints in scripts/flows/apps crates share one import path
  (windmill-types/windmill-common can't be reordered without a cycle).
* ListableScript / ListableFlow / ListableApp gain a
  `draft_users: Option<sqlx::types::Json<Vec<DraftUserRef>>>`
  field. The list SQL adds a per-row subquery
  `SELECT json_agg(...) FROM draft d LEFT JOIN usr u ...` that
  aggregates the workspace users with a per-user draft at this path.
  NULL (no drafts) decodes to None; LEFT JOIN against `usr` lets
  orphaned drafts (user removed from workspace) still surface with
  username = None.
* Synthesized draft-only rows set draft_users to a single-element
  vector with the authed user (those rows come from `email = $2`).

OpenAPI: `draft_users` added to listScripts / listFlows / ListableApp
response shapes as an array of `{ username }` with nullable username.

Frontend DraftBadge:
* Accepts `draft_users: { username?: string | null }[]`. Renders up
  to MAX_CIRCLES (3) initial circles; at 4+ users renders first 2 +
  a gray '+N' overflow circle.
* Initials: 'john.doe'/'john_doe' → 'JD', 'alice' → 'AL', the legacy
  NULL-email row → '?'.
* Color picked deterministically from a 6-entry palette so the same
  user gets the same circle color across rows.
* Label is now just 'Draft' (dropped the '+'). 'Draft only' is
  unchanged.
* Tooltip lists every user in full.

ScriptRow / FlowRow / AppRow thread `draft_users` through their
prop types and pass it to DraftBadge.

* fix(drafts): suppress 'You have unsaved changes' banner when deployed baseline is null

A brand-new variable/resource/trigger (no deployed row yet) has
`getDeployed() == null`, but the caller's `show` prop is computed
off `current != deployed` which is trivially true while the user
types. Result: the banner appeared with 'Show diff' (no-op — the
drawer early-returns on null deployed) and a 'Discard' that's
semantically backwards (there's nothing to revert to).

Gate `show` internally on `getDeployed() != null`. The check sits
in the banner rather than each caller because every caller would
otherwise need the same boilerplate guard.

* fix(drafts): hide LocalDraftBanner when deployed and current match the DiffDrawer's compare

Earlier I gated the banner on `getDeployed() != null`, but the user
still saw it fire on entries where 'Show diff' opens to 'No changes
detected'. That means `show` (the caller's coarse dirty check) flagged
a difference the DiffDrawer treats as a no-op — typically toggle
defaults (`false ↔ undefined`), removed empty arrays, or key-ordering
noise that `cleanValueProperties + orderedYamlStringify` collapses.

Replicate the drawer's comparison inside the banner: stringify both
sides through the same pipeline and only render when the keys differ.
A single `diffKey()` helper keeps the logic local; the catch-and-empty
fallback survives a non-serializable side rather than throwing.

* ui(drafts): nest user-initial circles inside the Draft badge

Previously the circles sat alongside the Badge in a parent flex
container; the result read as two separate UI elements. The Badge
component already exposes its children as a snippet rendered inside
its own flex row, so moving the circles into it makes them feel like
part of the same chip.

Knock-on tweaks: shrunk the circles from h-4/w-4 to h-3.5/w-3.5 so the
badge stays compact, and tinted each circle's ring with the badge's
indigo palette (instead of plain white) so the overlap reads as a
deliberate stack rather than dots floating on top of the chip.

* feat(drafts): drop the authed user's circle, mark own drafts with a '*' suffix

Three tweaks to the home-page Draft badge:

1. Filter the authed user out of `draft_users` before rendering
   circles. The row already signals 'this user has a draft' via the
   asterisk (below), so a circle for them would be redundant noise.
   New `currentUsername` prop on DraftBadge — pass
   `$userStore?.username` from each row. The tooltip still lists every
   user (with `(you)` next to the authed one) so the full picture is
   one hover away.

2. The badge already showed whenever `is_draft || draft_users.length > 0`
   (per-user OR any-user). Spelled the rationale out in a comment —
   no logic change.

3. Append '*' to the displayed summary when `is_draft` is true. Falls
   back to `draft_path`/`path` when summary is empty so the marker
   never decorates an empty string. Threaded the same expression into
   ScriptRow / FlowRow / AppRow.

Slice/overflow math now keys on the post-filter `otherUsers` list, so
dropping the authed user doesn't silently shrink the visible count
(e.g. 3 users incl. self → 2 circles, not 1 circle + a '+1' bubble).

* feat(drafts): clone per-user drafts when forking a workspace

`clone_workspace_data` clones every other workspace-scoped table on
fork creation (resources, variables, scripts, flows, apps, raw apps,
triggers, schedules) but quietly dropped the `draft` table. With
per-user drafts that meant any open editor in the parent lost its
pending edits the moment a fork was created — surprising and
inconsistent with how forks treat the deployed surface.

New `clone_drafts` mirrors the existing clone helpers: a single
INSERT...SELECT into the target workspace, preserving `path`, `typ`,
`value`, `created_at`, and `email`. The `email` FK targets
`password.email` which is instance-scoped so it carries across
workspaces without remap. `created_at` is preserved on purpose so the
per-tab `last_sync` baseline lines up with the parent's timeline —
otherwise the fork's next autosave would race a stale `last_sync`
and trip the conflict modal on every cloned draft.

Plain INSERT (not UPSERT) is safe because the fork target is empty at
create time; no conflict against the partial unique indexes
(`draft_pkey_with_user` / `draft_pkey_legacy`). The synthetic
BIGSERIAL `id` PK is regenerated by the default so it stays out of
the column list.

* ui(drafts): pin the authed user to the first circle instead of hiding them

Previously the authed user was filtered out of the circle row entirely
on the theory that the row's '*' suffix already signalled 'this user
has a draft'. New requirement: they should always lead the circle row
when they have a draft so the visual half of the signal lines up
across rows (consistent leading-slot identity, easy scan).

Switch from a filter to a sort: `orderedUsers` finds the authed user
in `draft_users` and splices them to index 0; everyone else keeps the
backend's alphabetical order behind. Slice/overflow math now keys on
`orderedUsers`, which guarantees the authed user never falls into
the '+N' bubble — they're at position 0 and the slice keeps the head.
The popover's '(you)' annotation moves to the circle's title attr too,
so hovering the leading circle confirms the identity.

* feat(drafts): drop draft_only column from script/flow/app

Drafts now live in the `draft` table exclusively — `draft_only` stubs in
script/flow/app are redundant. Migration `INSERT INTO draft ... ON
CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING` so
real per-user drafts already at the same path are preserved; only rare
stubs that lost their draft get a synthesised workspace-level row.
Stubs are then deleted (FKs cascade to *_version) and the column is
dropped. List endpoints keep a synthesised `draft_only: true` on rows
sourced from the draft table itself (sqlx default on the struct field).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui(drafts): surface draft state in AutosaveIndicator instead of toast+auto-modal

The "Loaded your saved draft" toast and the auto-opening
OtherUsersDraftsModal both surprised users on every editor mount. Move
both signals into the AutosaveIndicator label: "Loaded from draft" or
"Others are working on this {kind}" (priority) sits where Saving/Saved
do, with a one-shot light-green flash behind the indicator that fades
to transparent. Saving/Saved still win when they fire. The popover
gains a "See others' drafts" button that flips the modal open on
demand; the modal itself is now externally controlled via a bindable
\`isOpen\` threaded through DraftEditorModals.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui(drafts): per-user View JSON / Fork actions in DraftBadge popover

Hover popover used to be a plain text list of usernames. Now each row
gets a colored circle icon + name + "(you)" for the authed user, and
every OTHER user's row carries View JSON / Fork buttons mirroring the
OtherUsersDraftsModal. For draft-only entries owned solely by the
authed user, the popover ends with "Only you can see this {kind}" so
the row's privacy is obvious. ScriptRow / FlowRow / AppRow thread
workspace + itemKind + path + editPathFor through; AppRow switches
between app / raw_app on app.raw_app.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* nit

* fix(drafts): clone only the forker's per-user drafts on workspace fork

clone_drafts copied every user's drafts, but only the forker gets added
to the fork's usr table. Drafts owned by absent users LEFT-JOIN to NULL
in the home page's draft_users aggregate, surfacing as multiple
legacy-style rows at one path and crashing the popover with
each_key_duplicate. Filter the clone to email = forker OR email IS NULL,
and key the popover's #each by index defensively so future legacy
collisions can't crash the page either.

Also re-adds `draft_only: None` to NewScript/CreateFlowBody literals in
tests — the auto-generated windmill-api-client still carries the field
and the previous commit dropped them too aggressively.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): always populate other_drafts_users in maybe_overlay_draft

Reset-to-deployed reloads the deployed payload with get_draft=false,
which made the backend return other_drafts_users=[]. The route then
reassigned otherDraftsUsers to the empty list, dropping the count to
0 and hiding "See others' drafts" in the AutosaveIndicator popover —
but the other users' drafts hadn't actually gone anywhere. Fetch the
list independently of get_draft so the popover stays accurate across
reset reloads.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(drafts): alert user when their draft is older than the latest deploy

Open a modal on editor mount when the per-user draft was saved before
the latest deploy at the same path — i.e. a teammate deployed a new
version while this user's draft was sitting. Two choices: discard the
stale draft and pick up the deploy, or keep editing the older draft.
DraftEditorModals computes the staleness from the timestamps each route
threads in (script.created_at, flow.edited_at, app_version.created_at)
and the "Load latest deploy" callback reuses the route's existing
reset-to-deployed logic. Wired for script / flow / app / raw_app
editors; trigger / resource / variable drawer editors follow a
different pattern and aren't covered here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): deploy only wipes the deployer's draft, not everyone else's

Script / flow / app deploys ran an unconditional DELETE on every draft
at the path, so a teammate's deploy silently destroyed any other
user's pending draft. After the wipe, the other user's tab kept
auto-saving — re-creating the row at a NOW timestamp newer than the
deploy — and StaleDraftModal never fired because draft_saved_at had
been bumped past the deploy. Filter the DELETE to email = deployer
(plus the legacy NULL row), so other users' drafts persist and the
stale-draft prompt actually fires on their next reload.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): surface save failures in AutosaveIndicator instead of pretending Saved

postSave caught network errors with `console.error` and let the runner
finish normally. The indicator read the saving → none transition as a
successful save and flashed "Saved" even when the request had thrown.
Track failed keys in a SvelteMap, expose `'failed'` as a new
UserDraftSyncState, render "Save failed" in red with a CloudOff icon.
Failure clears on the next successful save for the same key, or when
recordRemoteSync seeds a fresh authoritative timestamp.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): surface 'Save failed' inside the AutosaveIndicator popover too

The popover used to repeat the cheerful "All changes are saved as a
draft on the server..." copy even when the inline label said
"Save failed", which read as contradictory. Add a red, text-xs warning
at the top of the popover body when the sync state is `failed`,
explaining that the latest edits didn't reach the server and that
editing again retries the save.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): surface the actual error message in the AutosaveIndicator popover

Replace the generic "your latest changes did not reach the server" copy
with the real failure detail. The syncer now stores the extracted
message in the failures map (formatSaveError walks body / message /
statusText) and exposes it via the state handle's `failureMessage`
getter. Popover renders it in red, monospaced, scrollable so a long
server traceback doesn't blow out the popover.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard

A `value: null` POST is a discard, not a save, but it ran through the
same runner the indicator watched — so resetting to deployed flashed
"Saving..." → "Saved", reading as "your draft just landed" while we
were actually wiping it. Track in-flight discards in a SvelteSet,
expose a distinct `'discarding'` UserDraftSyncState, and the indicator
stays quiet for it: no spinner, no label change, and the
`discarding → none` transition deliberately skips the "Saved" flash.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Revert "fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard"

This reverts commit 625a47c5d2.

* fix(drafts): flush pending autosaves when the editor hook unmounts

SPA navigation doesn't fire `pagehide`, so a debounced edit (up to
maxDebounceMs old) silently disappeared when the editor was unmounted
mid-typing. `UserDraft.useMany`'s onDestroy now walks every acquired
entry and fires `UserDraftDbSyncer.flush(query)` before releasing,
re-submitting the pending opts with `immediate: true`. The POST rides
the runner's own lifetime and survives the component teardown.

`use` / `useReactive` are thin wrappers around `useMany` so they
inherit the flush automatically. Editors that don't go through the
hook (sessions' `ScriptEditorView`, `AppJsonEditor`, copilot adapter,
DraftBadge fork action) only call `UserDraftDbSyncer.save` for
one-shot operations and don't need lifecycle flush.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* nit

* feat(ui): Modal2 fixedHeight='adaptive' sizes the modal to its content

The fixed-height steps force either wasted whitespace or clipped
content for small dialogs. `adaptive` emits no height rule (still
capped by max-h-screen-80) so the modal hugs its content. Use it in
StaleDraftModal, which only has two lines of copy and a button row.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(drafts): 'Create test drafts' button on the home page

Dev/QA helper that seeds one per-user draft for every supported kind
(script, flow, app, raw_app, trigger_schedule, resource, variable) at
fixed u/{me}/draft_<kind> paths, so the draft surfaces (home badges,
editors, stale-draft modal, others' drafts modal) can be exercised
without hand-creating items. Re-clicking overwrites the same paths.
Value shapes mirror what each editor's autosave writes, matching the
backend list synthesizers that parse them back.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): dedupe app list rows when a path holds both app and raw_app drafts

The apps list LEFT JOINed draft with typ IN ('app', 'raw_app') for the
is_draft flag — a path holding BOTH kinds for the same user (easy to
hit: open a raw-app draft path in the regular app editor and its
autosave writes the second kind) fanned the row out into two identical
entries and crashed the home list with each_key_duplicate. Join a
DISTINCT (path, workspace_id) subquery instead. Same dedup for the
draft-only synthesis block via DISTINCT ON (path) keeping the most
recently saved kind.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(drafts): asterisk on resource/variable/schedule/trigger rows with own draft

Add an is_draft flag to ListableVariable / ListableResource /
ScheduleLight / BaseTrigger list rows — a scalar EXISTS subquery on the
draft table for the authed email (no join, so no row fan-out), plus
is_draft: true on the synthesized draft-only rows. The list pages
(variables, resources, schedules, all trigger kinds) append `*` to the
displayed name when set, mirroring the home page's convention.

Also fixes draft-only resources never appearing on the resources page:
the page always lists with resource_type_exclude=cache,state,app_theme
(its tab split) and the synthesis gate bailed on any type filter. The
gate now keeps synthesizing and applies resource_type /
resource_type_exclude per-row against the draft JSON instead.

list_triggers (trait default) takes an authed_email: Option<&str> —
Some from the list endpoint, None from workspace export.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Revert "feat(drafts): 'Create test drafts' button on the home page"

This reverts commit 1f244a2a8b.

* fix(drafts): P1 hardening — save authz, secret scrubbing, hot-path index

1. save_draft had no authorization check (a regression from the old
   create_draft's require_writer_of_path): any workspace member could
   plant drafts in another user's u/ namespace or unwritable folders,
   and those drafts get surfaced to every reader of the path (home
   circles, others'-drafts modal, View JSON / Fork). New
   require_can_write_path: admins; own u/ namespace; g/ namespace when
   in the group; f/ folders with the write/owner bit (with the same
   folder-claim refresh deploy endpoints use). Operators are rejected
   outright — they're excluded from every other draft surface.

2. Secret variable values were persisted in the draft table in
   plaintext. save_draft now blanks variable.value for is_secret drafts
   at write time (the editor never round-trips secret values anyway —
   it fetches with decrypt_secret=false), and a migration scrubs rows
   persisted before the guard.

3. fetch_other_drafts_users runs on every get-by-path request with
   (workspace_id, path, typ) and no email predicate — neither partial
   unique index covers it, so it seq-scanned a table that accumulates
   per-user autosaves across all workspaces. Add a plain btree index;
   it also serves get_draft_for_user's IS NOT DISTINCT FROM lookup.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): Ctrl/Cmd+S flush narrates via the indicator, not a toast

The "Draft saved" toast fired even with the network down — flush never
rejects (postSave catches errors internally and routes them to the
failures map), so the success branch always ran. Drop the toasts from
the script / flow / raw-app Ctrl+S handlers; the AutosaveIndicator
already narrates the flush truthfully (Saving... → Saved / Save failed
in red).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): Ctrl/Cmd+S always flashes Saved in the indicator

After dropping the toast, an explicit Ctrl/Cmd+S with nothing pending
(the common case — autosave already landed everything) gave zero
feedback: flush() no-ops when pendingSaveOpts is empty and no state
transition fires. flush() now bumps a reactive per-key counter on
completion (no-op path included), exposed as flushCount on the state
handle; the AutosaveIndicator flashes "Saved" on the bump when the
pipeline is idle. Real flushes keep narrating through Saving... →
Saved / Save failed as before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui(drafts): Ctrl/Cmd+S replays the green backdrop flash on the indicator

Decouple the one-shot light-green → transparent backdrop from the load
hint label: triggerFlash() owns the keyed span (mounted only while the
animation runs), and both the on-mount hints and the Ctrl/Cmd+S
confirmation route through it. The flush bump fires after the POST
lands, so a real flush flashes too — not just the no-op path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(drafts): 'Create test drafts' button on the home page

Dev/QA helper that seeds one per-user draft for every supported kind
(script, flow, app, raw_app, trigger_schedule, resource, variable) at
fixed u/{me}/draft_<kind> paths, so the draft surfaces (home badges,
editors, stale-draft modal, others' drafts modal) can be exercised
without hand-creating items. Re-clicking overwrites the same paths.
Value shapes mirror what each editor's autosave writes, matching the
backend list synthesizers that parse them back.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): Ctrl/Cmd+S reaches the raw-app flush from every editor surface

The raw-app window keydown handler never fired in practice: the file
editor is a VS Code workbench in a same-origin iframe (keydowns don't
cross documents) and the inline-script / YAML Monacos swallow Ctrl+S
via addCommand. Two hooks:
- attach a capture-phase keydown listener inside the iframe document on
  each load (no preventDefault — VS Code's own save still runs, we
  flush the pending autosave alongside it);
- Editor.svelte / SimpleEditor.svelte re-broadcast their swallowed
  Ctrl+S as a `wm-monaco-save-shortcut` window event, which
  RawAppEditor listens for.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): editing a draft-only item opens create mode prefilled from the draft

Variable / resource / schedule / trigger editors treated every loaded
path as deployed and routed saves through the update endpoints, which
404 for draft-only items ("Resource not found at name ..."). The
get-by-path responses already mark the case (`no_deployed` from
fetch_draft_only) — editors now flip to create mode when it's set:
- VariableEditor / ResourceEditor: existedInitially = !no_deployed
- ScheduleEditorInner + all 10 trigger editor inners: loadTrigger /
  loadSchedule return { overlay, noDeployed } and openEdit sets
  edit = !noDeployed
The form opens prefilled from the draft and deploys via create, whose
endpoints already delete the creator's draft on success.

(The "Could not load schedule: Not Found" half of the report was a
stale dev backend — getSchedule?get_draft=true verified working on the
current build.)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): leading-edge draft saves for raw apps (no double debounce)

Raw-app file changes reach the parent already coalesced — the UI
Builder iframe holds a ~1s trailing debounce on its rebuild and only
posts setFiles when it fires. The syncer then stacked its own 1.5s
trailing window on top, so the draft landed ~2.5s after the user
stopped typing. The debouncer now supports a leading edge (run
immediately when the key is idle and cooled down; later schedules in
the window coalesce trailing with the max-wait ceiling, mirroring the
classic editor's first-keystroke-materializes-immediately logic), and
raw_app saves opt into it. The app build keeps its own trailing
debounce inside the iframe — only draft persistence is affected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui(drafts): blue flash for load hints, green for save confirmations

The backdrop flash now carries meaning: green = "your save landed"
(Ctrl/Cmd+S), blue = informational on-mount hints ("Loaded from
draft", "Others are working on this ..."). Color is passed as an
inline CSS custom property the keyframe reads, so the single keyframe
serves both variants.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Revert "fix(drafts): leading-edge draft saves for raw apps (no double debounce)"

This reverts commit 1b996fd73a.

* feat(drafts): 'Enable auto-save' toggle in the AutosaveIndicator popover

Browser-wide preference (default on, persisted in localStorage). While
off, the reactive keystroke mirror never POSTs — saves marked
`auto: true` park their latest opts in pendingSaveOpts instead of
scheduling, and the unload keepalive flush is skipped, so nothing
leaves the tab except explicit actions: Ctrl/Cmd+S flush (sends the
parked latest content), discard / reset-to-deployed, fork, conflict
overwrite. The indicator shows a muted cloud-off while disabled (the
idle check-mark would otherwise read as "everything saved") and the
popover copy explains the Ctrl/Cmd+S-only behavior. Re-enabling
re-schedules every parked unsaved draft so edits made while off catch
up immediately.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Revert "feat(drafts): 'Create test drafts' button on the home page"

This reverts commit fd7013b399.

* feat(drafts): Review & Deploy covers variables/resources/schedules/triggers

The drafts review page only assembled scripts/flows/apps from three
paginated list endpoints, so drafts of every other kind were invisible.
New GET /w/{ws}/drafts/list returns every draft of the authed user in
one query over the draft table, with a per-kind draft_only flag
(deployed-table EXISTS per kind); getDraftItems switches to it, which
also drops the 3×N-page fan-out.

CompareDrafts renders the new kinds (icon via a UserDraftItemKind →
layout-Kind mapping, gray kind badge, list-page edit links for
drawer-based editors), diffs them through a generic overlay GET, and
deploys them by replaying the editor save: create/update for variables
and resources, saveScheduleFromCfg for schedules, the per-kind
save*TriggerFromCfg helpers for the ten standalone trigger kinds.

Also fixes two paths stale since the draft_only column removal:
draft-only flows/apps now deploy via create (update 404s — there is no
row anymore), and discard always deletes the draft row (the old
delete-the-item branch 404'd for the same reason).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(drafts): optimistic asterisk while editing in list-page drawers

The `*` suffix on variable/resource/schedule/trigger rows came from the
server's is_draft flag, which only updates on a refetch — editing an
item in the drawer didn't mark its row until much later. New
localDraftHints module (SvelteSet-backed): editors publish their dirty
state (the same condition that shows the "You have unsaved changes"
banner) and the 13 list pages OR the hint into the asterisk condition,
so the suffix appears the moment the form diverges and clears on
discard/teardown. Wired once in useTriggerDraftSync (covers the
schedule editor and all ten trigger editors) plus VariableEditor and
ResourceEditor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ee repo

* fix(drafts): draft hints persist past editor teardown, re-sync on reopen

Clearing the optimistic asterisk on drawer close was wrong: the
divergence the editor observed is autosaved server-side, so the draft
outlives the drawer and the asterisk should too. Hints are now
corrected rather than expired — while an editor is settled on an item
it publishes the observed truth in both directions (divergence sets,
sitting at the deployed baseline clears), so a draft discarded from
another tab loses its stale asterisk the next time the item is opened.
No teardown cleanup anywhere.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(drafts): list-page asterisk mirrors the editor's banner, not stale is_draft

The asterisk was `is_draft || hint` — an OR can turn the asterisk on
optimistically but can never turn it OFF, so after discarding a draft
(or editing back to the deployed value) the stale server flag kept the
asterisk until the next list refetch.

Make the local hint a tri-state override instead: the editor publishes
the live banner state (true/false) into a SvelteMap, and the list pages
read `getLocalDraftHint(...) ?? is_draft` — the editor's observed truth
wins over the stale server flag in both directions.

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

* fix(drafts): autosaves equal to the deployed value delete the draft instead

When the user edits back to exactly the deployed value, the reactive
autosave mirror used to persist a baseline-equal copy — a useless draft
row that kept `is_draft` (and the list asterisk) on after refetch.

Add a `discardIfEqualTo` baseline getter to `UserDraft.useMany` specs:
when the cell's value deep-equals the deployed baseline, the mirror
POSTs `value: null` (delete) instead of the value. The variable and
resource editors pass their `initialStates` baseline, guarded on
`existedInitially` — draft-only/new items have no deployed copy, so
equality must never delete their only data.

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

* Draft encryption for secret variables

* fix(drafts): discardIf predicate + deploys clear the asterisk and draft row

Two follow-ups on the baseline-equal-autosave-deletes change:

1. `discardIfEqualTo` (baseline getter + raw deepEqual) becomes
   `discardIf` (predicate). Raw deepEqual reported spurious diffs after
   a refresh: drafts round-trip through JSON, which strips
   undefined-valued keys, so a restored draft (`{}`) never compared
   equal to the freshly built baseline (`{ labels: undefined }`) and
   the delete never fired. The editors now pass the SAME comparison
   that drives their "unsaved changes" banner — a new exported
   `draftValuesEqual` (JSON-normalized deep equality) used by both —
   so the banner and the synced draft can never disagree.

2. Truly saving (deploying) clears the asterisk and the draft row:
   - variable/resource editors: replace post-deploy `UserDraft.remove`
     (blanks the cell to `undefined`, which reads as dirty and keeps
     the banner + asterisk on) with `discard` to the just-saved state,
     and refresh `initialStates`/`existedInitially` so the editor
     settles clean.
   - trigger editors: `useTriggerDraftSync.discard` publishes the hint
     off explicitly — after a deploy the editor's `deployed()` baseline
     is stale, so the hint effect alone would keep the asterisk on.
   - Review & Deploy page: `deployDraft`/`discardDraft` clear the hint.

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

* revert encryption just for the resources part

* fix(drafts): required const DRAFT_KIND on TriggerCrud; deploy/delete cover raw_app

The TriggerCrud::user_draft_item_kind() default matched on TRIGGER_TYPE
and panic!'d on any unmapped string — a runtime crash on the first draft
save for a trigger that forgot to map. Replace it with a required
associated const DRAFT_KIND, so a missing mapping is a compile error.
user_draft_item_kind() now just returns Self::DRAFT_KIND; every impl
(OSS + EE) declares the const.

Also fix the app deploy/delete draft cleanup to cover raw_app: raw apps
deploy and delete through the same internal path, but the cleanup
filtered typ = 'app' only, leaving raw_app drafts dangling
(create_app_internal apps.rs:1465, update path apps.rs:2077) or
un-archived on delete (apps.rs:1687).

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

* fix(drafts): deleting an item wipes every user's draft, not just the caller's

Scripts/flows/apps already wiped all users' drafts on delete, but
resources/variables/schedules/triggers called delete_user_draft
(caller-scoped), so a teammate's draft on the just-deleted item lived on
forever — surfacing through fetch_other_drafts_users with no item left
to deploy onto. Add delete_all_drafts_for_path (all emails + the legacy
NULL row) and use it in every delete handler; keep delete_user_draft for
the discard-my-own-draft flow where the item lives on.

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

* perf(drafts): skip other-drafts query on non-editor reads (get_draft=false)

maybe_overlay_draft ran fetch_other_drafts_users (a usr join) on every
get-by-path, including worker/CLI reads of MB-scale flows & apps that
pass get_draft=false and never render the draft overlay or "others
editing" surfaces. Gate the query behind get_draft — only editor reads
pay for it. Reset-to-deployed editor reloads still get it (they pass
get_draft=true).

(Eliminating the serde_json::to_value materialization of the deployed
payload needs WithDraftOverlay to become generic over T, which is folded
into the get-by-path choreography refactor.)

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

* refactor(drafts): single-source the kind→table mapping via deployed_table()

The kind→table dispatch lived in three places that could drift: the
TriggerCrud string-match (already replaced by const DRAFT_KIND), the
table_for_kind access-check map, and a hand-written draft_only CASE in
list_drafts.

Add UserDraftItemKind::deployed_table() as the single source (plus an
ALL enumerator). table_for_kind now delegates to it, and the list_drafts
draft_only CASE is generated from it at runtime (table names come from
the closed enum, never user input — no injection). Drift between the
access check and the existence check is now impossible by construction.

Webhook and the native triggers (poll/cli/nextcloud/google/github) map
to None: they have no path-keyed backing table and aren't draftable, so
they report draft_only=true and use a path-only access check. This also
fixes a latent bug where table_for_kind mapped native kinds to
native_trigger, which has no `path` column — the access query
`SELECT 1 FROM native_trigger WHERE path = $1` would have errored.

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

* ee repo

* fix(drafts): close variable draft-secret laundering oracle (sentinel + rehydrate)

save_draft encrypts secret variable values with the workspace key, but
the ciphertext was round-tripped to the client and the deploy endpoints
decrypted whatever $encrypted: ciphertext the client submitted
(variables.rs create/update). Any workspace member who can write a
variable path could take an arbitrary workspace-key ciphertext (another
user's secret draft via GET /drafts/get with only path-read, or a
deployed secret's stored value) and submit it as their own secret
variable's value — the server decrypted it and, since they own the path,
they read the plaintext back. That bypasses the audited decrypt_secret
permission.

Fix: the ciphertext never leaves the server. get_variable swaps a draft
secret's $encrypted: value for an opaque $draft_secret sentinel (both the
draft overlay and the draft-only inner stand-in). On deploy the client
sends the sentinel back and the server rehydrates the plaintext from the
caller's OWN draft row — the only ciphertext it ever decrypts is one it
encrypted for this exact (workspace, path, email). A raw $encrypted:
submitted by a client is now rejected outright.

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

* fix(drafts): don't clobber a secret draft when autosaving the $draft_secret sentinel

After reload the client holds the $draft_secret sentinel for a secret
variable (never the ciphertext). Editing some OTHER field (description,
labels) triggers an autosave carrying value="$draft_secret" — and
save_draft's encrypt_secret_variable_value, seeing a non-empty,
non-$encrypted: string, encrypted the literal sentinel, overwriting the
real ciphertext in the draft row and losing the secret.

Treat the sentinel as "secret unchanged": restore the $encrypted:
ciphertext already stored in this user's draft row instead of encrypting
the placeholder (falling back to empty only if there's no prior
ciphertext). The new lookup reuses the same query shape as the deploy-
time rehydrate, so no new offline cache entry.

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

* Revert "$draft_secret" sentinel approach for variable draft secrets

Reverts 339c259fce and b2c38ef407. Instead of round-tripping a sentinel
and rehydrating server-side, we close the laundering vector more simply
by disabling cross-user draft visibility for triggers/resources/variables
(next commit) — an attacker can no longer read another user's secret
draft ciphertext to launder it.

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

* feat(drafts): keep drafts private to their owner for resource/variable/trigger kinds

Replaces the reverted $draft_secret sentinel: instead of laundering-proofing
the ciphertext round-trip, simply don't expose other users' drafts for the
drawer kinds (resource/variable/triggers). A viewer can no longer obtain
another user's secret-variable draft ciphertext, so it can't be laundered
into plaintext via deploy.

UserDraftItemKind::shares_drafts_across_users() — true only for
script/flow/app/raw_app. maybe_overlay_draft skips other_drafts_users for
non-sharing kinds, and get_draft_for_user (View JSON / Fork) returns 404
for them. Own-draft load/save is unchanged.

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

* refactor(drafts): make the list-page asterisk hint a shadow of UserDraftDbSyncer

The optimistic `*` hint was written by three open-editor publishers, so
draft deletions that didn't go through an editor (banner discard,
autosave-back-to-baseline, Review & Deploy) left a stale asterisk that a
server refetch couldn't clear (the hint overrides is_draft).

Move ownership to the syncer — the one choke point where a draft's
existence actually changes:
- postSave sets the hint on a saved write (value !== null) and clears it
  on a delete (null), so every syncer-routed delete clears it for free.
- save() lights it optimistically when a real save is scheduled, so the
  asterisk still tracks the editor's banner without the debounce lag.

The editors no longer SET the hint; they only CLEAR it when settled at
the deployed baseline (so a draft discarded from another tab disappears
on reopen). discardDraft drops its explicit clear (postSave covers it);
deployDraft keeps one (it deletes server-side, bypassing the syncer).

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

* chore(migrations): fold draft index + secret scrub into the base sync migration

Merge 20260610095349_draft_workspace_path_typ_index and
20260610100018_scrub_secret_variable_drafts into the base
20260528143710_draft_user_sync_schema migration (the index creation +
secret-draft scrub in .up, the index drop in .down; the scrub stays
irreversible). 20260609165313_remove_draft_only remains standalone.

Verified the full chain applies and reverts cleanly on a fresh DB.
(Rewrites an already-applied migration — existing dev DBs need a reset.)

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

* refactor(drafts): promote the get-by-path draft choreography to one helper

The "Some(deployed) → overlay / None+get_draft → draft-only / None → 404"
dance was copy-pasted across the get-by-path handlers and had drifted
(different 404 text, the trigger one missing the draft-only fallback at
first). Promote it to windmill_common::overlay_or_draft_only<T>, which
takes the deployed entity as Option<T> and a per-route not_found closure.

Converts scripts, flows, apps, schedules, and triggers onto it. Resources
keeps its own (it runs an async explain_resource_perm_error on the 404
path) and variables keeps its own (secret-decrypt logic interleaved with
the draft fetch) — both genuinely diverge from the common shape.

(The serde_json::to_value elimination via a generic WithDraftOverlay<T>,
and the list-only draft synthesis dedup, remain as follow-ups.)

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

* perf(drafts): serialize the deployed overlay payload in one pass

maybe_overlay_draft materialized the deployed entity into a
serde_json::Value tree (serde_json::to_value) and then serialized that
tree again into the response — two passes plus a full Value allocation
over what can be an MB-scale flow or app, on every get-by-path
(including get_draft=false worker/CLI reads).

Hold WithDraftOverlay.inner as a boxed erased_serde::Serialize trait
object instead, so the deployed payload flattens straight into the
response in one pass. The struct stays non-generic, so the helper and
all seven handler return types are unchanged; only the deployed type now
needs Send + 'static (already true — they're owned rows; added 'static
to TriggerCrud::Trigger to say so).

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

* refactor(drafts): one helper for the draft-only list synthesis query

The "draft rows at paths with no deployed counterpart" query was
copy-pasted into the variable / resource / schedule / trigger list
handlers, each hardcoding its own typ literal and NOT EXISTS table — a
drift hazard. Promote it to windmill_common::fetch_draft_only_list_rows,
which derives the absence-check table from kind.deployed_table() (the
same single source as the access check and draft_only flag). Each
handler keeps its own include_draft_only gating and per-type row mapping
(genuinely entity-specific); only the shared SQL is deduped. The trigger
handler's prior generated-SQL version is folded in too.

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

* fix(drafts): route raw-app draft deploys through the raw-app endpoint [P1]

deployDraft's raw-app guard was `kind === 'app' && rawApp`, but Review &
Deploy passes `kind === 'raw_app'` (raw apps are their own DRAFT_KIND),
so the guard never fired and the row fell into the visual-app branch.
There `d.value` is undefined (a RawAppDraft has files/runnables/data, no
`value`), so AppService.updateApp did a partial update — resetting policy
to the publisher default, never bundling/deploying the files — the
backend then deleted the user's raw_app draft rows, and the UI reported
"deployed". The work-in-progress was destroyed without ever deploying.

Route `kind === 'raw_app'` (or the editor's `app` + rawApp) through
deployRawAppDraft. The now-unreachable `raw_app` arm of the visual-app
branch is dropped.

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

* fix(drafts): allow draft saves for item-level extra_perms writers [P1]

require_can_write_path only accepted namespace rules (own u/, member g/,
writable f/), dropping the item-level extra_perms check the old
create_draft had. A user granted write on e.g. u/alice/script via the
Share dialog could still deploy it (the update endpoints go through RLS)
but could no longer save a draft — and because the editors autosave
continuously with no permission gate, editing a shared item produced a
persistent "Save failed: you don't have write permission" and Ctrl/Cmd+S
failures.

Add the item-level fallback: when a deployed row exists at the path,
check its extra_perms for a write grant (every deployed table has
extra_perms; the table comes from the closed deployed_table() mapping).
Draft-only items have no row and stay governed by the namespace rules.

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

* fix(drafts): pass rawApp on get-app for never-deployed raw-app drafts [P2]

A raw app that has only ever been drafted has no `app` row, so get_app
resolves the draft kind from the `rawApp` query param. getDraftDiffValues
("Show diff") and deployRawAppDraft both fetched with getDraft=true but
without rawApp, so the backend looked up the visual-app draft kind, found
nothing, and 404'd. Pass rawApp so the raw_app draft is found.

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

* feat(drafts): surface the localStorage→DB migration with toasts

migrateUserDraftsToDb already uploaded legacy "userdraft/..." entries and
cleared them on success (and runs after the v1→userdraft normalizer).
Add the user-facing surface: when real legacy entries are detected, show
an info toast "Migrating local storage drafts ..."; on a per-draft
failure show an error toast "Could not migrate draft <path> in workspace
<X>" with a "Delete draft" action that drops the stuck localStorage entry
(otherwise it retries every mount). Unparseable junk is still cleared
silently up front, so the toast only fires for genuine drafts.

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

* test(drafts): cover the autosave pipeline's pure-logic utilities [P2]

The deleted draft tests left the new debouncer + coalescing runner — the
core of the autosave pipeline — with zero coverage. Add vitest suites
(16 cases) for debouncerByKey (debounce window, latest-task-wins,
maxDebounceMs ceiling under a trickle, fresh-chain-after-fire, cancel,
key independence) and coalescingRunner (immediate run when idle, coalesce
burst to in-flight + latest, displaced-task drop, submitAndWait
resolve/reject/displaced, cancel semantics, key independence).

Broader replacement (save_draft conflict semantics + the require_can_*
checks as backend integration tests) still outstanding.

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

* feat(drafts): add UserDraft.seed — a one-shot baseline load that never POSTs

The page editors bracket their new-draft / deployed-baseline loads with
stopSync + restartSync so the programmatic write isn't synced as the
user's edit. Forgetting restartSync silently disables autosave for the
session — the footgun behind the three divergent resume strategies the
review flagged.

`UserDraft.seed(kind, path, value)` is the scoped alternative: it sets
the cell (all reactive readers update) and arms a single-shot
`seedNextWrite` flag the sync effect consumes — adopting the value as the
new baseline and skipping exactly that one POST, with no suspension to
resume. Additive: stopSync/restartSync are untouched and still used for
the writes that fan out across editor components (initContent cascades).
Foundation for converting the editor bootstraps off the bracket.

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

* refactor(drafts): extract usePageDraftSync; convert the scripts editor onto it

First step of unifying the four page editors' hand-rolled draft
orchestration (three divergent handle-ownership models + an
easy-to-forget recordRemoteSync). usePageDraftSync is the single model —
the page analogue of useTriggerDraftSync — owning the re-keyed autosave
handle, the live-editor-draft registry entry, recordRemoteSync (now a
method, not a per-page ritual), seedBaseline (via UserDraft.seed), and
draft removal.

The scripts editor is converted as the reference adoption: its inline
useReactive handle, live-editor-draft effect, recordRemoteSync, and the
two UserDraft.remove calls now go through draftSync. The new-draft
stopSync bracket stays (it spans ScriptBuilder's initContent cascade).

Verified in a real browser against the dev stack: load fires no spurious
save, a code edit triggers exactly one save_draft POST + a draft row,
and the draft persists across reload. Flows / apps_raw / apps conversions
follow.

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

* refactor(drafts): convert the flows editor onto usePageDraftSync

Replace the inline useReactive handle + UserDraftDbSyncer.recordRemoteSync
+ UserDraft.remove with draftSync. effectivePath is omitted — flows
register their live-editor-draft entry through FlowBuilder
(liveEditorDraftStoragePath), so the composable doesn't double-register.
The new-draft stopSync + armRestartOnFirstInteraction bracket stays (it
spans FlowBuilder's seed cascade). flowStore reads/writes draftSync.draft.

Verified in a real browser: load fires no spurious save, a summary edit
triggers exactly one save_draft POST + a draft row, and the edit persists
across reload.

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

* refactor(drafts): convert the apps_raw editor onto usePageDraftSync

Replace the UserDraft.use handle + mirror, UserDraftDbSyncer.recordRemoteSync,
and UserDraft.remove with draftSync. `path` is a mount-scoped plain `let`
(the editor remounts per path), so the composable's useReactive re-keys
only on workspace change — equivalent to the prior capture-once use().
effectivePath omitted (RawAppEditor owns the live-editor-draft entry); the
new-draft stopSync + armRestartOnFirstInteraction bracket stays.

Type-checked and behavior-equivalent (handle mechanism unchanged; the
centralized recordRemoteSync/remove read the same `path`). Not
browser-exercised here — no existing raw app in the dev workspace and the
new-draft template-picker flow isn't scriptable quickly; scripts and flows
(same composable) were verified live.

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

* fix(drafts): remove app autosave at its canonical key after deploy/rename

AppEditor keys the app autosave on the URL draft path and passes it down
as userDraftPath, but AppEditorHeader's post-deploy cleanup re-derived
the key from the just-typed deploy path (createApp) / the live $appPath
(updateApp) instead. For a new app the autosave lives at
u/{user}/draft_{uuid} while the typed path is the user's chosen name, and
a rename leaves the autosave at the original key — so removing at
path/$appPath missed the real draft row and orphaned it. Use the
canonical userDraftPath AppEditor already provides.

This is the "children re-derive the UserDraft key" fragility from the
review, addressed without giving apps a page-level handle — apps
deliberately lets AppEditor own the handle so the entry is destroyed on
unmount (a page handle would keep it alive and reintroduce spurious
autosaves on every /edit visit).

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

* test(drafts): integration tests for save_draft conflict semantics + authz [P2]

Replaces the deleted drafts.rs (which targeted the removed /drafts/create
API) with tests for the new surface:
- save_draft upsert → stale-last_sync conflict (rejected, value unchanged)
  → force overwrite → delete, the optimistic-concurrency contract.
- require_can_write_path: own namespace allowed, another user's namespace
  rejected, operators rejected.
- the item-level extra_perms fallback — a user granted write on a deployed
  item can save a draft on it (regression test for the authz drop).
- cross-user draft privacy: GET /drafts/get is 404 for the drawer kinds
  (variable/resource/triggers), not blocked for script/flow/app.

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

* chore(sqlx): refresh offline cache after the main merge

The merge auto-combined both branches' additions inside the resource
get-by-path query_as! (our draft_only/is_draft columns + main's
folder_labels(...) inherited_labels), producing query text neither branch
had cached — so the offline build failed for it. Regenerate the entry
(rename to the new content hash) and refresh a re-described workspace
query. Feature-gated/EE entries the local prepare can't compile are left
as committed.

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

* ee repo ref

* chore(system_prompts): regenerate for draft_only/is_draft trigger schema fields

The openapi.yaml trigger/schedule schemas gained draft_only + is_draft,
but system_prompts/generate.py wasn't rerun, failing the freshness check.

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

* refactor(drafts): defer save_draft write authz to RLS via a FOR UPDATE probe

require_can_write_path re-implemented the item-level extra_perms write
rule in Rust (SELECT extra_perms + get_perm_in_extra_perms_for_authed) —
a third copy of rules whose canonical home is the RLS policies, and the
exact lane that regressed once already.

Replace it with an RLS write-probe: `SELECT 1 FROM {deployed_table}
WHERE path/workspace ... FOR UPDATE` through UserDB. Postgres applies
UPDATE policies to rows locked via FOR UPDATE, so a returned row means
the canonical policies (see_own / see_member / folder-write /
see_extra_perms_*_update / admin_policy) would let this user UPDATE the
row — no write rule re-implemented, no drift possible. The probe's row
lock is released by the immediate commit.

The claim-based namespace checks stay, evaluated FIRST: they read the
same JWT claims RLS does (so outcomes are identical), they spare the
autosave hot path a DB round-trip for the common own-namespace case, and
they are the entire check for draft-only paths — where no deployed row
exists, so there is structurally nothing for RLS to evaluate. The u/own
+ folder-owner part now goes through the shared
windmill_api_auth::require_owner_of_path instead of bespoke code.

Adds a read-only-grant test case (extra_perms value false): the row is
visible under the SELECT policy but FOR UPDATE filters it under the
UPDATE policy — pinning the semantics the probe relies on. All 4 draft
integration tests pass.

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

* chore: point ee-repo-ref at the EE branch merge (has DRAFT_KIND consts)

ee-repo-ref was set to main's EE commit (d45b9a6) while the EE branch
was unpushed; building OSS (which requires const DRAFT_KIND on
TriggerCrud) against that EE ref fails with E0046 on every EE trigger
impl. The EE branch head e936e9a — the merge of d45b9a6 into the EE
remove-workspace-drafts branch, carrying the DRAFT_KIND consts — is now
pushed; point at it.

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

* fix(drafts): ignore permissioned_as fields in the unsaved-changes comparison

The schedule cfg carries permissioned_as / preserve_permissioned_as —
run-as deploy directives, not user-edited draft content — and the editor
round-trips them asymmetrically (preserve_… is rebuilt as
!!cfg.permissioned_as on load but `|| undefined` on build), so the
banner comparison could report a phantom diff.

Extract the normalization into a shared normalizeDraftForCompare (JSON
round-trip + a DRAFT_COMPARE_IGNORED_FIELDS list with the two fields)
and use it from BOTH comparators: draftValuesEqual (variable/resource
banner + discardIf) and useTriggerDraftSync's cfgDiffers (schedule and
trigger banners, the persist-effect's at-baseline discard, restore) —
one ignore-list, no way for the two to disagree.

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

* nit

* fix(drafts): at-baseline discard is auto-gated and only fires with a draft

Two related fixes to useTriggerDraftSync's persist-effect:

1. The reactive at-baseline discard bypassed the "Enable auto-save"
   toggle: with autosave off, value saves were parked (correct) but the
   discard's value:null still POSTed — so the editor never wrote drafts
   yet kept reactively DELETING them, and the only network traffic was
   discards. Thread `auto` through UserDraft.discard to the syncer; the
   persist-effect passes auto:true (parked for Ctrl/Cmd+S when the
   toggle is off), explicit discards (banner button, post-deploy
   cleanup, reset-to-deployed) stay ungated.

2. The discard fired unconditionally whenever the form sat at the
   deployed baseline — including a spurious value:null POST on every
   drawer open. Guard on cfgDiffers(h.draft, deployed): undefined on a
   fresh open (nothing to discard) and equal to deployed right after a
   discard (no repeat per cfg recompute).

Verified live as a non-admin user on a schedule: toggle on → no POST on
open, edit → one value save, revert → one discard; toggle off → zero
POSTs (everything parked), banner still functional.

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

* feat(drafts): scope the "Enable auto-save" toggle to the page editors

Add a canBeDisabled opt (default false) to UserDraft.use / useReactive /
useMany specs, threaded through acquireEntry into the reactive mirror's
save opts. The syncer's auto-save gate (and the pagehide-flush skip) now
only applies to saves whose handle opted in: the four full-page editors
— script / flow / raw app via usePageDraftSync, app via AppEditor's
use() — which are exactly the surfaces whose AutosaveIndicator carries
the toggle.

Drawer editors (variables / resources / schedules / triggers) keep the
default and always sync regardless of the toggle — previously a
toggle flipped off in some browser silently disabled their autosave and
the optimistic asterisk (both sit behind the same gate) with no toggle
UI anywhere on those surfaces to explain it.

Verified live: schedule edit with the toggle off now POSTs the value
save (and the discard on revert); script editor with the toggle off
still parks everything for Ctrl/Cmd+S.

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

* fix(drafts): consume the import handoff stores in the new-draft bootstrap

The /add pages used to read importStore / importFlowStore /
importScriptStore / sessionStorage rawAppImport to seed the editor from
"Import from YAML/JSON", "Build app" (from a script/flow), and the
workflows-as-code import. Since /add became a pure redirect to
/{kind}/edit/u/{user}/draft_{uuid}?new_draft=true, the writers kept
firing but nothing consumed the payload — every import landed in an
empty editor.

Consume them (one-shot read + clear) in the four edit pages' new_draft
branches, layering the imported content over the empty template with
path kept '' so the friendly-name generation still runs:
- scripts: $importScriptStore spread over the empty script (non-empty
  content also keeps ScriptBuilder's template bootstrap from overwriting
  it — that cascade is gated on content == '').
- flows: $importFlowStore spread over the empty flow.
- apps: $importStore — wrapped exports ({summary, value, policy}) and
  bare App values, mirroring main's /add.
- raw apps: $importStore then sessionStorage rawAppImport (the full page
  reload for cross-origin isolation drops in-memory stores); honored
  only when the payload carries files (rendering gates on them),
  skipping the framework picker; otherwise the template seed.

Verified live: "Build app" from a script lands on /apps/edit with the
canvas seeded from the script instead of an empty editor.

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

* chore(drafts): remove dead delete_user_draft + its stale doc [C4]

The doc claimed item delete handlers call it, but those all moved to
delete_all_drafts_for_path (an item delete is for everyone); the
caller-scoped discard goes through the save_draft route with value:null.
That left delete_user_draft with zero callers (OSS and EE) — remove it
and its orphaned sqlx cache entry, and reword the contrast note on
delete_all_drafts_for_path.

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

* chore(migrations): retire the sync_drafts-era index comment + right-size it [C6]

The draft_user_sync_idx comment described the deleted sync_drafts
polling endpoint (editors polling created_at ranges every 2-10s) — that
design was replaced by recordRemoteSync + save_draft last_sync, and
nothing range-scans draft.created_at anymore. Since this migration only
exists on this branch, fix it before it ships: the index's real consumer
is GET /drafts/list (workspace_id + email equality, ORDER BY path), so
swap the vestigial trailing created_at for path (rows come back in
output order) and rename to draft_user_listing_idx. Chain re-verified
on a fresh DB. (Byte-for-byte migration edit — dev DBs that already
applied it need a reset, as with the earlier consolidation.)

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

* fix(drafts): discardDraft awaits the delete POST before refetching [I5]

UserDraftDbSyncer.save resolves at enqueue time for debounced saves, so
discardDraft's await finished ~1.5s before the value:null POST and the
invalidateWorkspaceDrafts refetch re-listed the just-discarded draft.
Use immediate: true (resolves after the POST lands), matching every
sibling delete-then-refetch path.

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

* fix(drafts): replace stale draft_only gates in the builders [I6]

draft_only was dropped from the get-by-path wire shape (the column is
gone; overlays carry no_deployed instead), so these four reads were
always undefined:

- ScriptBuilder "Exit & See details" gate and TriggersEditor's
  isDeployed treated every draft-only script as deployed → now keyed on
  savedScript.no_deployed like the sibling reads right next to them.
- FlowBuilder's deploy path never took the direct-save branch for
  draft-only flows (no deployed version exists to compare against), and
  "Exit & see details" was offered for draft-only flows (404 details
  page) → both now keyed on the newFlow prop (driven by no_deployed),
  which the rest of the file already uses.

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

* fix(drafts): Ctrl/Cmd+S flushes the draft in the low-code app editor [I7]

The app editor's keydown handler swallowed the shortcut with a bare
preventDefault() — every other page editor flushes the pending autosave
(UserDraftDbSyncer.flush) so the AutosaveIndicator narrates Saving... →
Saved and parked edits (autosave toggle off) actually persist. Wire the
same flush, skipped in the AI session pane where no UserDraft handle
exists.

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

* fix(drafts): AI tool strings no longer describe drafts as localStorage [C2]

The copilot tool results/messages still told the model drafts were
"saved to local storage" / "a browser-only local draft" — drafts are
per-user rows in the server-side draft table now. Misleading the model
about the storage medium produces wrong explanations to users (e.g.
"your draft will be lost if you clear your browser data"). Reword all
occurrences to "draft" / "per-user draft (saved server-side)".

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

* fix(openapi): drop stale draft_only request props, fix OtherDraftUser, regen deref [D4][C5]

- The create-script (NewScript), createFlow, createApp and createAppRaw
  request bodies still documented draft_only — the backend request
  structs no longer read it, so an older CLI sending draft_only: true is
  silently ignored and fully deploys. Remove the property from the spec
  so generated clients can't offer it. (Response-side draft_only on the
  Listable* rows stays — the list synthesis populates it.)
- UserDraftOverlay.other_drafts_users item schema declared email and a
  required draft_saved_at; OtherDraftUser serializes only username
  (nullable for the legacy row — emails never leave the server). Align
  the schema. [C5]
- Regenerate openapi-deref.yaml/.json (served at runtime via
  include_str!) — they still advertised getScriptByPathWithDraft and the
  deleted draft surface, and now carry the drafts/save_draft routes.

Frontend gen client regenerated; check:fast clean.

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

* fix(sessions): stop session pane from clobbering server-side raw-app drafts [P1]

loadRawApp seeded the session runtime from result.value (the deployed
payload), ignoring the .draft pocket returned by the get-by-path overlay.
The subsequent UserDraft.save then POSTed deployed content with no
last_sync recorded, silently overwriting the user's server draft.

Now the no-draft branch consumes result.draft when present (matching the
flow/script branches) and records draft_saved_at via recordRemoteSync so
later session saves are conflict-checked instead of treated as fresh.
Also corrects the header and aiDraft-branch comments that claimed the
overlay merges drafts into top-level fields — it never does.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(rust-client): pass new get_draft arg to variable_api::get_variable

getVariable gained a GetDraft query parameter (per-user draft overlay),
so the generated client fn takes a sixth argument. Verified with the
same generate+check pipeline CI runs (rust-client/dev.nu --check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* nit: Workspace fork mention

* fix(drafts): don't leak other_drafts_users on draft-only private kinds [P2]

fetch_draft_only built the other_drafts_users list unconditionally,
while the deployed-overlay path gates it on shares_drafts_across_users.
For the drawer kinds (resource/variable/triggers) drafts are private to
their owner, so a draft-only GET was the one route that still told a
viewer who else has a draft at the path. Apply the same kind gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(drafts): probe a single row in the RLS write-probe [P2]

The script table keeps one row per version at the same path, so the
FOR UPDATE probe locked the entire version history and serialized
against concurrent deploys. LIMIT 1 locks one row — any UPDATE-policy
visible row proves writability (same pattern as scripts.rs's
latest-version lock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): consume the /add?param= seeding intents in new_draft branches [D2]

The /add routes' redirect preserves query params, but the edit pages'
new_draft branches only consumed the YAML/JSON import stores — every
other intent the old /add pages handled landed in a blank editor:

- scripts: ?hub= and ?template= forks (with locked language and a
  `<source>_fork` path suggestion), ?wac=python|typescript (WAC editor
  template + language), ?lang=, ?initial_args= (URL form), and the
  base64-JSON #hash payload (run page "Fork", workspace_settings
  handler-template buttons; WAC detection restored for imports too)
- flows: ?hub= (preprocessor placeholder replacement + env-variables
  panel), ?template=/?template_id=, ?fork=true (fork_flow localStorage /
  window.opener handoff), #state, ?tutorial=
- apps: ?hub= (fromHub inputs panel), ?template=/?template_id=,
  ?tutorial=

The redirect itself also dropped the URL hash — SvelteKit forbids
url.hash in load, so it forwards window.location.hash (correct for all
hash producers: they arrive as full page loads via window.open /
target=_blank).

Seeding priority and toasts mirror main's /add pages. Verified live:
hub/template/wac/hash/fork intents for scripts and flows, hub for apps
(dev hub returns empty payloads, code path confirmed via toast +
inputs panel); no autosave POSTs fire during seeding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): LS→DB migration no longer clobbers fresher server drafts [P2]

The one-off localStorage migration POSTed every entry with force: true,
unconditionally overwriting whatever the user had since saved server-side
from another browser. It now passes the LS copy's lastWrittenAt as
last_sync (epoch 0 when absent), so the server's conflict rule arbitrates:
empty slot → insert; server draft fresher → conflict, LS copy dropped;
LS copy fresher → upload wins. Verified all three outcomes against the
live save_draft endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(raw_apps): drop banned $bindable(default) on template picker open [P2]

`open = $bindable(false)` on an optional prop is the AGENTS.md-banned
pattern (the default masks the undefined state). The only caller always
binds a boolean, so `open` is now a required prop with a plain
`$bindable()`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): fork others' drafts via the import handoff, not an eager save

The Fork actions (OtherUsersDraftsModal + DraftBadge popover) saved the
fetched draft server-side immediately and navigated to the fork path,
which surfaced three problems: a server draft existed before the user
edited anything, the Path widget treated the slot as an existing item
("Only the owner can change the path"), and the value's draft_path kept
the source path while the URL said X_owner_fork.

Forking now routes through the same one-shot import handoff as the
"Import from YAML/JSON" actions (new shared forkDraftToImport helper):
stash the value in the kind's import store, navigate to /add, and let
the new_draft branch seed a brand-new own item — nothing saved until the
first real edit, fresh renamable path, no source identity riding along.

The editPathFor/currentUserUsername plumbing that only served the old
flow is removed from both fork surfaces and their callers. The new_draft
branches also clear the previous path's draft-presence state
(otherDraftsUsers, loadedFromDraft, stale-draft timestamps) — the page
component is reused across same-route navigation, so forking from an
editor with collaborators used to carry the "Others are working on
this" hint onto the fresh draft.

Verified live: fork of a legacy draft seeds content+summary on a fresh
u/{user}/draft_{uuid} slot with zero save_draft requests and no
leftover collaborator hints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(drafts): replace deprecated Popover with meltComponents Popover

- Migrate from old Popover.svelte to meltComponents/Popover.svelte
- Convert to new trigger/content snippet pattern with openOnHover=true
- Maintain hover behavior with debounceDelay=100
- Add key to visibleUsers each block for Svelte 5 compliance

* feat(drafts): seed forked drafts with the source path in the forker's namespace

Forking u/admin/myflow as guest now seeds the Path widget with
u/guest/myflow instead of a random friendly name — everything after the
source path's first two segments is kept, so f/folder/my/flow becomes
u/guest/my/flow. The re-homed path travels from forkDraftToImport to the
new_draft branches as a ?seed_path= param (the redirect preserves query
params; plain ?path= would be eaten in transit by ScriptBuilder's legacy
collab-param cleanup, which deletes path/collab from the live
searchParams object).

The script editor also passes initialPathChosen for any seeded path —
MetadataGen fires onChange for a non-empty summary at mount, and the
summary→path auto-slug would otherwise overwrite the explicit seed
(hub/template forks and URL-hash payloads included).

Verified live: forking a draft on u/admin/hard_working_script seeds
path u/admin/hard_working_script (with the "path already used" warning),
keeps the drafted summary/content, and still fires no save_draft until
the first edit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): DiffDrawer "Restore deployed" actually discards the draft [P1]

All four restoreDeployed implementations POSTed the delete through the
debounced pipeline and reloaded with getDraft defaulting to true: the
reload's draft write re-entered the autosave mirror (the one-shot seed
guard was consumed on first load), and debouncerByKey displaced the
queued value:null with the new save — the delete never reached the
server and the editor re-rendered the draft it was told to discard.

They now funnel through runResetToDeployed (the stopSync-bracketed
delete the AutosaveIndicator reset already uses) with each page's
proven reset body (getDraft: false reload), so the suspension mutes the
mirror while the delete flushes and sync re-arms on first interaction.

Also fixes the raw-app drawer navigating to the visual app editor
(/apps/edit) instead of /apps_raw/edit [P2].

Verified live on the script editor: Restore deployed issues exactly one
save_draft ({value:null} answered status=saved), the server row is
gone, and the editor re-renders the deployed content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): deploying a draft-only item reliably deletes its draft

Two bugs left the slot draft (u/{user}/draft_{uuid}) alive after a
successful deploy:

- RawAppEditorHeader.createApp removed the draft at the just-typed
  deploy path instead of the URL slot key (the visual header documents
  exactly this trap), orphaning the real row for every draft-only
  raw-app deploy.
- Everywhere else the delete went through bare UserDraft.remove, which
  only QUEUES the value:null in the per-key debouncer. Editors that stay
  mounted through the post-deploy navigation (AppEditor, RawAppEditor —
  and timing-dependently the script/flow builders' post-deploy
  draft_triggers mirror) keep mirroring their working value, and one
  such write displaces the queued delete with a fresh save — observed
  live: deploying a new visual app re-saved the full grid value at the
  slot right after deploy.

New discardDraftAfterDeploy helper (userDraftToast.ts) applies the same
bracket runResetToDeployed uses: stopSync to mute the mirror, remove +
immediate flush so the displacement window closes, re-arm on first
interaction. Wired into the script/flow pages' onDeploy and both app
headers' create/update paths (session-pane guards preserved).

Verified live for all three kinds: draft-only deploy issues the
value:null (status saved), the slot row is gone, and no post-deploy
save re-creates it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): forks-compare deploy clears drawer-kind drafts too

The script/flow/app deploy endpoints delete the deployer's draft
server-side, but the drawer kinds' (variable / resource / schedule /
triggers) create/update endpoints never touch the draft table — their
editors discard client-side after a save. deployDraft replayed the save
but not the discard, so "Deploy n drafts" on /forks/compare deployed
those kinds correctly and left the drafts listed forever.

deployDraft now issues the canonical value:null delete (immediate) for
the drawer kinds after a successful save. Verified live: deploying a
draft-only variable from /forks/compare creates the variable and the
draft row is gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): StaleDraftModal "Load latest deploy" actually discards the stale draft [P2]

The modal invoked onLoadLatestDeploy directly — the draft = undefined
write queued the delete and the reload's deployed-payload write
displaced it, overwriting the stale draft with a deployed-identical
copy (is_draft stuck on, asterisk persists, modal can't re-fire since
draft_saved_at moved past the deploy). All four pages now run the
callback through runResetToDeployed, same as the DiffDrawer restore.

Verified live: stale-draft scenario → Load latest deploy → exactly one
value:null POST, draft row gone, editor renders the newer deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): don't acquire a sync entry for empty-path specs [P2]

The read-only historical-hash view (/scripts/edit/x?hash=...) computes
draftPath '' but useMany still acquired a live entry at ws/script/ —
every edit mirror-POSTed to /drafts/save_draft/script/ (unroutable),
populating the failures map and pinning the AutosaveIndicator on "Save
failed" with a retry per debounce window. Empty-path specs now get a
detached local-only handle: bind: works, nothing syncs — which is what
usePageDraftSync's doc always claimed. Verified live: editing in the
hash view fires zero save_draft requests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): no spurious conflict after bfcache restore of a flushed page [P2]

flushOnPageHide advances the server rows with unreadable keepalive
POSTs and leaves lastSyncMap stale — correct when the document dies,
wrong when bfcache resurrects it: the next autosave carried the
pre-flush last_sync and the server rejected the user's own write as a
conflict, opening DraftSyncConflictModal. The flushed keys are now
remembered and dropped from lastSyncMap on pageshow with
event.persisted, so the first post-restore save takes first-push
semantics against this document's own flush.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ui(drafts): draft asterisk sits on the trigger row's main title

The draft hint rendered at the end of the secondary path line
(u/admin/item*) on the http/websocket/nats/kafka/email trigger lists —
easy to miss. It now renders at the end of the row's bold title, and on
the azure/gcp lists it moves from mid-title (after the path, before the
topic suffix) to the end of the line. mqtt/postgres/sqs/schedules
already had it on the title. Verified visually on the HTTP routes list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): trigger editors save the FIRST edit, not the second

Three interlocking fixes in the trigger autosave path:

- The entry's one-shot first-write seed guard (skipNextWrite) was never
  consumed for trigger entries: the drawers don't write the cell on open
  (the form holds the state, unlike variables/resources which pass a
  defaultValue), so the guard stayed armed and silently swallowed the
  user's FIRST edit — banner on, no asterisk, no save until a second
  change. maybeRestore now seeds the cell with the post-load baseline
  (server draft overlay if any, deployed otherwise) via UserDraft.seed,
  consuming the guard without POSTing.

- Guard hygiene in the cell's sync effect: a programmatic write consumes
  BOTH one-shot guards, and a no-op write (same serialization — e.g. the
  trigger pages fire openEdit twice per row click, re-seeding the same
  value) defuses a lingering seedNextWrite instead of leaving it armed
  to eat the next real edit.

- The at-baseline auto-discard is now deferred + revalidated (600ms):
  with the cell seeded, the double-openEdit churn transiently shows
  form-at-deployed + cell-holds-draft and an immediate discard deleted
  the server draft on open; the recheck skips the transient state while
  a genuine user revert still discards.

Verified live on the HTTP route editor: open-with-draft restores the
draft with zero POSTs, the very first field edit saves, and reverting
the form to the deployed value deletes the server draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ui(drafts): underscore-separated uuids in draft slot paths

u/{user}/draft_{uuid} now uses underscores instead of dashes in the
uuid — path segments elsewhere in Windmill are [a-zA-Z0-9_] words and
downstream consumers treat '-' as a foreign character. Nothing parses
the uuid back, so existing dashed slots stay valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): cascade draft cleanup on bulk-delete and rename

Drafts have no SQL FK to their underlying items (only to password.email),
so deletion and rename must cascade programmatically. Two gaps remained:

- Bulk delete of variables/resources did not wipe per-user drafts at the
  deleted paths (single delete already did via delete_all_drafts_for_path).
  Cascade them — including the linked resource/variable rows the bulk
  delete fans into — so no orphaned draft-only rows survive.

- Renaming a variable/resource/trigger left the per-user draft stranded at
  the old path. Add delete_own_draft_for_path and clear the deployer's own
  (+ legacy NULL) draft at the old path on rename, mirroring the
  script/flow/app rename path; teammates keep theirs (StaleDraftModal).
  Variable/resource renames also move the linked counterpart, so both
  kinds' drafts at the old path are cleared. Schedules have no rename path.

Note: sqlx offline cache not yet regenerated for the new/changed queries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): surface legacy NULL-email drafts and migrate pathless /add keys

Legacy workspace-scoped drafts (pre-per-user rows + the remove_draft_only
migration, all email IS NULL) stopped showing up because every per-user
lookup matched only email = self. Match (email = self OR email IS NULL)
everywhere a draft is surfaced or opened, with the owned row taking
precedence (DISTINCT ON / ORDER BY email NULLS LAST): the home drafts
list, the script/flow/app/drawer draft-only list syntheses, and the
get-by-path overlay/fallback.

The localStorage->DB migration also dropped pathless legacy keys
(userdraft/w/{ws}/{kind}/ with no path) — the new-item /add autosave —
because parseKey rejected an empty path, leaving them stranded in LS.
Mint a fresh u/{user}/draft_{uuid} slot for those (same convention as the
editors' /add redirects) so they migrate as regular draft-only items.

Note: sqlx offline cache not yet regenerated for the changed macros.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(sqlx): regenerate offline cache for draft cascade + legacy-draft queries

Adds the offline entries for the queries changed in the two preceding draft
fixes (delete_own_draft_for_path, the maybe_overlay_draft/fetch_draft_only
NULL-email fallback, and the script/flow/app draft-only syntheses).

Also forwards the `http_trigger` feature from windmill-api-openapi to
windmill-store: that crate imports `try_get_resource_from_db_as`
unconditionally, but the fn is cfg-gated behind a trigger feature, so the
openapi targets failed to compile in isolation (e.g. `--all-targets` under
resolver 2) — which blocked `cargo sqlx prepare`. The feature was already
present transitively in whole-workspace builds; this just makes it explicit
where the symbol is used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): resolve own draft owner in the admins workspace

The draft-owner surfaces (home-page badge, "others' drafts", View JSON /
Fork) resolve a draft's email to a username via the `usr` table. The
`admins` workspace has no `usr` rows — there a user's "username" IS their
email — so the join missed every owner and returned NULL, which the badge
renders as "Legacy workspace draft". A user editing a deployed item in
`admins` thus saw their OWN draft plus the genuine legacy NULL-email row
both labelled "Legacy workspace draft" (the reported duplicate).

Add the identity fallback `COALESCE(u.username, CASE WHEN workspace_id =
'admins' THEN email END)` to the script/flow/app draft_users aggregations
and fetch_other_drafts_users, and accept username==email in
get_draft_for_user. The genuine legacy row keeps username NULL (its email
is NULL, so the CASE yields NULL too), so it alone reads "Legacy
workspace draft" while the user's own draft now reads "<email> (you)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(drafts): keep "See others' drafts" after reset-to-deployed

other_drafts_users is only computed by the backend when getDraft is true
(the cross-user lookup is skipped otherwise). Reset-to-deployed reloads
with getDraft:false, so the editors were overwriting the known list with
the empty response — hiding the "See others' drafts" button until a full
page reload recomputed it. Discarding one's own draft is independent of
other users' drafts, which are untouched on the backend.

Only assign otherDraftsUsers on a getDraft:true load. Applied to the
script, flow, app and raw-app editors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* disable fork for operators

* Path reactivity issue

* docs(drafts): tighten draft-feature comments and drop dead code

The draft feature accumulated many multi-paragraph comments that risked
code-comment drift. Compact them to the AGENTS.md bar (constraints not
narration, state-once, no drafting-history), de-duplicating the repeated
draft_users / cascade / draft_only-synthesis rationale to one canonical
version per theme with terse cross-references elsewhere (~1300 fewer lines).

Also fixes three stale/contradictory comments surfaced while trimming:
- the operator authz note claimed operators are "excluded from every draft
  surface", contradicting require_can_read_path (they can read some drafts,
  never write) — reworded to match the code;
- a migration comment named a non-existent index (draft_user_sync_idx);
- a syncer comment documented the wrong map-key separator.

Removes notifyDraftLoaded (orphaned exported helper, no callers).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(drafts): rename save_draft route to /update for CRUD consistency

The draft write route was POST /drafts/save_draft/{kind}/{path}, which
stutters with the /drafts prefix and uses a non-house verb. Rename it to
POST /drafts/update/{kind}/{path} (operationId saveDraft -> updateDraft) to
match the codebase's CRUD convention (/list, /get/{path}, /update/{path}).
/list and /get/{kind}/{path} already matched and are unchanged.

Updates the handler, openapi spec + dereferenced bundles, the two
DraftService callers, the hand-built keepalive page-unload URL (it bypasses
the generated client, so it wouldn't be caught by regeneration), and the
integration tests. Response status values ("saved"/"conflict") are
unchanged, so there is no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 10:23:16 +02:00
Ruben Fiszel fddabe9c5c feat: clear conflict error + force delete when reusing a fork workspace id (#9499)
* feat: clear conflict error + force delete when reusing a fork workspace id

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

* fix: guard fork force-delete against double submit

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:55:03 +00:00
Ruben Fiszel 92c21bbe65 fix: drop archived items from fork compare (spurious 'not visible' warning) (#9481)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 22:45:18 +00:00
Ruben Fiszel 6156e2372a fix: gate native integration pickers behind non-operator check (#9465)
Operators are read-only and cannot create native triggers, yet the
google/github/nextcloud integration picker routes had no authorization
gate, letting any workspace member drive the admin-configured
integration's upstream API and enumerate its data (Drive files, repo
names, calendars, events).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 08:18:14 +00:00
Ruben Fiszel fbdf81ba5f fix: authenticate slack callback payload with per-workspace hmac (#9461)
* fix: authenticate slack callback payload with per-workspace hmac

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

* test: regression tests for unauthenticated slack callback decryption

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

* fix: verify slack submission signature before resume + close workspace oracle

Addresses review: verify private_metadata HMAC before handle_resume_action so a
tampered/unsigned submission is rejected up front, and map get_workspace_key
failure to the generic 401 so the status code is not a workspace-existence oracle.

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

* fix: domain-separate slack payload hmac from resume-secret hmac

Both MAC families key Hmac<Sha256> on the same per-workspace key; resume secrets
are distributed to approvers in resume URLs, so add a fixed domain tag
(slack_payload_v1) to the slack payload MAC to make the two non-interchangeable
by construction rather than by byte-layout coincidence.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 08:02:20 +00:00
Ruben Fiszel e1e7af6a25 fix: prevent token label collision bypassing job read access control (#9462)
* fix: prevent token label collision bypassing job read access control

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

* test: regression tests for token label collision job read access

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

* fix: bind job-read override fast-path to permissioned_as_email

Replaces the reserved-label / label-* exclusion approach: webhook-/http-/email-
labels are created through the public token API by the trigger panels, so they
cannot be reserved, and blocking label-* regressed legitimate re-reads. Instead
the username_override fast-path now requires the job's permissioned_as_email
(non-forgeable, never derived from the label) to equal the caller's email.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 07:47:52 +00:00
centdix cf5fefb521 feat: add metadata generation model setting (#9418) 2026-06-03 10:33:16 +00:00
Ruben Fiszel 0ba128afe7 fix(security): scope variable and resource value caches by caller identity (#9427)
The variable and resource value caches (backing
`GET /api/w/{w}/variables/get_value/{path}?allow_cache=true` and
`.../resources/get_value_interpolated/{path}?allow_cache=true`) are consulted
before the per-folder RLS query and store the already-decrypted value. The
resource cache was keyed only by `workspace:path` with no caller identity, so a
cache entry warmed by a privileged peer using `allow_cache=true` could be
returned to a caller with no access to the resource's folder on a cache hit
within the 30s TTL — leaking another folder's decrypted secrets.

Scope both caches to the caller's full authorization identity. The key is now
`auth_identity(authed):workspace:path`, where `auth_identity` is a SHA-256 of the
caller's effective authorization context (email, username, is_admin, is_operator,
sorted groups, sorted folders, sorted scopes) — mirroring
`job_read_access_cache_key`. Email alone is insufficient: the same email can
resolve to different effective permissions via job/owner-scoped tokens, so a
lower-privilege context must not reuse a higher-privilege context's entry.

Job-context resource interpolation is handled correctly: only `$WM_*` contextual
variables are resolved (and only when a `job_id` is present). The interpolation
reports whether the value contains a `$WM_*` placeholder
(`transform_json_value_tracked` + an `AtomicBool`). A value containing one is
job-dependent — even on a no-job read where it's left unresolved — and is never
cached (so a later job read never gets a stale placeholder or another job's
context). Any value without a `$WM_*` placeholder is job-independent and cached
under the identity key, shared across job contexts, so reads carrying a `job_id`
still hit the cache.

BEHAVIOR CHANGE: custom workspace environment variables are no longer interpolated
into resource values via `$NAME` (this was undocumented and prevented caching of
any `$`-prefixed value). Custom envs remain available to scripts/workers as before.
Built-in `$WM_*` contextual variables in resource values are unchanged.

The variable cache previously wrote with an identity-scoped key but read with the
unscoped key, so it never hit (a latent functional bug that happened to be safe).
Aligning the read path enables the cache and makes it identity-scoped by
construction. Secret variables are cached too, but the entry carries the
`is_secret` flag so a cache hit re-runs the per-read side effects a secret read
performs — the EE `variables.decrypt_secret` audit and running-job secret
registration (factored into `audit_decrypt_secret`, shared by both paths).

The unused `invalidate_{variable,resource}_cache` helpers can no longer target
identity-scoped entries; documented the constraint and refreshed the stale
key-format docs on the cache statics.

Tests:
- integration regression for both caches: a folder-scoped user warms the cache via
  allow_cache=true, then a user without folder access is denied (401) and never
  receives the cached value.
- integration regression that variables (secret included) are served from cache.
- integration regression for job context: plain and non-`$WM_` `$`-string resources
  stay cached and are served under a job_id, while a `$WM_*` resource (warmed without
  a job_id) is not cached.
- unit tests for `auth_identity`.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:31:45 +00:00
Ruben Fiszel 7edf3f0212 fix(auth): filter script/flow listings by token scope (GHSA-2ppx-66jv-wpw5) (#9426)
A token scoped to a single script or flow path (e.g.
`scripts:read:f/allowed/*`) could call `GET .../scripts/list_search` (or
`/list`) and receive `path` + full `content` for every script the
underlying user could see — likewise `flows/list_search` leaked the full
flow `value`. Route-level scope checks only validate `domain:action`, and
the listing handlers did no per-row scope filtering, leaking out-of-scope
source/definitions to narrowly-scoped tokens.

Apply `build_scope_path_predicate` (added in #9302 for resources/variables)
to `list_search_scripts`, `list_scripts`, `list_search_flows`, and
`list_flows`, mirroring the resources/variables fix exactly. Unscoped
tokens and tokens whose only scopes are `if_jobs:filter_tags:*` are
unaffected.

Adds integration regression tests (scripts + flows) covering: path-scoped
token sees only in-scope paths, broad `*:read` token still sees all
RLS-visible items, tag-filter-only and unscoped tokens unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 08:34:43 +00:00
Ruben Fiszel 04a08976ae fix: batch encryption-key rotation into one git-sync job (#9355)
* fix: trigger git sync for re-encrypted secrets on encryption key change

When changing a workspace encryption key, the secret variables get
re-encrypted with the new key, but the git sync was only dispatched for
the encryption_key.yaml metadata file. Repos with Secrets sync enabled
were left with stale ciphertexts until the next per-variable deployment.

Now, after the transaction commits, we also dispatch a Variable git sync
event for each re-encrypted secret so the new encrypted values are
pushed to the configured repos. Errors are logged but don't roll back
the key rotation.

Fixes WIN-1994
Fixes #9344

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

* feat: batch encryption-key rotation into one git-sync job

Workspace encryption key rotation now re-encrypts every secret variable
and then dispatches a single batched git-sync job carrying the Key event
plus one Variable item per re-encrypted secret. Repos with Secrets sync
enabled receive every new ciphertext in one commit instead of nothing
(previously only `encryption_key.yaml` was pushed) — and instead of N
separate jobs the debouncer might or might not merge.

Wires through the new `handle_deployment_metadata_batch` entry point
added in the companion EE PR; OSS has a no-op shim so the build stays
green.

Adds an integration test (`workspace_encryption_key_git_sync`) asserting
that rotating the key with 3 secret variables in scope produces exactly
one deployment-callback job whose `items` array contains the Key event
+ all 3 variable entries and `skip_secret=false`.

Fixes WIN-1994
Fixes #9344

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

* chore: bump ee-repo-ref for git-sync helper simplification

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

* test: cover non-debouncing git-sync fallback on key rotation

Adds a regression test exercising a workspace whose sync script predates
hub version 28103: the rotation must still queue a legacy-format
deployment-callback job per item (encryption_key + each re-encrypted
secret) instead of silently skipping the repo. Bumps ee-repo-ref to the
EE fallback fix.

Addresses the P1 raised in the PR review (Codex/Pi/Claude): batch path
dropped git sync entirely for repos without sync-job debouncing support.

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

* chore: add sqlx offline cache for encryption-key git-sync test queries

The cargo_test CI job builds with SQLX_OFFLINE=true; the two new
sqlx::query!/query_as! calls in
windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs
had no cached entries, failing the build with E0282. Regenerated and
added only the two new query caches (no EE/feature cache loss).

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

* chore: bump ee-repo-ref to updated EE companion PR (08e3b9b)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 05:12:35 +00:00
Ruben Fiszel 82722449e7 fix: fork compare visibility for non-admins and stale-token superadmins (#9283)
* fix: use fork-scoped authed for fork visibility in compare_workspaces

* test: add EE end-to-end repro for fork rename visibility

* chore: restore concurrency_locks sqlx cache lost in cleanup

* test: add regression for stale-superadmin-token fork visibility bug

* chore: update sqlx cache for new test queries
2026-05-22 08:03:59 +00:00