Commit Graph

226 Commits

Author SHA1 Message Date
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
Diego Imbert 7e1c1fa3a4 feat(apps): use the windmill-client SDK from raw app frontend code (#10377)
* feat(apps): use the windmill-client SDK from raw app frontend code

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

* fix(apps): bound the raw app SDK token to deployed runnables

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

* fix(apps): deny dependency jobs and survive a failed SDK mint

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

* fix(apps): confine the SDK token's users scope to the viewer's identity

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

* docs: describe the full raw-app SDK sentinel narrowing

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

* fix(apps): deny workflow-as-code replay for raw app SDK tokens

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

* fix(apps): deny preview-flow restart replay for raw app SDK tokens

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

* fix(apps): re-prompt when an app widens its SDK scopes mid-consent

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

* feat(apps): support the frontend SDK in sandboxed raw apps

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

* fix(apps): hand the sandboxed SDK token over only once per loaded document

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

* fix(apps): bind the sandboxed SDK handoff to the document we loaded

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

* fix(apps): use an unguessable nonce for the sandboxed SDK handoff

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

* fix(apps): reply to the sandboxed SDK handshake over its own port

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

* fix(apps): answer the raw app handshake only over a transferred port

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

* fix(apps): set frontend_sdk_scopes in the S3-gated policy literals

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

* docs: describe the sandboxed wrapper's credential as it now works

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

* ui nit

* feat(apps): make the frontend SDK work in the raw app editor preview

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

* fix(apps): guard the preview token mint and drop superseded responses

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

* fix(apps): refuse job tokens on every raw app SDK mint path

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

* docs: correct the mint caller list and the preview retry rationale

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

* fix(apps): apply the consent response's render mode before rendering

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

* fix(apps): restart the viewer when a redeploy changes the render mode

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

* fix(apps): restart on every render-mode change, not just the first

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

* fix(apps): clear the preview's SDK credential when scopes go away

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

* fix(apps): remove window.process in the preview instead of blanking it

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

* docs: cut the raw app SDK comments down to the invariant

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

* refactor(apps): use randomUUID for the raw app handshake nonce

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

* fix(apps): re-read the render mode before rendering without a token

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

* fix(apps): make the raw app handshake nonce unguessable again

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

* chore: pin the EE ref to a commit that builds against this OSS tree

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

* refactor(apps): authenticate the raw app preview by session instead of a token

The editor preview is same-origin and unsandboxed, so app code there already
holds the editing user's session cookie. Minting a scoped bearer for it added
an endpoint and a portable 12h credential without containing anything.

Inject only BASE_URL and WM_WORKSPACE: `windmill-client` falls back to
credentialed same-origin requests when it finds no token, so the SDK runs as
the editing user. Drops POST /apps/preview_sdk_token and the mint/race
handling in the editor.

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

* fix(sdk): send credentials only outside the browser

The API answers `Access-Control-Allow-Origin: *` and never sets
`allow_credentials`, so a credentialed cross-origin request fails before the
bearer is read — which is what a sandboxed raw app issues. Keying this on the
browser rather than on `WM_TOKEN` leaves non-browser callers byte-identical,
and browsers keep sending cookies same-origin through fetch's own default.

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

* remove windmill-client from templates

* fix(sdk): drop credentials only for raw app bundles

A sandboxed raw app calls the API from an opaque origin, and the API answers
`Access-Control-Allow-Origin: *`, which a credentialed request can never pair
with. Gate on WM_RAW_APP, set by the two places that build a raw app's
`window.process.env`, so every other windmill-client consumer is untouched.

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

* feat(apps): make frontend SDK access sandbox-only

An unsandboxed bundle runs same-origin with the viewer's full session, so a
consent prompt there implies a boundary that does not exist and the token adds
nothing it could not already do. Advertise scopes and mint only when isolation
is on; turning the toggle off clears the declared scopes with it.

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

* Revert "refactor(apps): authenticate the raw app preview by session instead of a token"

This reverts commit 81905e455b, restoring POST /apps/preview_sdk_token.

Session auth gave the preview the editing user's full permissions and worked
regardless of policy, so an app that would 403 for a viewer — or that declares
no scopes at all — ran fine in the preview and broke only once deployed. The
preview now takes the same credential as a deployed app, gated the same way:
sandbox off or no scopes means no env at all.

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

* docs(apps): state the sandbox-only SDK contract in the public schema

The Policy and EmbedTokenResponse descriptions still promised a token to any
raw app with non-empty scopes, and said raw apps skip tokens entirely. Point
authors at adding windmill-client themselves too, since the starter templates
no longer carry it.

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

* fix(apps): drop the preview token before minting its replacement

A mint is asynchronous, so clearing the env only on the empty-scope path left
the running preview — and any build fed meanwhile — holding scopes the policy
had just removed, or a token for the workspace just left, for as long as the
request took.

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

* fix(apps): restart the preview realm when its credential changes

Re-feeding the build resets the preview's DOM but keeps its JavaScript realm,
so the previous bundle's timers, listeners and pending callbacks went on using
the client they imported — and the token it captured at module load — after the
policy dropped it. Reload both shells instead; each replays the build on its
way back, so only the new realm survives.

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

* fix(apps): start the preview once per credential change

Restarting the realm made its shell replay the build immediately, so a delayed
mint ran the app once tokenless and again tokenful — mount-time side effects
twice per scope or workspace change. Hold the build back until the mint
settles: the shell comes back blank and whichever finishes last starts the app.

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

* fix(apps): wait for the detached preview shell before replaying

Its reload was only initiated, never awaited — unlike the inline iframe it had
no readiness flag — so a mint settling first posted the build to the retiring
document, which then ran alongside the replacement shell's own replay. Track
readiness from both paths that announce it: `load` for a freshly opened window,
`appPreviewReady` for a reload.

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

* chore: update ee-repo-ref to 99e143fa1e2e6c33b3525366a5afe48f7a4f020e

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

Previous ee-repo-ref: 609e197fbc08f1ce83dd86f816748cc19d213f77

New ee-repo-ref: 99e143fa1e2e6c33b3525366a5afe48f7a4f020e

Automated by sync-ee-ref workflow.

* nit better description

---------

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-03 19:31:57 +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 c69f08073a fix: add apps:run to the token scope picker and confine path-scoped app tokens (#10428)
* fix: expose apps:run in the token scope picker and let apps:write grant it

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

* fix: confine path-scoped app run/write tokens to the app they name

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

* fix: let apps:run read back its own app's S3 files, condense scope comments

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

* fix: let apps:write mint apps:run and extend run read-back to app S3 display routes

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

* docs: drop stale embed-token wording from the app S3 helper summary

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:38:05 +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
Guilhem 8a96e3a4ec fix: raw apps with no stylesheet were permanently un-deployable (#10364)
* fix: raw apps with no stylesheet were permanently un-deployable

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

* fix: keep js strict when defaulting the raw app bundle css

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

* test: drop ephemeral narration from raw app bundle regression test

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

* test: pin the extension each raw app bundle half is fetched under

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 22:32:57 +02:00
Ruben Fiszel 2bf7746cdd fix: operators cannot archive or delete flows and apps (#10322)
`create_flow`/`update_flow` and `create_app`/`update_app` reject operators, but
`archive_flow_by_path`, `delete_flow_by_path` and `delete_app` did not — so an
operator with folder write could delete a flow or app they were not allowed to
edit. Scripts already get this right (archive is guarded, delete is admin-only).

Verified on a live instance: all three returned 2xx for an operator before, 401
after, and a non-operator member with the same folder write is unaffected.
2026-07-26 11:36:33 +02:00
Diego Imbert 28a79ced15 feat: add explore button for object storage resources (#10306)
* feat: add explore button for object storage resources in resource list

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

* fix: make s3 drawer tooltip reflect explored resource

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

* fix: honor workspace prop in global s3 explorer and add resource connection error state

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

* fix: use picker's effective workspace in S3FilePreview requests

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

* fix: pass acting workspace to explore button in ResourcePicker

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

* chore: update ee-repo-ref to f78df23339e3136e8b6e9148a509508633448dd2

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

Previous ee-repo-ref: efb5e014fec34fc580b9dbb1b260494dd76c5462

New ee-repo-ref: f78df23339e3136e8b6e9148a509508633448dd2

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-07-24 18:41:09 +02:00
Ruben Fiszel c50a2abad0 fix(jobs): sanitize NUL in completed job result before jsonb insert (#10274)
## Summary

A job whose result contains a real NUL (U+0000) serializes to a `\u0000` JSON escape that the `jsonb`-typed `v2_job_completed.result` column rejects with Postgres `22P05` ("unsupported Unicode escape sequence"). This aborts the `INSERT` in `commit_completed_job`, which then retries 10 times and leaves the job unable to complete (surfaced as `Could not add completed job <id>: ... unsupported Unicode escape sequence`).

The fix sanitizes the serialized result immediately before the insert, with effectively zero overhead on the common NUL-free path.

## Changes

- **Promote `strip_json_nul` into `windmill-common`** (`utils.rs`): `fn strip_json_nul(&str) -> Cow<str>` — a `contains("\\u0000")` fast guard returns the input borrowed when clean; only a genuine odd-parity NUL escape triggers the O(n) rebuild. `Cow::Owned` is returned **only** when a NUL was actually stripped, so a legitimate `\\u0000` (escaped backslash + literal text) borrows through untouched. Replaces the two duplicated copies previously in `windmill-api/src/drafts.rs` (`strip_json_nul`) and `windmill-api/src/apps.rs` (`strip_null_chars`); both call sites now use the shared helper.
- **Add `serialized_json()` to the `ValidableJson` trait** (`windmill-queue/src/jobs.rs`): `Box<RawValue>` returns `Cow::Borrowed(self.get())` (zero-cost, already serialized); other impls serialize on demand via `to_raw_value`.
- **`commit_completed_job`** binds `strip_json_nul(result.serialized_json())` as `$3::text::jsonb` in both the `INSERT ... SELECT` and the `ON CONFLICT ... result = $3` (was `result as Json<&T>`). Stored data is unchanged (Postgres parses JSON text into `jsonb` identically); `wm_labels`/`result_metadata` still operate on the typed `T`.
- **Regenerated the sqlx offline cache** (one query file swapped; EE caches preserved).
- **Doc:** updated the stale `strip_null_chars` reference in `windmill-api-workspaces/src/workspaces.rs` to point at the shared `strip_json_nul`.

## Test plan

- [x] `cargo check -p windmill-queue -p windmill-api -p windmill-common -p windmill-api-workspaces` — clean, no warnings
- [x] `strip_json_nul` unit tests in `windmill-common` (clean-borrow, real-NUL, legit-escape borrow no-op, collision, nested keys/values, odd-run): 6 passed
- [x] End-to-end regression in `backend/tests/nativets_jobs.rs` (`--features deno_core`): a JS job returning a genuine NUL and a literal `\\u0000` completes, storing `"ab"` (stripped) and `"a\\u0000b"` (preserved). Without the fix the insert aborts and the job never completes.
- [x] `backend/tests/drafts_nul.rs` integration test still passes (helper refactor intact)
2026-07-23 10:39:38 +02:00
Ruben Fiszel d2c5d6f4b4 feat: make content search a full CE feature (#10252)
Content search (the `#` mode of the home-page Ctrl+K search, which
searches scripts/flows/apps/resources by content) was capped on CE to 10
scripts and 3 each of flows/apps/resources, with an "EE feature" warning
in the UI. It is now a full CE feature: the CE result caps are lifted to
match the previous EE limits (10000 scripts, 1000 each of the rest) and
the EE warning is removed.

Fixes WIN-2218

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:30:23 +02:00
Ruben Fiszel 4a898247a2 fix(apps): let entitled viewers read pre-existing S3 files from deployed apps (#10245)
* fix(apps): let entitled viewers read pre-existing S3 files from deployed apps

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

* fix(apps): confine S3 viewer fallback to full unscoped sessions

A scope-restricted token (e.g. apps:read:<app>, or an app-embed token) is
allowed on apps_u/* but rejected by the route-scope middleware on job_helpers/*,
so granting it the viewer fallback would be a new capability it cannot obtain
directly. Gate the fallback on scopes.is_none() so only full sessions (which can
already read via job_helpers) delegate; scoped and anonymous callers stay gated.
Add a scoped-token isolation assertion.

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

* fix(apps): treat filter-tags-only tokens as unscoped for S3 viewer fallback

The scopes.is_none() guard wrongly denied the viewer fallback to tokens that are
effectively unscoped (empty scope arrays and if_jobs:filter_tags:-only tokens),
which the route-scope middleware treats as unrestricted and which can therefore
read the same file via job_helpers directly. Reuse that semantics via a shared
is_effectively_unscoped helper so the relaxation covers exactly the tokens that
gain no new capability, while genuinely scoped tokens stay gated.

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-21 17:04:01 +00:00
Ruben Fiszel 8c725d9e44 fix(apps): honor presigned S3 signature on app display/preview routes (#10141)
The app provenance gate short-circuits on a valid presigned signature, but only the raw download_s3_file route parsed it. The parquet/csv/table-count/file-preview/metadata routes discarded sig/exp and always fell through to the provenance gate, so a presigned S3 object rendered as a table showed "File restricted" for any viewer who did not produce it. Thread sig/exp through every apps_u S3 display route and forward the presigned bearer from ParqetCsvTableRenderer/DisplayResult.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 21:18:15 +02:00
Ruben Fiszel f7eb5c460d fix(apps): invalidate cached app policy on change or deletion (GHSA-r5v4-cxh9-7qhq) (#10121)
* fix(apps): invalidate cached app policy on change or deletion (GHSA-r5v4-cxh9-7qhq)

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

* docs(agents): keep PR tests and comments minimal and non-ephemeral

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-15 17:41:54 +02:00
Ruben Fiszel 710a13a59d fix(apps): cover script/flow component outputs in deployed-app S3 provenance gate (#10070)
* fix(apps): cover script/flow component outputs in deployed-app S3 provenance gate

Deployed apps read S3 files on-behalf of the app author for logged-in viewers
(#10048). A confused-deputy guard confines those reads to files the app
"produced", but the recent-production check only matched inline `appscript`/
`preview` jobs nested under the app path. Files produced by the deployed
script/flow components an app is wired to run (e.g. a SQL query persisted to S3)
were therefore denied "File restricted" for every viewer, admins included.

Expand the provenance check to also match completed `script`/`flow`/`flowscript`/
`flownode` jobs whose `runnable_path` is one of the app's declared triggerables,
and accept the author identity via `permissioned_as = on_behalf_of` (not only
`created_by = caller`) so files produced on-behalf of the author are covered.
Reads outside the app's declared triggerables stay denied.

Adds a regression test seeding a script-kind produced file that reproduces the
"File restricted" denial before the fix and passes after.

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

* fix(apps): key S3 provenance on on-behalf identity + cover flow steps (review)

Addresses the CI review on the S3 provenance gate:

- P1 (confused deputy): the recent-production check keyed on `created_by =
  caller`, so a viewer who can run a declared script/flow directly (outside the
  app, with un-pinned inputs) could craft a result naming an author-only key and
  read it back through the app as the author. Key provenance instead on the
  producing job's `permissioned_as` matching the on-behalf identity the download
  reads as (the author in author-mode); a viewer's direct run has
  `permissioned_as = viewer` and no longer clears the gate. Drops `created_by`
  from both the appscript/preview and script/flow branches, closing the same
  latent hole in the pre-existing inline-script branch.

- P2 (dead flow-step branch): `flowscript`/`flownode` jobs have
  `runnable_path = <flow_path>/<step_id>`, which exact `= ANY(...)` never matched.
  Split script vs flow triggerable paths; flow kinds now match the flow's own job
  (bare path) and its step jobs via a `<flow_path>/%` prefix, bounded to declared
  flows.

- P2 (test realism): the regression test now uses the production
  component-prefixed triggerable key format (`<id>:script/...`), exercises a
  flow-step-produced key, and asserts a viewer's own direct run of a declared
  script stays denied (the P1 case).

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

* fix(apps): tie deployed-app S3 provenance to an app-origination marker (review)

Second CI-review round flagged that `permissioned_as` still does not prove a job
was app-launched: a runnable configured with its own `on_behalf_of` makes a direct
`/jobs/run` resolve `permissioned_as` to that identity (the app author), so a viewer
with run access could execute a declared runnable directly, craft an S3 result, and
read it back through the app. The flow-path `LIKE fp || '/%'` match also let `_`/`%`
in a declared path admit unrelated flows.

Introduce a real app-origination marker instead of inferring provenance:

- Add `JobTriggerKind::App`; `execute_component` stamps every app-launched job with
  `trigger_kind = 'app'` + `trigger = <app path>`. A direct `/jobs/run` cannot set
  this, so it is the authoritative signal that a file was produced *by the app*.
- The provenance gate's recent-production check collapses to
  `trigger_kind = 'app' AND trigger = <this app path>` (+ the 3h window and result
  containment). This drops the forgeable `created_by`/`permissioned_as`/
  `runnable_path`/kind logic entirely and removes the `LIKE` wildcard issue.
- Provenance is scoped to THIS app's path, so another app's jobs (even same author)
  do not authorize this app's reads.

Regression test rewritten to the marker model: an app-produced key clears for viewer
and admin; a direct run whose `permissioned_as` resolves to the author stays denied
(the forgery); another app's output stays denied. Adds `app` to the OpenAPI
JobTriggerKind enum.

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

* test(apps): assert execute_component stamps trigger_kind='app' at runtime

Adds an end-to-end test that runs a real script component through the app
runtime (`apps_u/execute_component`) and asserts the enqueued job carries the
app-origination marker `trigger_kind = 'app'` + `trigger = <app path>` (not the
runnable path). The provenance-gate tests seed the marker directly; this proves
the runtime actually produces the exact marker the gate depends on.

execute_component commits the job row and returns its id, so the assertion reads
the row directly — no worker needed to run the job.

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

* fix(triggers): reject trigger_kind=app for suspended-job reassignment (review)

`JobTriggerKind::App` (added for the app-origination S3 marker) became a valid
value for the resume/cancel suspended-trigger routes, whose handler derives the
table name `<kind>_trigger`. There is no `app_trigger` table, so both endpoints
would fail with a missing-relation database error (500). Reject `App` in
`get_suspended_trigger` alongside webhook/schedule so it returns a clean 400.

Adds a regression test asserting the reassignment route returns 400 (not 500) for
trigger_kind=app.

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

* fix(apps): don't stamp app-origination marker on preview runs (review)

The app-origination marker (trigger_kind='app') was stamped unconditionally,
including preview mode. A preview lets a `jobs:run` caller supply arbitrary
`raw_code` against ANY app path without that app's deployed policy (raw_code with
no path/id skips all app authorization), so a preview returning
`{"s3":"<author-only-key>"}` would forge the exact marker the S3 provenance gate
trusts and read the victim app author's file.

Gate the marker on `!is_preview`: only deployed, policy-checked executions are
app-provenanced. Preview/editor S3 display does not rely on this marker (the editor
routes reads through the force_viewer allowlist), so nothing legitimate regresses.

Adds a regression test asserting a preview run's job is not stamped trigger_kind='app'.

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

* fix(apps): editor-authorize preview marker + per-viewer S3 provenance isolation (review)

Closes the codex P1 (preview forgery) without breaking editor preview downloads,
and adds cross-viewer isolation to the provenance gate.

- Preview marker now requires app write: `execute_component` stamps the
  app-origination marker on a preview only when the caller can EDIT that app
  (`require_is_writer`), instead of never stamping previews. An app editor already
  wields the app's author identity (they can deploy a component that reads the same
  file), so marking their own preview is no escalation and keeps preview-produced
  S3 results downloadable in the editor; a `jobs:run`-only caller who cannot edit
  the app still cannot forge the marker. Deployed runs are unchanged (always
  marked).

- Per-viewer isolation: the provenance gate now also requires
  `j.created_by = <this caller>`. The security boundary stays the un-forgeable
  `trigger_kind='app'` marker; `created_by` is an additional filter ANDed under it,
  so it only narrows — a viewer can only download keys their OWN app runs produced,
  not another viewer's result. Restores the per-caller scoping #10048 had, now safe
  on top of the marker.

Tests: preview marked iff caller can edit the app; cross-viewer isolation (another
viewer's app-marked key denied, no admin bypass); direct-run and other-app keys
still denied; deployed run still stamped.

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

* fix(apps): require apps:write scope (not just writer ACL) to mark preview provenance (review)

require_is_writer checks the user's underlying ACL but ignores token scopes, so a
writer's token deliberately scoped to apps:run/apps:read/jobs:run but WITHOUT
apps:write could still mark a preview and forge provenance — even though that token
cannot deploy the app (update_app requires apps:write), breaking the "any marked
caller can deploy equivalent code" rationale.

Require BOTH apps:write:<path> scope (check_scopes) AND the writer ACL
(require_is_writer) before stamping a preview's app-origination marker. Deployed
runs unchanged.

Adds a scope-restricted-writer token to the test (apps:run/read + jobs:run, no
apps:write) and asserts its preview stays unmarked; retains the full-editor
positive case and the non-editor negative case.

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

* fix(apps): never app-provenance preview runs; read editor S3 as the caller (review)

Simplifies the preview handling: a preview executes as the *caller* (Viewer mode),
never as the author, so its results must be read back as the caller — never
author-mode — and must never carry the app-origination marker. This removes the
whole `require_is_writer` / `apps:write` / `can_preserve_on_behalf_of` reasoning
(which was also unsound: a writer's token or session may not be able to deploy a
component running as the app's on-behalf identity, so marking their preview could
still escalate).

- Backend: mark the app-origination marker for deployed runs only (`!is_preview`).
- Frontend: `getS3File` (AppImage/AppPdf/AppDownload) now routes editor/preview
  reads through the viewer-scoped `job_helpers/download_s3_file` endpoint (reads as
  the caller), matching what DisplayResult/ParqetCsvTableRenderer already do; only
  a deployed app view uses the provenance-gated `apps_u` endpoint. This is the path
  that previously relied on marking previews, so nothing regresses.

Test: a preview is never app-provenanced (owner's own preview and a non-editor's
both stay unmarked). Cross-viewer isolation, deployed marking, and the reassignment
guard are unchanged.

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

* fix(apps): app components run on-behalf of the app, not the referenced runnable (review)

Root-causes codex's on-behalf-preview finding: `execute_component` was overriding the
app's resolved on-behalf identity with the referenced script/flow's OWN
`on_behalf_of` (its `on_behalf_of_email`). That is wrong in the app context — the
app's execution mode should govern:

- A Viewer-mode app could execute a component AS the referenced runnable's on_behalf
  identity (privilege confusion / escalation), instead of as the viewer.
- A preview would run as that identity rather than as the caller, so its S3 output
  could not be read back as the caller — the download-identity mismatch codex flagged.

Always use the app-resolved identity (author in author-mode, caller in
viewer/preview); a referenced runnable's own `on_behalf_of` no longer leaks into app
execution. Direct `/jobs/run` still honors a runnable's `on_behalf_of` (unchanged).
With this, previews always run as the caller, so reading editor/preview S3 as the
caller (viewer-scoped `job_helpers`) is unconditionally correct.

- Test: the deployed-component e2e now seeds the script with a distinct on_behalf and
  asserts the component job's `permissioned_as` is the app identity, not the script's.
- Also reword the getS3File `configuration` param comment to describe current state
  only (AGENTS.md comment rule).

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

* chore(apps): surface 'app' trigger kind in Runs UI; condense provenance comments (review)

Addresses codex review nits:
- Add `app` to `jobTriggerKinds`, `triggerIconMap` (LayoutDashboard), and
  `triggerDisplayNamesMap` so app-component jobs (which now carry
  `trigger_kind = 'app'`) are filterable in Runs and render their trigger info.
- Condense the app-origination marker, on-behalf-identity, and provenance-gate
  comments to state each invariant once in <=4 lines at its relevant site
  (AGENTS.md comment rule).

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-14 10:29:48 +02:00
Ruben Fiszel 1e192f2d86 feat(apps): authorize deployed-app S3 reads on-behalf of the author for logged-in viewers (#10048)
* feat(apps): authorize deployed-app S3 reads on-behalf of the author for logged-in viewers

A logged-in user viewing a deployed app now reads S3 files (rich result,
table/image/PDF preview, CSV export, download, metadata) the same way an
anonymous viewer already does: on-behalf of the app author per the app policy's
execution_mode, gated by an app-provenance check — instead of against the
viewer's own S3 permissions. This aligns S3 with every other thing an app does
(scripts, flows, resources all already run on-behalf of the author) and lets an
operator who lacks folder S3 permission still see data rendered inside the app.

The raw job_helpers/* S3 API stays viewer-scoped: a viewer who lacks folder
permission is still denied there. Only which endpoint the app frontend uses for
logged-in deployed viewers changes.

Backend:
- Add app-scoped, provenance-gated apps_u/* variants for all S3 display ops
  (download_s3_file already existed; add download_s3_parquet_file_as_csv,
  load_file_metadata, load_file_preview, load_parquet_preview, load_csv_preview,
  load_table_count). Each routes through one shared helper
  (app_s3_on_behalf_and_provenance) that scope-confines an app embed token,
  resolves the on-behalf identity, and runs the provenance gate ONCE before
  dispatching to the EE *_internal S3 helpers.
- Close the confused-deputy hole in check_if_allowed_to_access_s3_file_from_app:
  the unconditional Ok() bypass for a logged-in, non-embed session now only
  applies in viewer execution mode (where the on-behalf identity IS the viewer,
  so the viewer's own permissions still bound the read downstream). Author-mode
  reads (anonymous/publisher) always enforce provenance, for anonymous and
  logged-in viewers alike, so a viewer cannot launder the author's S3
  permissions with an arbitrary file_key.

Frontend:
- Route the deployed-app view through apps_u/* using the app-viewer isEditor
  signal instead of login state (the old $userStore proxy wrongly sent
  logged-in deployed viewers to the viewer-scoped job_helpers API). Editor and
  preview keep viewer identity via job_helpers.

execution_mode: viewer remains the escape hatch for per-viewer S3 enforcement.

Fixes provenance-gated S3 display for logged-in operators on deployed apps.

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

* docs(backend): document cargo features, restarting the dev backend, and filesystem object storage

The dev backend runs `cargo watch --features quickjs` by default, which omits S3,
EE, MCP, and non-JS runtimes — feature-gated routes then 404 or return a
"requires <feature>" stub at runtime. Add a backend/CLAUDE.md section that:

- explains that you must restart the backend with the appropriate features to
  exercise gated functionality, with the pid/cwd-scoped restart recipe (never
  pkill target/debug/windmill) and the PORT=$BACKEND_PORT gotcha;
- documents what each commonly-toggled feature gate does (private, enterprise,
  license, parquet, duckdb, language runtimes, mcp, trigger kinds, no_auth) plus
  common combinations;
- documents using the built-in FilesystemStorage large-file storage for dev
  workspace object storage (hidden from the UI dropdown; set via
  edit_large_file_storage_config), including the advanced_permissions shape.

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

* fix(apps): don't flatten inner query in app-scoped S3 preview routes

axum's `Query` uses `serde_urlencoded`, which cannot deserialize the typed
(numeric/bool) fields of a `#[serde(flatten)]`-ed struct and 400s on `limit` /
`offset` ("invalid type: string, expected u32"). The app-scoped
load_csv_preview / load_parquet_preview / load_table_count routes flattened
LoadPreviewQuery / LoadCountQuery, so their previews were broken. Restate the
fields directly on the outer query structs (with an into_inner() to rebuild the
inner query) and extend the CE OSS stub to match.

Also bumps ee-repo-ref.txt for the companion EE csv-separator panic fix.

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

* fix: address CI review — nested DisplayResult routing, byte-range contract, docs, tests

- [P1] Thread `appPath` into the nested `DisplayResult`s (render_all children and
  the expanded-result drawer) so logged-in deployed viewers route nested/expanded
  S3 tables, images, PDFs, and downloads through `apps_u/*` too, not job_helpers.
- [P2] Mark `read_bytes_from`/`read_bytes_length` required on the
  `apps_u/load_file_preview` route (they are non-optional in LoadFilePreviewQuery),
  and mirror the full query shape in the CE OSS stub so the byte-range contract is
  enforced identically on CE and EE.
- [P2] Fix the backend retrigger command in backend/CLAUDE.md: cargo watch runs
  from `backend/`, so `touch README.md` (not `backend/README.md`).
- [P2] Trim app_s3_onbehalf.rs comments per AGENTS.md (state the invariant once,
  no drafting-history narration).
- Extend the integration test to cover the table-count, csv-preview (numeric
  limit/offset deserialization), and file-preview (byte-range required) routes.

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

* docs(apps): tighten S3 provenance-gate comments per AGENTS.md

Consolidate the viewer-mode / author-mode rationale to ≤4 lines at each branch
of the gate, and drop the repeated explanation from the shared
app_s3_on_behalf_and_provenance doc comment (which now just states what the
helper does). No behavior change.

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

* chore: update ee-repo-ref to f292a1040da6a667ce7c22abf63ec0debfdd480f

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

Previous ee-repo-ref: a582389084eb363997cb5e8053f29220e0d3eaec

New ee-repo-ref: f292a1040da6a667ce7c22abf63ec0debfdd480f

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-07-11 10:14:40 +02:00
Ruben Fiszel 5844c32ac5 fix: enforce read authorization when signing S3 objects (#10049)
`sign_s3_objects` minted a long-lived HMAC bearer signature for any S3 key
handed to it, by any authenticated workspace member, with no check that the
caller was allowed to read that key. Since `validate_s3_signature` only
verifies the HMAC and expiry at fetch time, any member (operators included)
could mint a transferable capability to read arbitrary S3 keys, bypassing the
advanced S3 permission rules (`check_lfs_object_path_permissions`).

Authorize the read at mint time: add an `ApiAuthed` extractor and, before
signing each key, require the caller's own `S3Permission::READ` via
`get_workspace_s3_resource_and_check_paths`. A caller can no longer sign a key
they cannot themselves read. The fetch-side validators are left unchanged.

The only legitimate caller is the wmill SDK invoked from an app-author job,
whose token authenticates as the executing (author) identity — which can read
the key — so authorized app display is unaffected.

Adds an integration test proving an authorized caller can sign a readable key
(and the signature validates end-to-end through the presigned fetch route)
while an unauthorized caller is refused.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 05:57:48 +02:00
Ruben Fiszel 3ec1f164be fix: strip NUL characters from app values at save time (#9903)
App values are persisted to a json column, which permits the JSON NUL
escape (backslash-u-0000), but are later converted to jsonb (e.g. a
workspace fork clone_apps, search indexing), which rejects it with
"unsupported Unicode escape sequence" -- silently making the app
un-forkable. The usual source is a binary file such as .DS_Store
accidentally bundled into a raw app file map.

A real NUL is unstorable in jsonb either way, and frontend code that
needs the character writes it as the source escape (which JSON-encodes
to an escaped backslash + literal u0000 and is left untouched), so rather
than hard-failing the save we strip genuine NULs and warn.

Add strip_null_chars and apply it at both app_version insert sites
(create_app_internal and update_app_internal, covering the regular and
raw create/update routes). It removes a genuine NUL escape (odd run of
backslashes before u0000) while preserving an even run. Returns a
borrowed Cow (no allocation) when the value is already clean. Covered by
unit tests.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 15:58:56 +02:00
Ruben Fiszel af01e90b5c feat(s3): replace CE 50MB upload cap with 10GiB workspace storage quota (#9874)
* fix(s3_proxy): enforce CE 50MB upload cap on multipart uploads

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

* feat(s3): replace CE 50MB upload cap with 10GiB workspace storage quota

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

* fix(s3): gate CE quota OSS stubs to not(enterprise) to match callers

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

* fix(s3): delta-aware CE storage quota + guard usage-load retry loop

Account for the overwritten object's size in the quota check so valid
same-size overwrites near quota are not rejected (Codex review), and stop the
storage-usage $effect from re-firing on persistent API errors (Pi review).

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

* fix(s3): count chunked PUTs; revert overreaching volume quota copy

Volumes write to workspace storage via a separate worker-side path with its own
50MB-per-file cap that this PR does not change, so revert the drawer copy that
claimed they count toward the 10GiB quota (Codex review). Bump ee-repo-ref for
the chunked-PUT accounting fix.

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

* fix(s3): add SQLx cache for CE usage-bump/quota queries; exclude volumes

Regenerate the missing offline SQLx cache for the not(enterprise) bump and
remaining-quota queries so the private CE offline build compiles, and bump
ee-repo-ref for the volumes/-prefix exclusion from the counted quota (Codex
review).

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

* fix(s3): always HEAD for CE upload delta so overwrites don't inflate usage

Bump ee-repo-ref for the fast-path overwrite-accounting fix (Codex review).

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

* fix(s3): reserve volumes/ prefix on CE write surfaces to close quota bypass

Reject direct writes to the reserved volume prefix on the app-upload surface and
add the OSS stub; bump ee-repo-ref (Codex review).

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

* fix(s3): refuse new multipart work when CE workspace is at quota

Bump ee-repo-ref for the multipart-initiate/part quota gate (Codex review).

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

* feat(s3): reserve in-flight multipart bytes against CE storage quota

Add workspace_multipart_inflight table + grants, SQLx cache for the reservation
queries, and bump ee-repo-ref. Bounds abandoned multipart uploads that the
list-based recount can't see (Codex review).

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

* fix(s3): clear multipart reservation only after a successful complete

Add exclude-upload arg to the OSS quota stub/caller and the SQLx cache for the
updated remaining-quota query; bump ee-repo-ref (Codex review).

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

* fix(s3): per-part multipart reservation; commit only on part success

Per-part workspace_multipart_inflight schema (upload_id, part_id) so retries
replace rather than double-count; SQLx cache for the reworked queries; bump
ee-repo-ref (Codex review).

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

* perf(s3): HEAD the multipart overwrite target once per upload, not per part

SQLx cache for the stored-credit lookup; bump ee-repo-ref.

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

* chore: update ee-repo-ref to bea5a8b5120d6d69cab1ad4611ebe463559bd200

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

Previous ee-repo-ref: 6e6ff86f1939cf74736b7d435bf6851416437523

New ee-repo-ref: bea5a8b5120d6d69cab1ad4611ebe463559bd200

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-07-03 00:57:19 +02:00
Ruben Fiszel 76a9523009 feat: use derived username instead of email for non-member superadmins (#9857)
* feat: use derived username instead of email for non-member superadmins

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

* fix: address review - drop redundant username cache, guard whoami membership by email

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

* refactor: use explicit non_member boolean instead of role string for superadmin banner

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

* fix: resolve email from password table for non-member superadmin permissioned_as

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

* fix: resolve non-member superadmin drafts via shared username->email resolver

Adds resolve_username_to_email (usr, then super_admin password fallback for both derived-username and email modes) and uses it in get_email_from_permissioned_as and the drafts get/list endpoints, so a non-member superadmin's drafts resolve and no email leaks into the drafts payload.

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

* test: superadmin-not-in-workspace schedule uses derived username as permissioned_as

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

* fix: resolve non-member superadmin identity in draft owner-circles, username_to_email, and home filter

Applies the password-fallback username resolution to the script/flow/app/draft owner-circle subqueries and the username_to_email endpoint (was an admins-workspace 'username == email' hack), and switches the home items-list user-folder filter to the non_member flag instead of the now-broken username-contains-@ heuristic.

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

* fix: backfill non-member superadmin favorites from email to derived username

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

* fix: propagate DB errors in username resolution instead of leaking email (CI review)

Addresses cubic-dev-ai P2: get_instance_username_or_fallback_to_email now returns Result and only falls back to the email for a genuine 'no derived username'; a query error propagates so callers fail closed rather than leaking the raw email as the acting username.

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

* fix: clarify non-member superadmin popover (username used + admin permissions)

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

* fix: keep username_to_email endpoint member-only to not disclose non-member superadmin email (CI review)

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

* fix: forbid disabling automate_username_creation once usernames assigned (CI review)

Makes the setting effectively one-way once instance-wide usernames exist, so the global-uniqueness invariant that keeps stored u/<username> identities (schedules/triggers/drafts/superadmin ownership) unambiguous can never be dropped back to workspace-local uniqueness. Re-saving false on an already-disabled instance stays a no-op.

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-01 14:54:07 +00:00
Ruben Fiszel 0dbd9c1231 perf: eliminate dual-connection DB pool contention across worker, queue, and api (#9798)
* perf: eliminate dual-connection DB pool contention across worker, queue, and api

Reuse the held transaction (or move pool reads before begin()) instead of
checking out a second pool connection while a tx is open, extending the
fix from #9789/#7861. Targets the per-worker pool (max 5) hot paths plus
several server-pool API handlers.

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

* fix: pass owned pool to get_email_from_permissioned_as in http trigger handler

The generified signature takes impl PgExecutor; the http trigger handler
passed &db where db is already &DB, yielding &&Pool which does not impl
PgExecutor (only surfaced under the full feature set in CI).

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

* fix: keep RLS-exposed reads on the non-RLS pool and isolate flow-eval reads in a savepoint

Addresses review of the dual-connection sweep:

- worker_flow: wrap the stop_after_all_iters_if reads in a SAVEPOINT. The
  caller swallows the error and keeps using tx, so a DB read failure must
  not leave the outer transaction aborted (it would fail the later commit).
  Matches the previous pool-read semantics.

- Revert reads that were moved onto an RLS (user_db) transaction back to the
  non-RLS pool, since RLS row-visibility/role context can change results:
  push_scheduled_job (email/tag/settings lookups; reachable with a user_db
  tx from api-schedule/api-flows), push_inner native-retry dedicated_worker
  routing (RLS isolation variants), resources.rs app-namespace folder
  auto-create (non-admins must not be blocked), and the script archive/delete
  UPDATEs. Non-RLS db.begin() reuse and move-before-begin are kept.

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

* test: failpoint proving the stop_after_all_iters_if savepoint isolates an aborted read

Adds a worker-crate failpoints feature and a data-driven hook: when the
stop_after_all_iters_if expr is the magic sentinel, the in-evaluation read runs
SELECT 1/0 to abort its (savepoint) transaction. The test asserts the flow still
completes (iteration marked failed) — which only holds if the savepoint keeps the
outer status-update transaction committable. Without the savepoint the abort would
poison the outer tx and the job would never complete.

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-25 21:33:17 +00:00
hugocasa 2879cbb65a feat(apps): opt-in sandbox isolation for published & raw apps (alpha) (#9420)
* feat(apps): sandbox published & raw apps with a scoped embed token

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

* chore: point ee-repo-ref at embed-token EE commit

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

* feat(apps): allow top-navigation from the sandboxed app iframe

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

* feat(apps): share app localStorage across apps via the embedder

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

* feat(apps): publisher disable-sandbox option with per-version viewer consent

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

* chore(sqlx): cache for disable-sandbox queries

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

* chore: bump ee-repo-ref to disable-sandbox EE commit

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

* fix(apps): always sandbox the served raw-app wrapper + viewer fixes

The raw-app wrapper served by get_raw_app_data now always carries
`CSP: sandbox`. The publisher "disable sandbox isolation" opt-out is applied
entirely on the viewer side, which (after per-version consent) builds its own
same-origin blob wrapper — so the backend-served document stays isolated
regardless of how it is reached, never via a relaxed real-origin URL.

Also:
- CORS on the global /apps_u mount so the opaque viewer can load custom-path
  public apps cross-origin.
- Reject runnable-bridge messages unconditionally until the iframe is bound.
- Relay the viewer's in-app hash up to the embedder address bar so deep links
  stay shareable (hash only; embedder keeps its own pathname).

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

* feat(apps): render public raw apps single-iframe (drop embed token)

Public raw apps now render directly on the real origin with a single
opaque bundle iframe and the page credential, instead of the opaque
viewer + scoped-token indirection. The author bundle stays isolated in
its own opaque iframe (CSP-sandboxed); low-code apps, whose code runs in
the viewer frame, keep the opaque viewer + scoped token.

embed_token now reports raw_app and skips minting a token for raw apps;
the access check still gates visibility.

Also set disable_sandbox: None in the remaining Policy constructors so
the full feature build (all_sqlx_features, enterprise, license) compiles.

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

* chore: bump ee-repo-ref to single-iframe raw-app EE commit

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

* feat(apps): grandfather existing apps as legacy-unsandboxed + authed-only consent

Existing apps are stamped by migration as `legacy_unsandboxed` so they keep
running same-origin on upgrade — no breakage and no consent prompt. New apps are
sandboxed by default; re-deploying an app clears the flag.

The publisher `disable_sandbox` consent prompt is now shown only to authenticated
viewers — an anonymous viewer has no session to expose, so the prompt was
meaningless friction.

embed_token reports `legacy_unsandboxed` and `authed`.

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

* chore: bump ee-repo-ref to legacy-unsandboxed EE commit

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

* feat(apps): deploy-time migration prompt for legacy-unsandboxed apps

On the first re-deploy of a grandfathered (legacy-unsandboxed) app, the
publisher must explicitly choose: enable sandbox isolation (the flag is
cleared → the app becomes sandboxed) or keep running without isolation
(→ disable_sandbox, with per-version viewer consent). updatePolicy() no
longer carries the legacy flag through a deploy, so the choice is what
sticks.

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

* fix(apps): disable the sandbox-isolation toggle until the app is deployed

The Deploy-drawer "Disable sandbox isolation" toggle called setPublishState()
— which updates the app by path — even before the app was first deployed, when
the path is empty, throwing an error. Guard it with disabled={!savedApp},
matching the adjacent visibility toggle.

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

* feat(apps): sandbox the in-workspace low-code app viewer in an opaque iframe

Extend the opaque-origin iframe isolation to the logged-in /apps/get viewer.
/apps/get becomes an embedder that keeps the workspace chrome + Edit button and
renders the app inside a cookieless, chrome-less /app_embed viewer route, handed
a scoped embed token minted from the member's session. The app frame runs in an
opaque origin (no allow-same-origin), so it cannot reach the member's session
cookie or window.parent.

- apps.rs: get_app_embed_token_for_path (authed, by-path, scope + RLS gated);
  mint_app_embed_token grants a path-scoped apps:read:{path} so the viewer can
  load its own app definition and no other
- lib.rs: CORS on /apps (bearer-token only, no cookies) for the opaque viewer's
  by-path reads
- new /app_embed/[workspace]/[...path] viewer route (private analog of /public)
- PublicAppFrame: viewerUrl prop to point the opaque iframe at the viewer route

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

* feat(apps): unify in-workspace app viewers on the shared sandboxed path

Route every in-workspace app display (low-code and raw) through the same
PublicAppFrame -> PublicApp machinery as the public viewer, so the sandbox /
legacy-unsandboxed / disable-sandbox-consent behavior is identical on every page.

- new InWorkspaceAppViewer renders both app types via PublicAppFrame; /apps/get
  and /apps_raw/get become thin wrappers over it
- /apps_raw/get previously rendered RawAppPreview directly (always isolated, with
  no legacy-grandfathering or consent handling); now consistent with the rest
- retire the legacy same-origin raw viewer /apps/get_raw/[version] and re-point the
  apps-list row to /apps_raw/get; remove the dead /apps_raw/[ws]/[version] route
- load the raw bundle secret in the shared viewer (getAppByPath doesn't return it)

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

* fix(apps): address PR review feedback (scope + policy hardening, nits)

- require handler-level apps:read on list_apps / list_search_apps so a scoped
  embed token cannot read app definitions through the list endpoints. The route
  layer treats apps:run as satisfying read; the handler check (which does not)
  closes the gap.
- treat legacy_unsandboxed as backend-owned: strip any client-provided value in
  create/update so it can only be set by the grandfather migration, not the API.
- document mint_app_embed_token's caller-verifies-access contract.
- use Button's declared onClick prop for the consent action (was onclick, which
  fell into the rest-spread and bypassed the component's click handling).
- test: lock that the embed scopes cannot satisfy domain-level apps:read.

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

* docs(apps): document embed-token endpoints in openapi + fix doc nit

Second-round review nits:
- add the three app embed-token endpoints (apps/embed_token/p/{path},
  apps_u/embed_token/{secret}, and the EE apps_u/embed_token_by_custom_path) plus
  the EmbedTokenResponse schema to openapi.yaml; note .html on get_data
- mint_app_embed_token doc: "Both" -> "All" (it lists three call sites)

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

* feat(apps): bound embed-token scopes to the caller's own

The embed-token mint now enforces ensure_scopes_within_caller, so the
minted scope set is always within the calling credential's own scopes
(a no-op for regular unscoped sessions). Adds a unit test locking the
boundary and documents the contract on mint_app_embed_token.

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

* fix(apps): raw-app ctx in external embeds + page credential in direct render

- RawAppPreview: engage the storage relay only in opaque frames (probe Web
  Storage instead of just window.parent), so a public raw app embedded in an
  external iframe hydrates ctx/storage directly; add a relay-timeout fallback
  so an unresponsive parent can never stall the ctx handshake.
- PublicAppFrame: in direct render, expose the page's own bearer credential
  through the AuthToken context (JWT public URLs), matching the previous
  route behavior; opaque-viewer mode keeps the embed token.

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

* feat(apps): sandbox isolation UI polish + COI embed support for raw apps

- Deploy drawer: move the sandbox toggle out of "Public URL" into its own
  "Sandbox isolation" section (the setting applies to every viewing surface,
  not just the public URL), with positive phrasing, visible helper text, and
  state-aware alerts (warning when disabled, info for pre-isolation apps).
  Toggling it now toasts its own message instead of the login-mode one.
- Extract the deploy-time migration prompt into a shared
  LegacySandboxMigrationModal built on the common Modal component, and wire
  it into the raw app editor header too (it previously had no prompt, so
  re-deploying a pre-isolation raw app silently changed behavior).
  updateRawAppPolicy now also drops the backend-owned legacy flag, matching
  the low-code updatePolicy.
- Viewer consent prompt: use the common ConfirmationModal and show the app
  path (new appPath prop) instead of the route pathname, falling back to
  "this app" when the path isn't known yet.
- COI embeds: propagate the wm_coep opt-in to the raw-app wrapper document
  and have the backend assert COEP require-corp on it when the flag is
  present — required for the bundle iframe to load when the public app page
  is embedded inside a cross-origin-isolated page. Previously this only
  worked in dev because the Vite proxy injects the header; the production
  response lacked it.

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

* feat(apps): app navigation parity across sandboxed and direct viewers

- Navbar component: same-app items relay query + hash to the embedder page
  (which mirrors them onto the root URL, keeping its own pathname and
  transport params), app items navigate the top page through a validated
  wm_embed_navigate relay instead of the cookieless viewer iframe, and
  external items keep opening a new tab. Selected-item detection now
  recognizes the /app_embed viewer route and ignores transport params.
- Frontend-script `goto` and button `onSuccess: gotoUrl`: same-window
  navigation goes through a shared appNavigateSameWindow helper that relays
  to the embedder inside the opaque viewer (same-origin paths SPA-navigate,
  http(s) URLs do a full load, other schemes rejected) and keeps plain
  window.location everywhere else.
- /apps/get and /apps_raw/get: key the viewer by workspace/path so in-route
  navigation fully remounts it — previously the URL changed but the app (and
  in sandbox mode its path-scoped token) did not follow.
- wm_embed/wm_embedder_origin added to the reserved query params so they no
  longer leak into the app's ctx.query.
- Raw apps: drop the sandbox attribute entirely for the unsandboxed
  (grandfathered/consented) blob path, matching the pre-isolation viewer
  exactly — the attribute added no isolation there and sandboxed popups
  (e.g. OAuth flows).

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

* fix(apps): preserve grandfathered policy across updates + in-workspace viewer parity

Round of compatibility hardening so pre-existing apps behave exactly as
before on every surface:

- `legacy_unsandboxed` is now preserved across app updates unless the payload
  explicitly clears it (`false`, sent by the editor's migration prompt and the
  sandbox toggle). Unrelated update paths — CLI / git-sync redeploys,
  publish-mode toggles, cross-workspace promotion — no longer silently drop
  the grandfathering. Clients still can never SET the flag.
- The embed-token endpoints (secret, path, EE custom-path) read only the
  sandbox-decision policy fields, leniently, and no longer mint a token for
  raw / legacy / disable_sandbox renders: the token is only consumed by the
  sandboxed low-code render, and minting for the others wrote a useless token
  row per view and could fail the render for scope-restricted callers.
- In-workspace viewer parity with the pre-sandbox `/apps/get`: new
  `inWorkspace` mode on PublicApp (no "Powered by Windmill" badge / user
  overlay, no HTML-result approval gate, column flex wrapper, `hideRefreshBar`
  honored again), and the page's query/hash are forwarded into the opaque
  viewer so `ctx.query` / `ctx.hash` reach the app.
- Raw apps: `window.ctx` is always `{ctx, workspace}` again (anonymous viewers
  of pre-existing bundles rely on `ctx.workspace`), and the runnable bridge's
  job-id scoping now applies only to sandboxed renders (`gateJobIds`) — an
  unsandboxed bundle holds the same credential as the bridge, so gating there
  only broke pre-existing apps polling persisted or runnable-returned job ids.
- Document `disable_sandbox` / `legacy_unsandboxed` in the openapi Policy
  schema; add a unit test for the lenient policy read.
- bump ee-repo-ref to the matching EE commit.

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

* fix(apps): keep share-link viewer credentials out of the isolated app context

The JWT path segment of authenticated share URLs is an embedder-side
credential, consumed only to mint the scoped embed token. Two transport
channels still copied it into the isolated frame where app-authored code
runs:

- the opaque viewer iframe src defaulted to window.location.href — the
  public and custom-path routes now pass a sanitized viewerUrl (JWT segment
  stripped, query/hash preserved, captured once so the hash relay does not
  reload the iframe);
- document.referrer on the same-origin iframe navigation carried the full
  embedder URL — both app iframes now set referrerpolicy="no-referrer"
  (sandboxed renders only for the raw bundle iframe, keeping exact legacy
  parity; nothing reads the referrer).

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

* chore(frontend): drop unused import inherited from main merge

`slide` import in AssistantMessage.svelte (from #9539) turns `npm run check`
red on this branch.

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

* fix(apps): redirect the removed raw-app viewer path to the unified viewer

The old same-origin raw-app viewer route (/apps/get_raw/{version}/{path}) was
removed in favor of the sandboxed unified viewer. Re-add a thin client route at
the old path that redirects stale bookmarks to /apps_raw/get/{path}, preserving
query + hash (the pinned version is dropped — the unified viewer shows latest).

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

* fix(apps): narrow embed-token scopes and base consent on browser session

- Embed token: resource access is metadata-only (list/type/exists) via a
  `resources:run` marker — resource values (get/get_value/get_value_interpolated/
  list_search) are no longer reachable. Job reads are by-id only: an `app_embed`
  sentinel blocks the workspace-wide job enumeration/export routes (jobs/list,
  list_filtered_uuids, queue/list, completed/list, queue/export) while by-id
  result polling keeps working.
- disable_sandbox consent now gates on whether the browser holds any Windmill
  session (cookie-only whoami) rather than workspace-scoped auth, so a viewer
  logged into a different workspace is still prompted before a same-origin render.
- db-explorer: resolve the MySQL database name server-side (the metadata query
  already falls back to DATABASE()) instead of reading the resource value
  client-side; getTablesByResource derives the default db from the schema.

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

* chore(apps): trim embed-scope and consent comments

Reduce duplication — state the resource/job route exclusions and the
workspace-session-vs-cookie rationale once at their source and reference them
elsewhere; drop contrast/justification phrasing. No behavior change.

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

* feat(apps): make app sandbox isolation opt-in (alpha)

Replace the disable_sandbox + legacy_unsandboxed policy pair and the
per-version viewer consent with a single positive `sandbox` opt-in flag.
Apps are unsandboxed by default (same-origin, full session — the
pre-isolation behavior), so existing apps are unchanged and no migration
is needed. Publishers opt an app into isolation from the deploy drawer,
flagged alpha.

- Policy.sandbox: Option<bool>; EmbedTokenResponse -> {token, expiration,
  raw_app, sandbox}; mint an embed token only for sandboxed low-code apps.
- Drop the legacy-unsandboxed migration and the deploy-time migration
  prompt; remove the consent modal and the browser-session probe.
- Deploy drawer: a single "Sandbox isolation" toggle (alpha), off by
  default, shared by the low-code and raw editors.
- Bump ee-repo-ref to the companion EE commit.

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

* fix(apps): confine embed token to its intended user/folder/job routes

The embed token's broad read scopes spanned whole domains while the
matching routers are CORS-enabled for the opaque app iframe:

- users:read / folders:read were domain-wide, so the token could reach
  users/list, users/list_usage, users/username_to_email/*, folders/list,
  etc. Restrict to an app_embed-sentinel allowlist: only users/whoami and
  folders/listnames; deny the rest of those domains.
- jobs:read allowed jobs/completed/export, missed by the job denylist.
  Add it alongside jobs/queue/export.

Extend the embed-scope allow/deny test matrix to cover all of these.

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

* docs(apps): align sandbox comments with the opt-in model

The consent prompt, deploy-time migration, and legacy-unsandboxed
grandfathering were removed when sandbox isolation became an opt-in
policy flag; update the comments that still described them so they
match the two-state (default-unsandboxed / opt-in-sandboxed) reality.
Comments only, no behavior change.

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

* fix(apps): confine embed-token job reads to runs the app launched

App component jobs are stamped `created_by = the viewer`, so an embed token
reads its own runs via the launched-by-viewer fast path. The token then also
inherited the viewer's broader job access (share links, folder ACLs, admin
RLS), letting user-authored app JS reuse it to read unrelated jobs by id. Stop
embed tokens at the fast path: only jobs the viewer launched, never those
merely visible to them. Return NotFound so the untrusted app can't probe
existence.

Regression test: an embed token reads its own launched job but is denied the
foreign job (result/logs/getupdate) an admin viewer's normal token can read.

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

* fix(apps): allowlist embed-token apps/jobs routes + scope run to the app

The embed token's apps:run/jobs:read reached more than a running app needs.
Replace the job denylist with strict per-domain allowlists on the app_embed
sentinel:

- Apps: only the app's own definition (apps/get/p/<path>) and the public
  app-serving endpoints (apps_u/*). Denies workspace app inventory
  (exists, custom_path_exists, list, list_paths*).
- Jobs: only the by-id poll routes the frontend JobLoader uses. Denies job
  counts and the job_signature/resume_urls capability-minting routes (the
  by-id reads remain confined to the app's own runs).

Drop unqualified apps:run from APP_EMBED_SCOPES; mint apps:run:<path> instead
and authorize apps:run:<requested path> first in execute_component, so the
token can only run its own app's components, not another app's.

Extend the embed-scope route matrix and add a path-scoped run unit test.

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

* docs(apps): clarify the sandbox toggle vs the on-behalf-of model

The deploy-drawer sandbox copy leaned on "session" in a way that collided
with the on-behalf-of permissioning right above it. Reword it to say the
toggle governs what the app's browser-side code can reach in the viewer's
browser — distinct from who its runnables execute as — and rename the label
to "Isolate the app from the viewer's browser session".

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

* fix(apps): path-scope embed-token S3 download to its own app

The apps_u/* allowlist also admitted apps_u/download_s3_file/<path>, whose
handler authorized any authenticated caller — so an embed token minted for app
A could download app B's S3 files via B's on-behalf policy. Add the same
path-scoped guard execute_component uses: download_s3_file_from_app now checks
apps:read:<path> first, confining the token to its own app. Other path-taking
apps_u routes are already covered (writes lack apps:write; embed_token/p
path-checks; public_resource is type-constrained).

Extend the path-scoping unit test to cover apps:read (download) alongside
apps:run (execute).

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

* fix(apps): path-scope public-app-by-secret read to the embed token's app

The apps_u/* allowlist admitted apps_u/public_app/<secret>, whose handler only
checked the viewer's read access — so an embed token minted for app A could read
app B's definition by secret (confused deputy via the viewer's identity).
get_public_app_by_secret now binds a scoped caller to the resolved app with
check_scopes(apps:read:<path>), confining it to its own app; unscoped sessions
and anonymous access are unchanged.

get_raw_app_data needs no binding (pure secret capability, no caller identity).
Document the full set of app-resolving handlers the path-scoped read covers.

Bump ee-repo-ref for the companion custom-path fix.

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

* fix(apps): preserve pre-sandbox behavior for db-explorer, edit link, jwt

Three behavior-parity fixes for non-sandboxed (existing) apps that the
sandbox-isolation refactor changed incidentally:

- DB-explorer MySQL table picker: when the connection can see multiple
  non-system schemas, label the default db's tables unprefixed again. The
  resource-value read was removed globally, so identify the default db from
  the introspection script's `DATABASE() AS default_db_name` (carried on
  SQLSchema.defaultDb) instead of guessing "the single schema key". Equivalent
  to the prior resource.database match; editor-only (table picker).
- In-workspace Edit button: restore `?nodraft=true` on both /apps/get and
  /apps_raw/get, so opening the editor from the viewer loads the deployed
  version, not a draft.
- Custom-path (/a) viewer: restore the "could not authenticate user with jwt
  token" toast when a path JWT fails to resolve a user, instead of silently
  falling through.

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

* fix(apps): confine embed-token S3 downloads to the app's own keys/outputs

download_s3_file_from_app authorized any authenticated caller for any S3 key
(opt_authed.is_some() bypass). A sandboxed app's embed token carries the
viewer's identity, so app-authored JS could fetch arbitrary S3 keys readable by
the on-behalf identity, beyond the app's own declared keys or outputs.

Route app embed tokens through the same allowlist as anonymous viewers — the
app's declared allowed_s3_keys, or files produced by this app's own component
runs — instead of the authed bypass. The produced-files check is parameterized
by created_by (the embed viewer for a token, else anonymous) so a sandboxed
app's own S3 outputs still render while arbitrary keys are denied.

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

* fix(apps): let embed tokens cancel their own jobs; gate cancel to launcher

A sandboxed low-code app supersedes an in-flight component run on re-run by
canceling it, but the embed token only had jobs:read, so cancellation silently
failed and prior jobs ran to completion.

- Permit the by-id jobs_u/queue/cancel POST for app_embed tokens at the route
  layer (the only write reachable through the existing by-id allowlist).
- Gate cancel_job_api: an app_embed token may cancel ONLY jobs it launched
  (created_by == viewer). cancel_job_api had no other per-job ownership check,
  so this also confines the token instead of letting it cancel any job by id.
- /app_embed now sets workspaceStore so cancellation targets the right
  workspace instead of an empty/stale one in the cookieless iframe.

Add a shared has_app_embed_sentinel helper; cover cancel in the route matrix
and the jobs_read_auth integration test (own job cancelable, foreign denied).

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

* fix(apps): drop get_root_job_id from the embed-token job allowlist

Audit of the embed token's reachable job routes: get_root_job (jobs_u/
get_root_job_id) has no access check in its handler at all — it returns any
job's root-job id by id — and the app runtime never calls it. Remove it from
the by-id allowlist so the embed token can't probe a foreign job's flow lineage;
add a denied-route assertion.

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

* feat(apps): scope sandboxed-app localStorage per app

Sandboxed apps shared one localStorage store (one key on the real origin), so an
app could read or clobber another app's keys — and, with job ids stashed there,
reuse its embed token to read another app's job. Scope the backing store per app.

The embed-token endpoints now return the resolved app_path (EmbedTokenResponse;
not a new disclosure — the viewer already receives the path when it loads the
app). PublicAppFrame (low-code) and RawAppPreview (raw) key their backing store
by it: wm_apps_localstorage:<app_path>. Same app shares one store across its
public and in-workspace surfaces; different apps are isolated. Unsandboxed apps
are unaffected (real same-origin localStorage, as before).

Bump ee-repo-ref for the companion custom-path change.

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

* fix(apps): scope embed access checks to embed tokens + key app storage by workspace

- Apply the path-scoped read/run checks on the public-by-secret read and the
  component run path only when the caller is an app embed token, so other
  caller types keep their prior access.
- Key the sandboxed app's backing client storage by workspace + path instead
  of path alone, and return the resolved workspace from the embed-token
  endpoints so the custom-path viewer can derive it.
- Show a clear message instead of an indefinite loader when the viewer route
  is opened outside its embedder.

Bumps ee-repo-ref to 5b8476b.

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

* fix(apps): mint embed tokens only from the trusted embedder caller

An app embed token must not reach the embed-token mint endpoints; refresh
minting stays with the embedder session/JWT. Enforced at the scope route
layer and at the mint chokepoint, with a route-matrix regression test.

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

* fix(apps): support S3 upload and frontend-script S3 download in sandboxed apps

Sandboxed apps run with a scoped embed token (no cookie). Let the app's
S3 file-input upload and the frontend-script download({s3}) helper work in
that context: upload is reachable with apps:run and re-checked per-app at the
handler; the script download routes through the app-scoped apps_u endpoint
with the embed token instead of the cookie-authed job_helpers path. Default
(unsandboxed) apps are unchanged.

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

* chore: update ee-repo-ref to b0cb761bf9852974e571b2978032d310cc998517

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

Previous ee-repo-ref: e673c714a4618fdb72353a475f49c748e6016642

New ee-repo-ref: b0cb761bf9852974e571b2978032d310cc998517

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-06-23 10:05:00 +02:00
hugocasa e19594df2a fix: re-enforce scoped API token boundaries across handlers (#9712)
* fix: re-enforce per-path token scope on store rename, delete and interpolation

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

* fix: enforce token scope on workspace export and resume-url minting

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

* fix: enforce per-item and runnable scope on trigger create paths

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

* fix: enforce app write scope before persistence and on rename

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

* fix: enforce scope containment on mcp oauth approval

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

* fix: scope mcp endpoint-proxy jwt to the proxied route

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

* fix: treat resource-linked variables and resources as covered by resource scope

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

* fix: only require variables:read for plaintext-secret workspace export

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

* fix: handle singlestepflow resume, reject empty mcp grant, scope var-skipped tarball

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-22 23:55:19 +02:00
Diego Imbert 19bc0052f1 fix(backend): include raw_app drafts in list_apps draft_users (#9647)
The home list's draft user badges come from list_apps' `draft_users`
subquery, which only matched `draft.typ = 'app'`. `app` and `raw_app`
are separate draft kinds over the one `app` table, so a deployed raw app
with a pending draft had `is_draft = true` (the join already matches both
kinds) but an empty `draft_users` — the row showed a "Draft" badge with
no owner badge. Match `typ IN ('app', 'raw_app')`, consistent with the
`is_draft` join and the draft-only query.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 23:22:28 +02:00
Ruben Fiszel 3bf6e102af fix(apps): apply scope-path predicate to app list/search endpoints (#9581)
The list_apps and list_search_apps endpoints did not filter returned
rows against the calling token's resource-qualified scope. A token
scoped to apps:read:u/foo/specific_app could list every app in the
workspace, including full app_version.value definitions.

Apply build_scope_path_predicate, mirroring the protection already in
place for script, flow, resource and variable list endpoints.

Fixes WIN-2046

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:53:30 +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 d0aaba0f16 require pinned sha for inline raw_code in viewer app run mode (#9570)
* fix: require pinned sha for inline raw_code in viewer app run mode

* fix: gate rd_string import behind parquet feature to fix oss build

* fix: also require pin for raw_code with app_script id in viewer run mode
2026-06-14 23:48:03 +02:00
Ruben Fiszel 765f50c474 feat: folder-level label inheritance for scripts, flows and jobs (#9524)
* feat: folder-level label inheritance for scripts, flows and jobs

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

* fix: use SECURITY DEFINER folder_labels() for RLS-consistent inheritance

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

* feat: extend folder label inheritance to apps, resources, variables, schedules

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:49:02 +00:00
Ruben Fiszel cf9ad54181 feat: workspace protection rule to restrict anonymous app deployment (#9509)
* feat: restrict anonymous app execution mode to admins

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

* test: add regression test for anonymous app admin gate

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

* feat: gate anonymous app mode behind workspace protection rule

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

* fix: lock app row on anonymous-mode check, fail closed while rules load

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 13:47:19 +00:00
hugocasa b0c3b01d31 fix(cli): preserve user drafts on sync push and permissioned-as (#9381)
CLI deploys (sync push, set-permissioned-as) went through the same
create/update endpoints as a UI "deploy from draft", which delete the
draft at that path. That silently wiped teammates' in-progress drafts on
every push. Add a transient skip_draft_deletion deploy flag (mirroring
deployment_message) that the CLI sets; the backend then skips the
DELETE FROM draft for scripts, flows, and apps. UI deploys are unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:02:11 +00:00
Diego Imbert 0f7dd86e5c feat: persistent in-editor drafts via UserDraft (#9121)
* refactor(frontend): remove localStorage-backed autosave drafts

Strip the per-editor localStorage autosave for flows, apps and raw apps,
along with the associated restore toasts and diff actions, so we can
replace them with a unified UserDraft service in a follow-up. The
backend DraftService (DB-backed drafts) is untouched.

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

* feat(frontend): add UserDraft service for per-workspace local drafts

Introduces UserDraft, a key-value store keyed by
`{workspace}/{itemKind}/{path}` and backed by localStorage. Supports
save/get/remove plus a reactive use() handle so multiple component
instances observing the same draft stay in sync via a shared $state
loaded through useLocalStorageValue. Designed to host drafts for
scripts, flows, apps, raw apps, resources, variables, and all trigger
kinds.

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

* tests

* nit schedule_ prefix

* feat(frontend): persist deep mutations in useLocalStorageValue

Track the serialized value alongside the $state and add an $effect that
deep-reads it (via readFieldsRecursively). When a deep mutation produces
a serialization that differs from the last persisted blob, write it to
localStorage. The setter keeps writing synchronously so callers reading
localStorage right after assignment still see the new value; the effect
no-ops on those because lastSerialized was already updated by the setter.
Undefined values are persisted as a removal.

UserDraft no longer needs its own removeItem workarounds for undefined
values — useLocalStorageValue handles that uniformly now.

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

* feat(frontend): add defaultValue + empty-path handling to UserDraft

UserDraft.use() accepts an opts.defaultValue used when no localStorage
entry exists yet. It is not persisted on first read — only an actual
mutation writes through.

Empty paths (new items) bypass localStorage entirely. The entry still
lives in the in-memory Map so multiple components on the same /add page
share state, but save/get/remove/use never read or write localStorage
with an empty path. Once the item is saved and the route navigates to
its new URL, a fresh use() on the non-empty path takes over.

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

* feat(frontend): wire script editor to UserDraft

The script editor's top-level state now lives in UserDraft.use(), keyed
on the route's path (page.params.path on /scripts/edit, '' on /scripts/add).
Deep edits inside ScriptBuilder persist automatically; deploy and draft
restore now call UserDraft.remove to clear the local autosave alongside
the backend draft.

Replaces the URL-hash autosave that ScriptBuilder used to write via
replaceStateFn — that prop is now gone, the encodeScriptState debounce
is gone, and Triggers no longer takes a saveSessionDraft callback.
Viewing a specific historical hash (?hash=...) is kept draft-free by
passing '' as the path.

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

* feat(frontend): wire flow editor to UserDraft

flows/add and flows/edit drive the flow value through a StateStore
adapter backed by UserDraft.use, so every edit auto-persists at
userdraft/w/{ws}/flow/{path} without touching FlowBuilder's internal
.val convention. On returning visits the local autosave wins and a
toast offers a diff against the latest backend draft/deployed version;
on a fresh visit the backend value is written into the handle. Deploy,
save-as-draft rename, restore-draft and restore-deployed each call
UserDraft.remove on the route path so the local autosave doesn't
outlive the action.

Adds UserDraft.has() for "is there already a local draft?" detection
in the load path.

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

* feat(frontend): wire app editor to UserDraft

AppEditor registers a UserDraft.use<App> handle for its current path
(empty path for /apps/add stays in-memory) and a single $effect
deep-tracks the internal stateApp and forwards every mutation to the
handle. useLocalStorageValue's lastSerialized check then dedupes the
actual localStorage writes per tick, so even fast drag/resize loops
only persist when the JSON output really changes.

/apps/edit overlays a local autosave from UserDraft.get on top of the
backend value when one exists, with the existing "Discard / Show diff"
toast wired to UserDraft.remove. Deploy, save-as-draft, restore-draft
and restore-deployed all call UserDraft.remove on the relevant path,
including the JSON editor save paths.

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

* feat(frontend): wire raw app editor to UserDraft

/apps_raw/edit owns the canonical raw-app state (files, runnables,
data, summary) in four $state vars; a single $effect deep-tracks them
and forwards the bundle to a UserDraft.use<RawAppDraft> handle so each
mutation tick persists at userdraft/w/{ws}/raw_app/{path} (deduped by
useLocalStorageValue's serialized check). On load the route overlays
the local autosave on top of backend.draft/deployed and offers a
"Discard / Show diff" toast when they diverge; matching local entries
are silently dropped. Deploy, save-as-draft rename, restore-draft and
restore-deployed each call UserDraft.remove on the route path.

/apps_raw/add keeps the same shape (UserDraft.use with empty path)
so the draft is in-memory only and we drop it explicitly when the
initial save creates the real path.

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

* feat(frontend): wire resource editor to UserDraft

ResourceEditor registers a UserDraft.use<ResourceState> handle keyed
on the initialPath (empty for new resources, in-memory only). A
$effect deep-tracks the current workspace's edit state and forwards
mutations to the handle; on bootstrap and lazy backend-fetch the
local autosave wins over the backend value when they diverge. After
a successful save() we call UserDraft.remove so the local autosave
doesn't outlive the deploy. Cross-workspace deploys always start from
the live backend value rather than the local draft.

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

* feat(frontend): wire variable editor to UserDraft

VariableEditor persists the current workspace's edit state via
UserDraft.save on every mutation, keyed on editPath ('' for new
variables → in-memory only). Backend fetches now overlay a matching
local autosave when one exists, and initNew() rehydrates from the
in-memory empty-path entry so opening a fresh "Add variable" drawer
keeps any unsaved work from the previous open. After a successful
save we drop the corresponding entry.

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

* editor external changes sync

* fix(frontend): don't UserDraft.remove flows while route is still mounted

The /flows/add and /flows/edit routes drive FlowBuilder from a flowStore
whose getter reads flowHandle.draft directly. Calling UserDraft.remove
synchronously before goto() therefore wiped the in-memory entry, made
flowStore.val collapse to emptyFlow(), and tripped
UnsavedConfirmationModal against the just-saved value — even though the
deploy/save-draft itself succeeded.

Drop those explicit removes in onSaveInitial, /add onDeploy, and
/edit onDeploy. The empty-path entry self-cleans on unmount via
onDestroy ref counting; for the non-empty edit path the next visit's
load-time diff will silently overwrite localStorage when the local
autosave matches the deployed value. Restore-draft/restore-deployed
keep their explicit remove because they navigate to the same route
(no modal) and loadFlow immediately rehydrates the handle.

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

* Revert "fix(frontend): don't UserDraft.remove flows while route is still mounted"

This reverts commit 079ebef72b.

* Only remove from localStorage

* feat(frontend): saveInitialValue option on useLocalStorageValue

The first time a value flows into a UserDraft.use() handle — typically
the editor route loading the backend value via flowHandle.draft =
backendFlow — is the baseline, not a user edit. Persisting it on the
spot puts a copy of the backend into localStorage on every page open
and produces spurious "local autosave" toasts on next visit when the
serialization round-trips differently.

useLocalStorageValue now takes options.saveInitialValue (default true,
backward compatible). When false, the first time the serialised form
of the state changes — via the setter or via a deep mutation — the
lastSerialized cache is updated but localStorage is not touched. Every
write after that persists normally. UserDraft.use() passes false.

Tests updated to reflect the new contract (first write is the
baseline) and a regression test added for the second-write-persists
behaviour.

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

* fix(frontend): persist full multi-workspace bundle for resources/variables

ResourceEditor and VariableEditor can stage edits for several target
workspaces in a single drawer session (see deployTo / states[ws] map).
The previous UserDraft wiring only persisted states[$workspaceStore] —
the user's session workspace — so any edit made under a different
target workspace tab disappeared on refresh.

Persist the entire `states: Record<wsId, State>` bundle as the draft
value instead. On lazy-fetch we pick the local state for that ws if
present and divergent from the backend; on bootstrap for new
resources/variables we restore states for every workspace the user
had staged. The localStorage key still lives under the user's session
workspace via UserDraft, but its contents now cover all target
workspaces from that session.

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

* fix(frontend): bake parent_hash into the initial script load

loadScript() assigned the backend value to scriptHandle.draft and then
deep-mutated parent_hash on the next line. Under
useLocalStorageValue's saveInitialValue=false contract only the very
first write is the baseline — the parent_hash mutation right after
counted as a second write and was persisted to localStorage, so
opening an existing script would silently write a draft entry even
though the user hadn't touched anything.

Combine `parent_hash` (and the topHash override) into a single
bakedBaseline so each branch of loadScript performs exactly one
assignment to scriptHandle.draft. Mirrored across the local-autosave
branch's discard callbacks too.

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

* feat(frontend): wire SqsTrigger editor to UserDraft

Persist the trigger's getSaveCfg() output to
userdraft/w/{ws}/schedule_sqs/{path} on every edit, overlay any
existing local autosave on top of the backend value when openEdit
loads the trigger, and clear the entry on successful update.

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

* feat(frontend): wire KafkaTrigger editor to UserDraft

Same pattern as the Sqs trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger, drop the entry on successful update.

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

* feat(frontend): wire NatsTrigger editor to UserDraft

Same pattern as the Kafka trigger: persist getSaveCfg() on every edit,
overlay any local autosave on top of the backend value when openEdit
loads the trigger (with initialConfig/originalConfig snapshotted from
backend first so hasChanged correctly reports the overlay as unsaved),
drop the entry on successful update.

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

* feat(frontend): wire MqttTrigger editor to UserDraft

Same pattern: persist getSaveCfg() on edits, overlay local autosave
in openEdit (with initialConfig/originalConfig snapshotted from
backend first), drop the entry on successful update.

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

* feat(frontend): wire GcpTrigger editor to UserDraft

Same pattern as the other triggers.

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

* feat(frontend): wire AzureTrigger editor to UserDraft

Same pattern as the other triggers.

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

* feat(frontend): wire WebsocketTrigger editor to UserDraft

Same pattern as the other triggers.

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

* feat(frontend): wire PostgresTrigger editor to UserDraft

Same pattern as the other triggers.

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

* feat(frontend): wire EmailTrigger editor to UserDraft

Same pattern as the other triggers.

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

* feat(frontend): wire HTTP RouteEditor to UserDraft

Same pattern as the other triggers, keyed on schedule_http.

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

* feat(frontend): wire ScheduleEditor to UserDraft

Same pattern, keyed on schedule_schedule. ScheduleEditor doesn't track
an originalConfig (its saveDisabled doesn't compare against a baseline)
so ordering is simpler — initialConfig snapshotted from backend, local
autosave overlaid after.

This completes UserDraft wiring across all 11 trigger editors.

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

* refactor(frontend): rename schedule_* UserDraft kinds to trigger_*

The schedule_ prefix grouped all the trigger editors under what looked
like a "scheduler" namespace; trigger_ is what these actually are
(triggers — including the cron-style schedule). Mechanical rename
across UserDraftItemKind, every trigger editor's UserDraft.save/get/
remove calls, and the one test that asserted on the localStorage key.

Behaviour-only impact: existing localStorage keys under
userdraft/w/{ws}/schedule_{kind}/{path} from older builds will be
ignored on next open (no schema migration). Users will lose any
unsaved trigger drafts persisted before this change.

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

* refactor(frontend): wrap UserDraft localStorage payload as { value }

localStorage entries now look like {"value": <draft>} instead of just
<draft>. The wrapping is invisible at the API boundary — UserDraft.use,
.save, .get, .remove all still operate on the unwrapped draft value —
but it leaves room to add metadata (timestamps, originating user,
schema version, ...) later without breaking existing entries.

Internals:
- StoredDraft<V> = { value: V } is what we serialise to localStorage
  and what useLocalStorageValue's $state holds.
- wrap()/unwrap() helpers gate the boundary; the handle returned by
  use() unwraps on get and wraps on set.
- readPersisted() defensively drops entries whose payload isn't a
  { value: ... } object, so pre-migration drafts written by earlier
  commits on this branch are simply ignored (has() returns false,
  get() returns undefined) rather than confusingly surfacing as
  undefined-shaped drafts.

Test data switched from { value: X } (which collides confusingly with
the wrapper shape) to plain primitives / objects, plus a regression
test for the pre-migration ignore behaviour. 28 tests pass.

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

* feat(backend): expose freshness for UserDraft staleness check

Variable
- Add `edited_at TIMESTAMPTZ NOT NULL DEFAULT now()` + `edited_by VARCHAR(50)` to the `variable` table (parity with `resource`); set them on INSERT and on every UPDATE.
- Surface them on `ListableVariable` so `getVariable` / `listVariable` return them.

DB drafts (script, flow, app/raw_app)
- The `*WithDraft` endpoints now also return `draft.created_at` as `draft_created_at`. The draft value alone wasn't enough to tell whether a teammate (or another tab) had pushed a fresh draft while local autosave was in flight; the new field is the staleness signal.
- Wired in `get_script_by_path_w_draft` (`ScriptWDraft.draft_created_at`, including the `prefetch_cached` forwarding), `get_flow_by_path_w_draft` (`FlowWDraft.draft_created_at`), and `get_app_w_draft` (`AppWithLastVersionAndDraft.draft_created_at`). OpenAPI updated to match.

The frontend will read these in a follow-up to implement the local-draft staleness check; this commit only widens the API surface.

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

* feat(frontend): track remote rev metadata on UserDraft entries

Extends StoredDraft<V> with two optional rev fields used by the
forthcoming staleness modal:

- remoteRev — the deployed version's id/hash/timestamp at the moment
  the local draft was created. Compared against the latest deployed
  rev on reload.
- remoteDraftRev — the DB-draft created_at at the moment the local
  draft was created. Only meaningful for kinds that have a DB draft
  (script, flow, app, raw_app). Checked first so a teammate's draft
  push is detected before the "deployed version moved" case.

API additions on the handle returned by UserDraft.use():

- handle.meta — read the rev metadata currently stored.
- handle.setDraftAndMeta(value, meta) — atomic write of value + meta in
  a single state.val assignment. Editor routes use this on load so the
  baseline rev rides along with the value without consuming the
  saveInitialValue=false dedup slot twice.
- handle.setMeta(meta) — update just the rev metadata after the user
  picks "Keep current draft" in the staleness modal.
- handle.draft = X — unchanged surface; now preserves existing rev
  metadata across user edits.

Plus UserDraft.getMeta() and UserDraft.save() preserves any persisted
rev metadata when called without a live handle.

7 new tests cover the metadata surface; all 35 pass.

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

* feat(frontend): staleness modal for the script editor's local autosave

Replace the script editor's toast-based "Discard / Show diff" pattern
with a dedicated modal that surfaces *why* the local autosave is out
of date: a new DB draft on the server, or a new deployed version.

Adds `checkStaleness` (UserDraftMeta vs current backend revs, draft-rev
priority) and a `setMeta({ force: true })` mode so the "Keep current
draft" acknowledgement persists even when it happens to be the
entry's first state mutation — under `saveInitialValue: false` an
ack-only setMeta would otherwise be skipped and the modal would
re-fire on next mount.

The modal lives at LocalDraftStaleModal.svelte; the script editor
wires it as a template for the remaining editors. Other editors
(flows, apps, raw_apps, resources, variables, triggers) still use
the previous toast pattern and will be migrated in follow-up
commits.

* feat(frontend): staleness modal for flow, app, and raw-app editors

Migrates the flow, app, and raw_app editor routes to the same
`LocalDraftStaleModal` flow already used by scripts: compare the
recorded meta against the current `version` / `versions[last]` and
`draft_created_at`; on mismatch, surface the choice in a modal.

Adds `UserDraft.saveMeta` for routes that don't hold a live handle
(the app editor reads via `UserDraft.get` and the handle lives in
the child `AppEditor` component). It writes meta directly to
localStorage and tolerates the no-entry case.

* feat(frontend): migrate legacy localStorage autosave entries

Apps and flows used to autosave under un-scoped keys (`flow`/`flow-{path}`,
`app`/`app-{path}`, `rawapp`/`rawapp-{path}`) with a base64-encoded
state envelope. This adds a one-off migration that rewrites surviving
legacy entries under the workspace-scoped `userdraft/w/{ws}/{kind}/{path}`
keys with the new `{ value }` wrapper, transforms the payload where the
shape differs (drops the flow view-state envelope, defaults the new
raw-app `summary` field), and drops the source key.

The migration lives in its own file (`userDraftLegacyMigration.ts`)
so the new UserDraft service stays free of legacy decoders. Idempotent
via a `userdraft/legacy_migrated_v1` sentinel; runs from the logged-in
root layout once a workspace is known. Defensive shape checks avoid
clobbering co-resident apps that happen to use the same key prefixes.

* nit remove comments

* refactor(frontend): per-workspace UserDraft handles in Resource/Variable editors

Earlier commits in this PR wired the resource and variable editors to a
single multi-workspace bundle stored under the user's session workspace
key — which mixed workspaces in one localStorage entry and required a
custom multi-key fix-up pass to persist edits for other workspaces.

Reset both editors to their pre-PR shape and apply the minimal change:
the per-workspace `Record<string, ResourceState>` (resp. `VariableState`)
becomes `Record<string, UserDraftHandle<…>>`, with one handle per
workspace created via `UserDraft.use(…, { workspace: ws })`. The handle
keys its own localStorage entry under that workspace, so cross-workspace
edits stay cleanly separated and reactivity flows through the handle's
`draft` accessor — `bind:` on form fields just works.

Adds `manualRelease: true` + `handle.release()` to `UserDraft.use` so
the editors can register handles lazily inside an effect (Svelte 5
forbids `onDestroy` outside component init). The editors register a
single top-level `onDestroy` that releases every collected handle.

After a successful save, the per-workspace autosave is cleared via
`UserDraft.remove(itemKind, path, { workspace })`.

* refactor(frontend): seed per-workspace handles via UserDraft.use defaultValue

ensureHandle was doing a post-hoc `if (h.draft === undefined) h.draft = baseline`,
which relies on the saveInitialValue=false skip to swallow that seeding
write. Hand the baseline to `UserDraft.use({ defaultValue })` instead —
useLocalStorageValue uses it as the initial $state value when localStorage
is empty, so lastSerialized is correct out of the gate and no setter call
is needed.

* feat(frontend): persist empty-path drafts across reloads

Empty paths used to be in-memory only (via the `isLocalOnly` short-circuit)
because we worried about collisions between concurrent /add tabs. The user
asked for the trade-off to flip: a /flows/add or /scripts/add reload should
restore the user's work, while explicitly clicking "+ Flow / + Script / …"
should always open a clean editor.

- Drop `isLocalOnly` from UserDraft so empty-path entries persist under
  `userdraft/w/{ws}/{kind}/` like any other path. The existing per-kind
  refcounting and saveInitialValue=false behavior already handle them
  correctly — the change is just lifting the bypass.
- Each /add page now calls `UserDraft.remove(kind, '')` synchronously
  when `?nodraft=true` is present in the URL, before the handle is
  created.
- The two "+" entry points that lacked the `?nodraft=true` flag
  (CreateActionsScript's plain `<a href>` and CreateActionsFlow's
  YAML/JSON import paths) now include it, so every fresh-start path goes
  through the wipe.
- Tests updated: the "empty path (in-memory only)" block becomes
  "empty path (persists across reloads)" and asserts the new behavior.

* refactor(frontend): drop legacy-migration shape guard

We assume Windmill is the only app on the origin, so the
isPlausibleLegacyValue per-kind shape check was just dead weight.
Keep the cheap "decoded is an object" guard for malformed payloads.

* docs(frontend): refresh stale "in-memory only" comments around empty paths

Empty-path UserDraft entries persist now. Drop the leftover "in-memory
only" comments on the /add pages' handle creation, and rewrite the
EditorHeader save-initial-draft comments to describe why the UserDraft.remove
call is still needed: the draft was promoted to a real path on the
backend, so the prior-path autosave must not shadow a future "+ App" /
"+ Flow" / … visit.

* fix(frontend): strip ?nodraft=true from /add URLs synchronously

The previous cleanup ran in afterNavigate, which (a) fires asynchronously
— a quick reload between mount and the callback would re-wipe the
freshly-started draft — and (b) did `url.search = ''`, nuking sibling
params like ?template, ?hub, and ?wac.

Move the URL cleanup to the same synchronous block that calls
UserDraft.remove on nodraft, using `window.history.replaceState` so it
lands before paint. Only the `nodraft` key is removed — other params
survive.

* feat(frontend): toast when editor opens on a local autosave

When a route loads its local autosave (differs from backend, no
staleness alarm), surface "Restored from local storage" with up to
two reset actions:
- "Reset to saved draft": drop the autosave, reapply the backend DB
  draft. Only shown when the backend has a DB draft.
- "Reset to deployed": drop the autosave, delete the DB draft on the
  backend (if any), reload from the deployed version. Only shown when
  the item has a deployed version.

The toast title + label wording + per-state inclusion live in a
single helper (`$lib/userDraftToast`). Each editor passes its own
reset callbacks since the side effects differ per route (handle vs
UserDraft.get/save, redraw counters, loadXxx helpers).

Wired to scripts/edit, flows/edit, apps/edit, apps_raw/edit. Resource
and variable editors don't have DB drafts and use per-workspace
handles — a follow-up will tailor a single-action version.

* feat(frontend): load URL-encoded scripts on /scripts/add

The "Fork" action on run/[...run] and several workspace-settings
helper-script templates base64-JSON-encode a NewScript into the URL
hash on `/scripts/add#...`. Until now /scripts/add silently dropped
that payload — both call sites landed on a blank editor.

Decode `page.url.hash` at module top, and if it parses to an object,
apply it as `scriptHandle.draft` and surface "Loaded from URL". The
URL value wins over local autosave, ?template, ?hub, and YAML imports
because the hash represents an explicit "open this script" intent.

Parsing is inlined rather than reusing `decodeState` so an unrelated
hash (e.g. a future route anchor) doesn't fire its default "Impossible
to parse state" error toast.

* feat(frontend): strip URL hash from /scripts/add after consumption

The URL-encoded script is a one-shot seed (Fork preview, workspace
handler templates, hub publish) — keeping the hash in the bar after
loading meant a reload would re-apply the original payload and wipe
whatever the user edited since landing.

After applying `urlScript` and firing the "Loaded from URL" toast,
clear `location.hash` via `window.history.replaceState`. The user's
edits then flow into the normal autosave path (UserDraft empty-path
entry), and a reload restores those edits instead of the seed.

* feat(frontend): load URL-encoded scripts on /scripts/edit + consume-once

Mirror the URL-hash seed mechanism from /scripts/add to /scripts/edit
for parity: decode the base64-JSON-encoded NewScript payload from the
URL hash, apply it over the bakedBaseline as the editor's initial
state, send "Loaded from URL", and strip the hash immediately via
window.history.replaceState so a reload restores the user's autosave
rather than re-injecting the seed.

The seed wins over local autosave + backend draft + deployed —
UserDraft.remove(script, draftPath) drops the stale autosave on disk
before setDraftAndMeta writes the seeded value, so the user's
subsequent edits will overwrite cleanly.

Skipped when ?hash= is in the URL (historical-version view, which is
read-only relative to drafts) and when the hash fragment isn't a
parseable encoded payload.

No callers build /scripts/edit#<encoded> URLs today — this lands the
mechanism for future symmetry with /scripts/add.

* fix(frontend): "Reset to deployed" loop on Restored-from-local toast

UserDraft.remove only clears localStorage — the entry's reactive cell
stays alive as long as some component holds a handle. The toast
callback was relying on remove+loadXxx to reset state, but loadXxx
then read the *in-memory* autosave through the still-alive entry,
matched it against the now-deployed reference, and re-fired the same
toast. Forever.

Drop the in-memory state explicitly before the load:
- scripts/flows/apps_raw (route-level handle): `handle.setDraftAndMeta(undefined, {})`
- apps (handle lives in the AppEditor child): set `app = undefined`
  to unmount AppEditor — its onDestroy releases the handle and the
  entry's refcount drops to 0, destroying the entry.

ScriptBuilder / FlowBuilder / RawAppEditor briefly unmount while the
reload fetches; the flash is the user-visible "loading" cue.

* fix(backend): convert draft.created_at to TIMESTAMPTZ

The new `*WithDraft` endpoints surface `draft.created_at` as
`Option<chrono::DateTime<Utc>>` for the frontend's staleness check,
which requires `TIMESTAMPTZ`. The column was originally created as
plain `TIMESTAMP`, so SQLx fails to deserialize any row that has a
non-null draft and the handler returns HTTP 400 instead of 200 —
caught by `test_draft_endpoints` in the integration tests.

Migrate the column to `TIMESTAMPTZ`, interpreting existing values as
UTC (matching `now()`'s behaviour on a UTC server). No compile-time
sqlx queries reference the column, so the offline cache stays valid.

* fix(frontend): settings drawer auto-opening on /scripts/edit

ScriptBuilder's metadataOpen flag fires when `initialPath == ''` (the
heuristic for "new script, expected on /scripts/add"). The route's
`let initialPath = $state('')` left it empty until applyBaseline ran
later inside loadScript.

Pre-PR, the editor was gated on a route-level `script` $state that
started undefined, so ScriptBuilder didn't mount until loadScript's
synchronous block set both `script` and `initialPath` in the same
tick. With UserDraft.use reading localStorage synchronously, the gate
(`scriptHandle.draft`) is satisfied at mount time and ScriptBuilder
mounts with the still-empty initialPath, popping the drawer open.

Seed initialPath from page.params.path synchronously so ScriptBuilder
sees the path on its first render. Falls back to '' for the historical
`?hash=` view to preserve the existing behaviour there.

* fix(backend): refresh draft.created_at on every upsert

The draft upsert was `ON CONFLICT (...) DO UPDATE SET value = EXCLUDED.value`,
so subsequent draft writes left `created_at` frozen at the first INSERT.
The frontend's UserDraft staleness check reads that timestamp as
`remoteDraftRev`; with it frozen, an updated remote draft looked
identical to the originally-baselined one and the "newer draft was
saved on the server" modal never fired.

Touch `created_at` on conflict too. The column's semantic widens from
"first write time" to "last write time", which is what every reader of
the field actually wants — the staleness signal is the only consumer.

SQLx offline cache regenerated to match the new query text.

* fix(frontend): persist trigger drafts in script-editor autosave

The triggers in ScriptBuilder live in a dedicated `triggersState`
$state, separate from the `script` object that the UserDraft handle
deep-tracks. Pre-PR the per-builder localStorage autosave bridged the
two by snapshotting `triggersState.getDraftTriggersSnapshot()` into
the payload on every write — that bridge was dropped when we removed
the per-builder autosave in favour of UserDraft.

Add an $effect that deep-reads triggersState and mirrors the snapshot
back into `script.draft_triggers`. The UserDraft handle (already
deep-tracking `script`) then persists the trigger drafts as part of
the script autosave, restoring the prior behaviour.

* feat(frontend): debounce option on useLocalStorageValue + 500 ms in UserDraft.use

Adds `debounce: number` to `useLocalStorageValue`'s options. When set,
repeated mutations within the window collapse into a single
localStorage write fired by a plain `setTimeout`. The in-memory
`$state` is updated on every change so readers of `.val` always see
the latest value; only the persistence side-effect is deferred.

No `onDestroy` flush — the timer is independent of the Svelte
lifecycle, so SPA route teardown doesn't drop the pending write
(the callback still fires later as long as the JS context is alive).
A hard browser tab close within the window does drop it; that's an
acceptable trade-off vs the complexity of `beforeunload` listeners
and the leak/refcount issues they create alongside `useLocalStorageValue`'s
keyed instances.

`UserDraft.use` opts in with `debounce: 500` so a typing storm in the
script/flow/app editor produces one localStorage write per 500 ms
instead of one per keystroke.

Tests switch to `vi.useFakeTimers()` and a `flushPersist()` helper to
keep the synchronous `expect(localStorage…)` assertions working. New
test verifies the coalescing behaviour end-to-end.

* fix(frontend): tighten legacy-migration key matching

The legacy migration was consuming any localStorage key starting with
`app-`, `flow-`, or `rawapp-`, with no constraint on what followed and
no shape check on the decoded payload. Two failure modes called out
in review:

1. A future feature (or third-party extension) picking a name like
   `app-recent` would silently lose data on first migration run.
2. A stray key that happened to base64-decode to valid JSON but
   wasn't a real legacy draft would still get promoted to the new
   format, surfacing later as a phantom "Restored from local storage"
   toast on the next edit.

Two guards:

- `LEGACY_PATH_SHAPE = /^[uf]\/[^/]+\/.+$/`: after a `<prefix>-` match,
  the remainder must look like a Windmill item path (`u/owner/name`
  or `f/folder/name`, possibly with deeper segments). Bare-prefix
  empty-path entries (`app` / `flow` / `rawapp` for `/add` autosaves)
  still match the exact branch and don't go through the shape gate.
- `isPlausibleLegacyValue`: after decode, require the payload to
  carry the field the legacy writers actually produced
  (`flow.flow` for flows, any of `summary|value|policy|path` for
  apps, any of `files|runnables|data` for raw apps).

Both are belt-and-suspenders: nothing else currently uses these key
prefixes, but enforcing the shape locally keeps the migration safe
against future namespace collisions.

* fix(backend): drop AT TIME ZONE 'UTC' from draft.created_at migration

The original migration forced `USING created_at AT TIME ZONE 'UTC'`,
which tags every existing wall-clock value as UTC. That matches the
common case (Postgres on a UTC server, which the Docker image and most
managed offerings default to), but on a non-UTC operator's deployment
it shifts all pre-migration timestamps by the server's tz offset.

Drop the USING clause. Postgres's default `TIMESTAMP -> TIMESTAMPTZ`
cast reinterprets each existing value in the session's current
timezone — which is the same timezone under which the original
`INSERT ... DEFAULT now()` values were truncated to TIMESTAMP, so
the conversion correctly recovers the original instant regardless of
the operator's timezone. Same semantics on UTC servers, correct
semantics on non-UTC servers.

Down migration updated symmetrically.

* docs(frontend): clarify staleness modal copy

The four route-level editors (scripts/flows/apps/apps_raw) keep the
user's local draft visible behind the modal so they can glance at it
before choosing. The old body text described the situation (server
has moved on, local autosave is behind) but didn't say what's
actually on screen or how each action maps to it.

New body leads with "The editor is showing your local autosave" and
spells out each action: "Load latest replaces what's on screen; Keep
current leaves it alone." Same copy for both `cause = 'draft'` and
`cause = 'version'`, branching only on what the user is "behind"
relative to.

* refactor(frontend): drop dead updateDraftCallback from Triggers constructor

None of the eight `new Triggers(...)` call sites pass an update
callback any more — the bridge was a leftover from the pre-UserDraft
era when ScriptBuilder ran its own localStorage autosave and had to
be notified on every triggers mutation. The unified UserDraft handle
now deep-tracks `script.draft_triggers` via the $effect in
ScriptBuilder, so the callback channel is dead weight.

Removes the third constructor parameter, the private field, and the
six `this.#updateDraftCallback?.()` invocations across setters and
mutators.

* docs: review nits — variable.edited_at backfill, UserDraft toast/modal headers

Three low-priority callouts:

- Document the variable.edited_at backfill in the migration. All
  existing rows get a single `now()` timestamp from the column
  DEFAULT; the staleness check only consumes the field as an opaque
  rev string and never displays/sorts on it, so the collision is
  harmless — but worth saying out loud.
- Add module headers to userDraftToast.ts and LocalDraftStaleModal.svelte
  explaining how this layer sits above the per-browser UserDraft
  autosave and is distinct from the backend DraftService (the
  server-side "Save as draft" feature surfaced as `*.draft`).

* refactor(frontend): replace UserDraft.release() with useMany()

Public surface change:
- New `UserDraft.useMany(getSpecs: () => UserDraftSpec<V>[])` returns a
  reactive array of handles. The reconcile loop acquires entries for
  added specs, releases entries for removed specs, and re-uses cached
  handles for unchanged keys so caller-captured references stay stable.
- `UserDraft.use(kind, path, opts?)` becomes a 1-len wrapper around
  `useMany`. The spec getter is `untrack`ed so reactive opts
  (`$workspaceStore` etc.) are still captured-once — current `use()`
  semantics unchanged.
- `UserDraftHandle.release()` and the `manualRelease` option are gone.
  Component teardown is handled by a single internal `onDestroy` that
  releases every entry `useMany` acquired.

ResourceEditor + VariableEditor migrated:
- Replaced `Record<ws, Handle>` + manual `ensureHandle`/`release` with
  a `workspaceSpecs: $state<Array<{ws, defaultValue}>>` plus a
  derived `Record<ws, Handle>` that pairs each ws with its parallel
  handle from `useMany`. `ensureHandle(ws)` is now just a push to
  the specs array; `VariableEditor.reset()` clears it. The reconcile
  loop handles acquisition/release end-to-end.

Tests:
- Dropped the `manualRelease`/`release` test; the option no longer
  exists.
- Added a `useMany` test asserting per-spec entries, isolated
  workspace-scoped localStorage keys, and a single onDestroy
  registration covering every acquired entry.

Implementation note: I tried wrapping `useLocalStorageValue` in
`$effect.root` to give the entry's `$state`/`$effect` an independent
scope (in case `useMany`'s reconcile effect tore down nested effects
across cycles). But `$effect.root`'s callback wasn't running
synchronously in the test runtime (vitest + svelte-vite plugin), and
the original `use()` implementation called `useLocalStorageValue`
directly without issue. Reverted to the direct call; the
nested-scope concern stays theoretical.

* fix(frontend): isolate UserDraft entries via $effect.root

The previous commit landed `useMany` calling `useLocalStorageValue`
directly. That works for the `use()` 1-spec wrapper (whose getter is
untracked, so the reconcile `$effect` never re-runs), but for dynamic
specs (ResourceEditor / VariableEditor) it leaks the persist `$effect`
into the reconcile `$effect`'s scope — meaning the second spec change
would destroy the first entry's deep-mutation persist loop.

Wrap the `useLocalStorageValue` creation in `$effect.root` so the
entry's reactivity lives in its own scope. Stash the returned
disposer on the entry and invoke it when the refcount hits 0.

The vitest runtime's `$effect.root` returns its disposer but never
runs the callback (a test-env quirk, not a production behaviour).
Kept a documented fallback that calls `useLocalStorageValue` directly
when the callback doesn't populate `stateRef`. In tests that path
parents the persist `$effect` to the test scope and lives long
enough; in production `$effect.root` runs the callback synchronously
per the Svelte 5 spec and the fallback is unreachable.

* chore(frontend): drop leftover console.log in setDraftConfig

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(frontend): wire ?nodraft=true to actually skip the local autosave on /edit

The flows/apps/apps_raw `/edit` routes had a `?nodraft=true` handler
that just stripped the param from the URL via `afterNavigate` —
nothing behind it. The original pre-PR semantics (and what every
caller assumes) was "skip the localStorage autosave on this load."

Mirror the synchronous wipe pattern already in /add: when nodraft is
present, call `UserDraft.remove(kind, path)` and strip the flag from
the URL via `window.history.replaceState`, before the UserDraft handle
is created. The handle then reads an empty entry and the editor opens
on the backend version. A plain reload (no nodraft) restores the
autosave normally.

Removed the redundant `afterNavigate` blocks. Dropped the now-unused
`afterNavigate` import in all three; apps/edit still imports
`replaceState` (used downstream), so only that name stayed.

* feat(frontend): GC UserDraft entries older than 30 days

Without a sweep, a heavy user accumulates one localStorage entry per
(workspace, kind, path) they ever touched. The pre-PR single-key
autosave self-capped at one entry per editor; this one needs an
explicit GC pass.

Mechanism:
- Stamp every persist with `lastWrittenAt: Date.now()`. Added at four
  sites: `useLocalStorageValue`'s new `transformBeforePersist`
  option (covers both setter and deep-mutation persists),
  `UserDraft.save`'s no-handle fallback, `persistDirect` (force-meta
  writes), and the legacy migration. Done at persist time, not in
  `wrap()`, so deep mutations bump the clock too — `wrap()` runs only
  on `.draft =` assignments, which would leave the timestamp stale for
  bind-mutated editor sessions.
- `gcUserDrafts(maxAgeMs = 30d)` walks every `userdraft/w/...` key,
  removes the ones older than the cutoff. Entries written before this
  field existed (pre-PR or pre-this-commit) get backfilled with the
  current time on first sweep so a 30-day clock starts fresh; the
  alternative — sweeping on sight — would wipe work that the legacy
  migration just rescued.
- Wired into the logged-in layout: runs once on mount and every 30 min
  via `setInterval` (cleaned up in the effect's return).

Tests use `vi.setSystemTime` to drive the clock; assertions on the
stored payload now go through a `storedShape` helper that strips
`lastWrittenAt` before string-comparing, so the existing
`expect(...).toBe(wrapped(...))` style still reads cleanly. New tests
cover the sweep, the backfill behaviour, the default 30d window, and
a custom `maxAgeMs`.

* fix(frontend): break useMany reconcile feedback loop

The reconcile effect read `handles.length` / `handles[i]` for the
"unchanged?" early-exit optimisation and then `handles.splice(...)`
to publish the new array. Reading `handles` inside the effect
registered it as a dependency; the subsequent splice re-fired the
effect; ad infinitum (Svelte threw
`effect_update_depth_exceeded`).

Wrap the comparison reads in `untrack` so the effect's only
tracked dependency stays `getSpecs()`. The splice still fires the
downstream readers of `handles` (the whole point of `useMany`'s
reactivity); it just doesn't re-enter its own producer.

* fix(frontend): untrack the splice's own .length read in useMany reconcile

The previous fix wrapped only the comparison reads in `untrack`, but
`handles.splice(0, handles.length, ...next)` still reads `.length`
under the effect's tracking scope — same feedback loop, same
`effect_update_depth_exceeded`.

Move the whole "compare + splice" block inside `untrack`. The
downstream notification on splice still fires (untrack suppresses
dependency subscriptions on the producer side, not write
notifications), so consumers of `handles` still re-render.

* nit

* fix(frontend): drop in-memory handle before reloading after DB-draft discard

When the "Script/flow loaded from latest saved draft" toast's
"Reset to deployed" action ran, it:
1. Deleted the DB draft via DraftService.deleteDraft.
2. Called UserDraft.remove (clears localStorage only).
3. Called goto + loadScript / loadFlow.

But the handle's in-memory state still held the now-deleted DB draft
and its meta (remoteDraftRev pointing at the gone draft's created_at).
On the reload, the editor's loadScript/loadFlow saw `localDraft !=
undefined` and ran the staleness check, which compared
`meta.remoteDraftRev = <old timestamp>` against
`currentDraftRev = undefined`. Verdict: "version" stale → spurious
"A newer version was deployed on the server" modal, even though
nothing on the server actually moved. The editor visibly froze
behind the modal because the in-memory state wasn't refreshed.

Drop the in-memory state with `handle.setDraftAndMeta(undefined, {})`
before the reload — same fix already applied to the
"Restored from local storage > Reset to deployed" toast action.

apps/edit and apps_raw/edit's "discard draft" actions don't call
DraftService.deleteDraft (they just swap the in-memory view to the
deployed branch), so they don't hit this codepath.

* fix(frontend): drop in-memory handle in DiffDrawer restoreDraft/restoreDeployed

Same UserDraft.remove-without-clearing-in-memory bug as the previous
two commits, this time in the DiffDrawer's "Restore to draft" /
"Restore to deployed" buttons on all four /edit routes. The handler
deletes the DB draft (in the deployed case), wipes the localStorage
entry, navigates, and reloads — but the route's UserDraft handle
still holds the old draft + meta in memory, so the reload's
staleness check compares the stale meta against the freshly fetched
backend and surfaces a spurious "newer version was deployed" modal.

- scripts/edit, flows/edit, apps_raw/edit: route-level handle —
  `handle.setDraftAndMeta(undefined, {})` before the reload.
- apps/edit: the handle lives in the AppEditor child, so force a
  remount by setting `app = undefined; redraw++` before goto/loadApp
  (matches the existing pattern from the toast's onResetToDeployed).

* fix(frontend): legacy app migration matches actual stored shape

Legacy AppEditor wrote `encodeState($appStore)` — the inner App value
(grid/fullscreen/theme/unusedInlineScripts/hiddenInlineScripts), not the
wrapping AppWithLastVersion. The plausibility check was matching the
wrapping fields, so real legacy app entries were filtered out and never
migrated to the new userdraft/w/{ws}/app/{path} keys.

* fix(frontend): untrack meta-preservation reads in UserDraft setters

`set draft`, `setMeta`, `UserDraft.save`, and `UserDraft.saveMeta` all
read `state.val` before writing it (to preserve existing rev metadata).
When called from inside a `$effect` — as AppEditor does to mirror its
reactive `$state` into the handle — the read subscribes the effect to
the entry's `$state` cell that the write then mutates, producing an
`effect_update_depth_exceeded` loop. Wrap the reads in `untrack` so
mirrors don't self-trigger.

* fix(frontend): apps detect drift + restore on /apps/add reload

Two related issues in the app editor's UserDraft wiring:

1. Drift wasn't detected on first deploy/draft after starting an
   autosave. The route only backfilled meta on a reload that found a
   local diff — so the first external change after editing slipped
   through with empty `previousMeta`. AppEditor now receives the
   load-time revs as `initialRevs` and seeds them into the handle's
   meta on the first mirror, capturing the rev at autosave-creation
   time.

2. /apps/add didn't restore from LS on plain reload. The route
   always initialised `value` to `emptyApp()` and the AppEditor's
   `stateApp` captured the prop unconditionally, so the LS autosave
   was shadowed. `stateApp` now falls back to `appDraftHandle.draft`
   when present; the template/hub/import branches explicitly
   `UserDraft.remove('app', '')` to keep "start fresh from this
   content" semantics.

Also work around `useLocalStorageValue`'s `saveInitialValue: false`
skip slot — in the mirror pattern the slot survived past mount and
swallowed the user's first edit. Consume it up-front with a
wipe-then-restore pair so subsequent edits persist normally.

* feat(frontend): restored-from-local toast in resource/variable editors

Resource and variable editors silently loaded LS autosaves over the
backend value, leaving users with no signal that the form wasn't
reflecting deployed state. Both now fire the standard
`notifyRestoredFromLocal` toast (with a "Reset to deployed" action
that re-seeds the handle from the just-fetched backend) the first
time a lazy-fetch finds the local draft diverging from the remote.

* fix(frontend): add UserDraft.discard so "Reset to deployed" doesn't re-persist

The "Reset to deployed" toast action in resource/variable editors
called UserDraft.save with the backend value to repaint the form. That
left a duplicate-of-backend autosave in localStorage which would
silently restore on every subsequent reload, defeating the reset.

New UserDraft.discard(itemKind, path, fallback) clears LS AND resets
any live handle's in-memory state to the fallback, skipping the next
persist so the fallback doesn't round-trip back into storage. Backed
by a new `skipNextWriteOnce()` method on useLocalStorageValue's return.

* fix(frontend): use UserDraft.discard in apps reset flows

The apps editor route doesn't hold the UserDraft handle — AppEditor
(the child remounted by {#key redraw}) does. When a reset action ran
`UserDraft.remove` + `redraw++`, Svelte could mount the new AppEditor
before the old one's onDestroy released its handle, leaving the
entry's in-memory state.val populated with the stale autosave. The
new AppEditor would then re-acquire that entry and shadow the
just-emptied localStorage.

Switch every reset path (stale modal Load latest, restored-from-local
toast, DiffDrawer restoreDraft/restoreDeployed) to `UserDraft.discard`
so the in-memory cell is cleared synchronously alongside LS. Also
plumb `currentRevs` updates so the next mount's initialRevs reflects
the acked state.

* fix(frontend): /flows/add restores autosave on plain reload

`loadFlow()` initialised the local `flow` variable to `emptyFlow()`,
then passed it to `initFlow` which writes it to `flowStore.val` (=
`flowHandle.draft = flow`). On a bare /flows/add reload (no
template/hub/import/fork/urlHash) the assignment overwrote the
persisted autosave with the empty baseline. Seed `flow` from
`flowHandle.draft` instead, and keep `emptyFlow()` as the explicit
"start fresh" baseline for template/hub branches.

* nit rename

* fix(frontend): snapshot UserDraft proxy before structuredClone in resource save

`states[ws].draft` is now a Svelte $state proxy (it flows through
UserDraft's useLocalStorageValue cell). `structuredClone` can't clone a
proxy and threw "Failed to execute 'structuredClone' on 'Window'",
blocking resource saves. Snapshot to a plain object via
`$state.snapshot` before assigning the dirty baseline.

* fix(frontend): raw app deploy toast crash + harden Toast against bad type

RawAppEditorHeader's catch blocks called `sendUserToast(msg, e)`,
passing an Error as the `_type` arg. `classes[<Error>]` is undefined so
`color.descriptionClass` threw — and because the toast renders in the
root layout, it crashed the whole page on raw app deploy/create. Fixed
both call sites to the proper `(msg, true)` error form.

Also hardened Toast.svelte: coerce any non-AlertType `type` to 'error'
so a future miscall degrades to a plain error toast instead of taking
down the page.

* fix(frontend): /apps_raw/add restores autosave on plain reload

The route initialised files/runnables/data/summary to hardcoded
defaults, and the $effect mirror then wrote those defaults over the
persisted empty-path autosave. Seed the $state from
`draftHandle.draft` instead; import/template/hub branches
`UserDraft.remove('raw_app', '')` for explicit "start fresh"
semantics. Also consume useLocalStorageValue's saveInitialValue=false
skip slot (wipe-then-restore) so the user's first edit isn't dropped.

* feat(frontend): staleness modal in resource/variable editors

Resource/variable editors only showed the restored-from-local toast;
they never surfaced the staleness modal when the backend item moved on
since the local autosave was written. Wire LocalDraftStaleModal +
checkStaleness using the backend `edited_at` as `remoteRev` (these
items have no DB-draft concept). Meta is backfilled on reload for
legacy autosaves and seeded on the first real edit via a guarded
effect, so an external edit is detectable as drift. Per-workspace
detection; the modal is a singleton driven by `pendingStale`.

* feat(frontend): restored-from-local toast in standalone trigger editors

The schedule/postgres/http/kafka/websocket/email/sqs/nats/gcp/azure/
mqtt editors silently overlaid the local UserDraft autosave on top of
the backend config in `openEdit`, with no signal that the form wasn't
showing deployed state. Each now snapshots the just-loaded backend
config, then fires `notifyRestoredFromLocal` with a "Reset to
deployed" action that drops the LS entry and re-applies the snapshot.

* fix(frontend): trigger autosave no longer false-restores on plain open

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

* refactor(frontend): live UserDraft handle for trigger editors

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

* refactor(frontend): live UserDraft sync for raw app editors

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

* refactor(frontend): extract useTriggerDraftSync composable

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

* docs(frontend): trim rot-prone comments in UserDraft

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

* in /script, put code state in URL

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>
2026-05-20 14:58:26 +00:00
Ruben Fiszel 22ec4da5f0 tighten security from vuln report (#9264)
* fix: harden app preview S3, WM_ env reservation, set_progress scoping

* fixup: minimize #1 fix to single SQL-level filter

* fixup: apply WM_* filter to HTTP agent-worker branch + normalize app S3 scope path
2026-05-20 14:02:50 +00:00
Ruben Fiszel 2a780ad87a feat: resolve relative imports from local content in script/flow preview (#9233)
* feat: thread temp_script_refs into preview jobs

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

* feat: resolve python preview relative imports from temp script refs

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

* feat: use local relative imports in wmill script preview

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

* feat: use local relative imports in wmill flow preview

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

* feat: add temp_script_refs to Preview and FlowPreview openapi schemas

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

* fix: pass temp_script_refs to bun lockfile gen for no-lock preview

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

* refactor: route script preview through shared buildPreviewTempScriptRefs

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

* feat: resolve local relative imports in wmill app dev inline scripts

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

* fix: address cubic review — bundle cache key, preview-mode gate, error masking

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

* perf: skip dep-tree build when previewed script has no relative imports; narrow old-backend classifier

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

* fix: address review issues (preview-only gate, bundle preview, app dev cwd)

Three P1s flagged in repeated codex/pi reviews on PR #9233:

- Gate _TEMP_SCRIPT_REFS extraction on JobKind::Preview (bun + python
  executors) and propagation in worker_flow on JobKind::FlowPreview. job.args
  includes caller-controlled request args, so honoring this key on deployed
  runs would let a caller swap import resolution to local content uploaded
  via /raw_temp.
- run_bundle_preview_script now injects temp_script_refs into PushArgs.extra,
  mirroring run_preview_script — closes the silent data drop for the bundle
  preview path.
- wmill app dev chdirs to the wmill.yaml root before buildPreviewTempScriptRefs
  and restores after, so the `cd <app>__raw_app && wmill app dev` invocation
  (cwd is the raw_app folder, no app_folder arg) still walks sibling workspace
  scripts like f/lib.ts.

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

* chore(ee): bump ee-repo-ref to 5b347d6 (handle_python_deps arity fix)

Picks up the EE arity fix so cargo_test + check_ee_full compile cleanly.

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

* chore(ee): bump ee-repo-ref to 52e273d (agent-workers bundle path arity fix)

Picks up windmill-ee-private 52e273d which adds the missing &None arg to
compute_bundle_local_and_remote_path in windmill-api-agent-workers/src/ee.rs.

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

* chore(ee): bump ee-repo-ref to 2d6ffd3 (EE main merged in)

Previous bump pinned an older EE commit, missing the audit-log object-store
export module (EE PR #579, commit ec3cd35) and other EE main updates. The
CE backend's `crate::ee_oss::anchor_audit_logs_s3_checkpoint_env_var` and
`export_audit_logs_to_object_store` references need the new EE definitions.
Merged origin/main into the EE branch and pinned the merge commit.

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

* chore: update ee-repo-ref to b0c87b1272c25dca4aa9148c87fb024a9d9ef322

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

Previous ee-repo-ref: 2d6ffd32c99bd93e79cf78675cb89499a81b17e1

New ee-repo-ref: b0c87b1272c25dca4aa9148c87fb024a9d9ef322

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-20 12:44:29 +00:00
Ruben Fiszel 4b1bea8aed fix: enforce auth guards on app component preview execution (#9235)
* fix: enforce auth guards on app component preview execution

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

* fix: guard previewed runnable path and worker tag in app preview

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

* fix: validate app_script id ownership and keep root push isolation

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

* refactor: scope app preview guards to operator check + referenced runnables

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

* fix: require jobs:run scope and tag check on app preview (apps:run escalation)

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-19 17:40:55 +00:00
Ruben Fiszel 24eedef918 fix: constrain unauthenticated get_public_resource to app_theme resources (#9203)
* fix: constrain unauthenticated get_public_resource to app_theme resources

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

* test: remove get_public_resource regression test

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-17 14:36:51 +00:00
Ruben Fiszel dfeed9c5c2 fix: actionable error when a custom_path is taken by an app in another workspace (#9190)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:29:53 +00:00
Ruben Fiszel 5753516da5 read inline-script tag from app policy in run mode (#9005)
* fix: read inline-script tag from app policy in run mode

Previously the worker tag for app inline scripts was taken from the
client-supplied raw_code on every execute. End users running a deployed
app could intercept the request and submit any tag, redirecting the job
to an arbitrary worker group.

Persist the tag on PolicyTriggerableInputs at deploy time, and in run
mode read it from the policy instead of the request body. Preview mode
(editor-only) still honors the client tag, since the editing user is
already trusted by the policy check.

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

* fix: also reject client tag in legacy run-mode (no app_script id)

The previous commit only enforced policy-tag in the id-bearing arm.
Apps deployed before the lockfile/app_script entry exists hit the
\`(None, Some(raw_code), None)\` arm in run mode (triggerable keyed by
\`rawscript/<sha>\`), where client tag was still trusted.

Hoist an \`is_preview\` flag from the outer match and route both inline
arms through it: client tag is honored only in preview mode.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-01 17:50:09 +00:00
Ruben Fiszel 0c22f52b46 feat: support assigning a worker tag to app inline scripts (#9002)
* feat: support assigning a worker tag to app/raw-app inline scripts

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

* fix: omit empty tag field from inline script raw_code payload

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

* style: shrink tag popover width

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-01 17:21:17 +00:00
Ruben Fiszel 1169d9bfd3 feat: add delete_after_secs and sensitive_inputs for raw app runnables (#8975)
* feat: add delete_after_secs and sensitive_inputs to raw app policy

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

* chore: simplify sensitive toggle label

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

* chore: use tertiary text for sensitive toggle label

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

* chore: unset sensitive field when toggled off

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

* fix: address PR review feedback

- plumb force_viewer_sensitive_inputs/delete_after_secs so editor preview
  matches deployed-mode encryption
- reuse resolve_delete_after_secs helper for consistency with scripts/flows
- log+ignore schedule_job_deletion errors so a failed schedule doesn't
  surface as an execute_component failure
- fix text-primay typo in CacheTtlPopup and DeleteAfterUsePopup
- tighten extraFields return type to Partial<Pick<...>>

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-04-29 17:15:03 +00:00
Ruben Fiszel 5b3913052e refactor: convert read-hot globals to AtomicBool/I64 and ArcSwap (#8815)
* refactor: extract load helpers from reload_setting family

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

* refactor: convert atomic primitive globals to AtomicBool/AtomicI64

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

* refactor: convert CRITICAL_*/HUB_API_SECRET/INSTANCE_EVENTS_WEBHOOK/JWT_SECRET to ArcSwap

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

* chore: pin ee-repo-ref to arcswap-refactor EE branch commit

* refactor: convert BASE_URL/HUB_BASE_URL/MIN_VERSION/LICENSE_KEY*/LICENSE_KEY_ID to ArcSwap

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

* refactor: convert worker hot-path globals to ArcSwap (WORKER_CONFIG et al)

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

* chore: pin ee-repo-ref to combined arcswap-urls+worker EE commit

* chore: update ee-repo-ref to d8be8f88cb8898c8f6b27421989d53528223815d

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

Previous ee-repo-ref: c375aaaac9ec0fc0480993627d0defc8054c31a4

New ee-repo-ref: d8be8f88cb8898c8f6b27421989d53528223815d

Automated by sync-ee-ref workflow.

* fix: cleanup unused imports + fix 2 missed WORKER_CONFIG readers

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

* chore: update ee-repo-ref to ce0f8fbbbde09c4a858312d2d8716d224e99042c

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

Previous ee-repo-ref: 450b601b5aba0ca0b2045f4b5071aa8701b4bfb7

New ee-repo-ref: ce0f8fbbbde09c4a858312d2d8716d224e99042c

Automated by sync-ee-ref workflow.

* fix: secret_backend_integration test — BASE_URL.write().await → .store()

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

* refactor: convert APP_WORKSPACED_ROUTE to AtomicBool for symmetry with HTTP_ROUTE_WORKSPACED_ROUTE

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

* chore: update ee-repo-ref to e587df8 (post-#535 merge)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-14 00:04:10 +00:00
Ruben Fiszel 64c58c824f feat: add deploy restriction rule and fork review requests (#8804)
* feat: add deploy restriction rule and fork review requests

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

* chore: update ee-repo-ref.txt for fork review requests

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

* fix: address PR review comments on fork review requests

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

* refactor: rename fork review requests to deployment requests

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

* chore: update ee-repo-ref.txt for deployment request rename

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

* refactor: inline deployment request panel into deploy layout

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

* fix: place Request deployment button to the left of Deploy

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

* refactor: inline fork triggers into main deploy list

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

* refactor: open real trigger detail drawer for inline fork triggers

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

* feat: email notifications for merge completion and reply pings

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

* fix: update deployment_request + protection_rule tables on workspace id rename

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

* chore: update ee-repo-ref to 972893c3870e4c4a70a35748abed282d88904805

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

Previous ee-repo-ref: 5684d1c17d930b17849c1e5d7577891e64682d45

New ee-repo-ref: 972893c3870e4c4a70a35748abed282d88904805

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-04-13 17:10:37 +00:00
Ruben Fiszel 60211c1d19 feat: folder default_permissioned_as rules for ownership defaults on deploy (#8801)
* feat: add folder default_permissioned_as rules for ownership defaults on deploy

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

* fix: remove unnecessary auth guard on default_permissioned_as — rules are advisory only

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

* chore: regenerate system prompts with new CLI commands

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

* fix: address CI review findings — TOCTOU, race condition, email validation, type coercion

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

* fix: add sqlx offline cache for test queries (fixes cargo_test CI)

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

* fix: address remaining review findings — incomplete request bodies, dead code, redundant import

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

* fix: address remaining review findings — full script fields, reactive stores, catch-all validation

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

* fix: app/schedule/trigger set-permissioned-as fetch remote first to avoid data loss

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

* fix: app set-permissioned-as avoid creating redundant app version

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

* feat: compact user/group toggle + select for folder default_permissioned_as rules

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

* feat: collapse default_permissioned_as section by default in folder editor

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

* feat: include default_permissioned_as in FolderFile CLI type for YAML round-trip

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

* fix: process folder.meta changes before items in push to apply new rules immediately

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

* fix: clone default_permissioned_as on fork/rename + add full lifecycle tests

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

* test: add no-op guarantee test — folder without rules behaves like before

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

* refactor: rename cliBehavior to syncBehavior — more accurate scope

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:14:07 +00:00
Ruben Fiszel c4c9ef5fd7 feat: add optional labels to scripts, flows, apps, schedules, triggers (#8609)
* feat: add optional labels to scripts, flows, apps, raw apps, schedules, and triggers

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

* fix: update sqlx cache, make labels optional in openapi, regenerate system prompts

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

* feat: add minimal labels input UI to script, flow, and schedule editors

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

* fix: reduce gap between summary and labels input

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

* feat: add labels to script/flow detail pages and summary/path popover

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

* fix: move labels inside SummaryPathDisplay trigger for clickable area, reduce gap

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

* fix: display labels inline to the right of summary, not below

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

* fix: increase gap between summary and labels

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

* feat: add labels to resources/variables, make labels nullable, add home page label filter badges

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

* feat: add labels to workspace export/import, resources, variables + test coverage

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

* fix: make migration idempotent, regenerate sqlx cache after merge

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

* fix: pass labels in script create and flow create/update API calls

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

* feat: add labels input UI to resource and variable editors

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

* fix: remove negative margin from LabelsInput to prevent overlap

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

* fix: add top and left margin to LabelsInput for better spacing

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

* fix: reduce left margin on LabelsInput

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

* fix: widen label input to w-32

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

* fix: use inline-flex so LabelsInput doesn't stretch full width

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

* fix: remove flex-wrap so label input stays on same line as badges

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

* feat: add label filter presets to resources, variables, and schedules search

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

* fix: use max-w-32 on label input to prevent stretching

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

* fix: pull labels closer to summary with negative top margin

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

* fix: increase negative margin to pull labels even closer to summary

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

* fix: pass labels in schedule create/update API calls

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

* fix: use COALESCE to preserve existing labels when not provided in schedule/flow update

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

* fix: add labels to CreateResource, EditResource, CreateVariable, EditVariable in OpenAPI spec

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

* feat: display label badges on resource and variable list pages

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

* feat: display label badges on schedule and all trigger list pages

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

* feat: add folder and label presets to schedules search filter

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

* fix: apply user_folders_only filter on all workspaces including admins

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

* feat: add label presets to resources and variables search filters

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

* fix: derive folder presets from loaded items, not all workspace folders

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

* fix: add label query parameter to resource and variable list endpoints in OpenAPI

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

* feat: display label filter badges inline with folder filters on home page

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

* Revert "feat: display label filter badges inline with folder filters on home page"

This reverts commit 6767a50aa6.

* feat: support comma-separated label filters (allowMultiple) in all list endpoints

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

* fix: append label presets with comma for allowMultiple filters instead of duplicating key

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

* fix: hide label presets that are already in the comma-separated filter value

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

* fix: replace unsafe manual SQL ARRAY construction with parameterized queries, add labels to ScriptWDraft

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

* fix: complete down migration, add labels to Resource/Variable OpenAPI schemas, remove type cast, add label length validation

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

* fix: add labels field to Schedule test fixture

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

* fix: add labels field to Rust client struct constructions

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

* fix: regenerate sqlx cache with --all-features for EE builds

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

* chore: regenerate sqlx cache and package-lock after merge with main

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

* fix: squash two migrations into one, use IF NOT EXISTS for idempotency

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

* fix: track label changes in SummaryPathDisplay to enable save button

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

* fix: use JSON string comparison for label dirty tracking in popover

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

* fix: navigate to script by path after save from popover to load new version

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

* fix: update initialLabels after save so subsequent label changes enable save again

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

* fix: use onchange callback for label dirty tracking instead of derived comparison

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

* fix: reload script by path after label save to fetch new version

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

* feat: propagate script/flow labels to jobs at push time

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

* feat: show script/flow labels on runs page, merge with wm_labels for completed jobs

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

* fix: change job labels type from JSONB to text[], show labels on job detail page, fix type mismatch

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

* fix: add labels to QueuedJob struct, fix get_job queries to return v2_job.labels

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

* fix: replace +Label text with icon only

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

* feat: add tag icon before labels on job detail page

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

* fix: move tag icon inside badge on job detail page

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

* fix: use blue badge with tag icon in RunBadges, remove duplicate labels from JobDetailHeader

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

* fix: set icon position to left so tag icon renders in badge

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

* fix: render Tag icon inline in badge children instead of via icon prop

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

* fix: retry icon prop with small badge and position left

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

* feat: add hover tooltip showing "Label: X" on job label badges

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

* feat: include v2_job.labels in runs page label filter and broad search

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

* chore: regenerate sqlx cache and system prompts after merge with main

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

* fix: add labels to EE JobPayload constructions, regenerate sqlx cache with --all-features

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

* fix: regenerate sqlx cache CE-only (without EE symlinks that cause conflicts)

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

* fix: update remaining wm_labels JSONB queries to use text[] merge expression

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

* fix: simplify job labels to just read v2_job.labels (wm_labels already merged at completion)

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

* fix: consistent label badge spacing with gap-0.5 wrapper and px-0.5 on badges

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

* fix: add labels: None to test utils JobPayload construction

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

* fix: add labels to all test fixture JobPayload/NewFlow/EditApp constructions, regenerate sqlx cache

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

* fix: fix vertical content shift by fixing container and input height to h-5

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

* fix: npm_check errors - unused imports, combinedItems order, flow.labels type, badge px-1 padding

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

* fix: remove unused FolderService imports, fix label badge alignment in RunBadges

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

* fix: restore deleted service imports in variables page, remove empty loadFolders

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

* chore: trigger CI with updated ee-repo-ref

* chore: update ee-repo-ref to merged EE companion PR

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

* chore: trigger fresh CI run for updated ee-repo-ref

* fix: match label badge size with other badges in RunBadges using {large} prop

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

* fix: remove icon from RunBadges label badge to fix vertical alignment

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

* fix: shorten "Job kind" to "Kind" in run badges

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

* fix: add small inline tag icon (10px, -mt-px) to label badge without disrupting height

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

* fix: add "Label: X" hover tooltip to all label badges, show hidden labels on +N hover

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

* feat: add tag icon and "Label: X" tooltip to home page label filter badges

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

* fix: show LabelsInput even when path is hidden in ResourceEditor

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

* feat: add labels input to new resource creation drawer (AppConnectInner)

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

* iterate

* fix: add LabelsInput to all resource creation steps in AppConnectInner

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

* fix: reduce LabelsInput top margin from -mt-3 to -mt-1

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

* fix: increase negative margin to -mt-2 for tighter spacing

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

* fix: split the difference with -mt-1.5

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

* fix: adjust to -mt-1 for label spacing

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

* fix: per-site label spacing via class prop instead of global negative margin

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

* feat: make label badges clickable to toggle label filter on resources, variables, schedules

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

* fix: use proper array indexOf for label filter toggle, set undefined correctly on removal

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

* fix: use delete instead of undefined to properly clear label filter

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

* feat: add /labels/list endpoint and autocomplete dropdown to LabelsInput

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

* fix: use inline preventDefault for Svelte 5 event handling

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

* feat: add "Create new" option in label autocomplete, regenerate sqlx cache with update_sqlx.sh

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

* feat: add GIN indexes on labels column for all 16 tables

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

* fix: remove CONCURRENTLY from GIN index creation in migration

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

* test: add comprehensive label coverage for pull, edit, removal across all item types

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

* fix: simplify job label filters to only use v2_job.labels, remove wm_labels back-compat

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

* test: add integration tests for job label propagation, display, and filtering

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

* fix: address PR review findings — missing labels in fetch_script_for_update, app rename, escape key bug

- Add `labels` to SELECT in `fetch_script_for_update` to prevent lost labels on script clone
- Pass `labels` in app branch of `moveRenameManager.ts` so app renames preserve labels
- Clear `inputValue` before `adding = false` in LabelsInput escape handler to prevent accidental label add via onblur
- Fix `test_job_label_filter` to complete jobs via SQL (label filtering only works on completed jobs)
- Add `test_wm_labels_from_result_merged_with_static_labels` integration test using Bun

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 17:39:32 +00:00
Ruben Fiszel cf30bcf3f9 5x request size limit for raw app bundle uploads (#8640)
* feat: 5x request size limit for raw app bundle uploads

Raw app bundle endpoints (create_raw, update_raw) now get 5x the
configured request size limit. Also improves error messages when
multipart uploads exceed the limit to include the actual limit
and mention it's adjustable in instance settings.

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

* fix: mention size limit as possible cause, not definitive

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 11:14:20 +00:00
Ruben Fiszel 0389d9601c chore: upgrade axum 0.7 to 0.8 (#8539)
* chore: upgrade axum 0.7 to 0.8 and related dependencies

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

* test: add route reachability tests for ~80 previously untested endpoints

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

* fix: switch feature-gated trigger handlers from axum::async_trait to async_trait crate

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

* fix: update new trash routes to axum 0.8 path syntax

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

* chore: update ee-repo-ref to latest EE commit

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

* test: upgrade route tests to assert 2xx responses with proper data setup

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

* test: restore npm_proxy and ai_routes tests using local echo servers

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

* fix: gate workspace fork test behind enterprise feature flag

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

* test: add ~40 more endpoint tests (jobs authed, health, favorites, ACLs, reachability)

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

* fix: address review findings from axum 0.8 upgrade

- Use cookie value_trimmed() instead of value() for cookie 0.18 compat
- Update comments still referencing old :workspace_id syntax

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

* chore: update ee-repo-ref

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

* chore: update ee-repo-ref to 61ae055ea31481f1899953e9d5f65566b8c707b1

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

Previous ee-repo-ref: 0059d175a6fdddf52998b183bf91059b224704ac

New ee-repo-ref: 61ae055ea31481f1899953e9d5f65566b8c707b1

Automated by sync-ee-ref workflow.

* test: add test for new get_imports endpoint

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

* fix: remove unused import in raw_apps test

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-27 09:55:04 +00:00
Ruben Fiszel 69ce946241 feat: add trashbin system for soft-deleting items (#8519) 2026-03-26 09:51:34 +00:00
wendrul 34e3115bcb fix: raw apps bundle not found during deployment error (#8515) 2026-03-25 10:59:48 +00:00
Diego Imbert aa30fd252d fix: Move database manager SQL queries to backend (#8306)
* SQL Query builders in Rust

* Remove frontend sql scripts and substitute at execution

* fix null value bug

* Handle WM_INTERNAL_DB marker for apps deployed prior

* Revert policy handling

* Fix database studio empty string as where clause

* check policy

* Revert "check policy"

This reverts commit 3ea7899979.

* Revert "Fix database studio empty string as where clause"

This reverts commit 432fc87915.

* Revert

* legacy comments

* Move DDL queries to backend

* tests

* move bigquery bun scripts to backend

* expand markers + other nits

* fix: escape sql literals in query builders and async preview sql

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

* fix: quote all user-supplied identifiers in query builders to prevent SQL injection

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

* fix: suppress dead_code warnings for deserialization-only fields and test-only helpers

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

* fix: correct DDL test assertions and drop_table schema handling for non-schema DBs

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

* MySQL fix

* Fix 0/1 bool

* MySQL fix Yes/No casing

* Better error toasts

* Fix ms sql ntext cast

* fix: quote table name in Snowflake SHOW PRIMARY KEYS query

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

* fix: quote schema and table in Snowflake SHOW IMPORTED KEYS query

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

* fix: quote BigQuery dataset name in metadata query

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

* fix: remove invalid + separator in MSSQL CONCAT for count query

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-24 09:45:37 +00:00