Commit Graph

1719 Commits

Author SHA1 Message Date
Ruben Fiszel 0692b97c8a fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path (#9276)
* fix(ai): enforce RLS and scope check on user-supplied X-Resource-Path

The AI proxy handler accepts an X-Resource-Path header to override the
configured workspace AI provider. When supplied, the handler loaded the
resource value from the resource table using the root DB pool with no
resources:read scope check, so any authenticated workspace user could
point X-Resource-Path at a restricted AI resource (e.g. one in a folder
they cannot read) and the proxy would use that resource's provider
credentials for the outbound AI request.

For user-supplied resource paths, now require resources:read:{path}
scope and fetch the resource through user_db.begin(&authed) so RLS
enforces the same folder/group boundary as the resource API. The RLS-
scoped $var: resolution stays in place as defense in depth. The
admin-configured workspace/instance ai_config path is unchanged.

Fixes WIN-1971

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

* test(ai): regression test for X-Resource-Path RLS enforcement

Cover all four cases:
- non-admin pointing X-Resource-Path at a restricted resource is rejected
- non-admin pointing it at a resource they own still works
- admin can point it at any resource
- workspace-configured proxy flow (no X-Resource-Path) is unchanged

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-21 15:21:27 +00:00
hugocasa 2db1c0a1fc fix: early return should consider failure_module result (#9241) 2026-05-20 15:58:05 +00:00
centdix 289549a048 refactor: move google ai proxy handling to windmill-ai (#9260)
* refactor: add ai proxy execution mode

* refactor: move google ai proxy handling

* refactor: share google ai request building
2026-05-20 15:24:07 +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 bb78b1c06d fix(s3): sandbox stored XSS via download response headers (#9263)
* [ee] fix(s3): sandbox stored XSS via download response headers

Reported chain: a workspace user uploads xss.html via
apps_u/upload_s3_file with content_type=text/html&content_disposition=inline;
when an admin clicks the resulting download URL the browser renders
the attacker page in Windmill's origin and can escalate via the
SameSite=Lax session cookie.

Fix on the download side only — leaves upload semantics unchanged so
existing integrations are not affected:

- download_s3_file_internal (used by apps_u/download_s3_file and
  job_helpers/download_s3_file) emits X-Content-Type-Options: nosniff
  and Content-Security-Policy: sandbox on every response (EE).
- The HTTP static-asset trigger emits the same headers on single-file
  responses. Static-website responses keep their existing semantics
  (CSP sandbox would break a legitimate static site); restricting
  write access to those buckets remains the documented mitigation.

Sandbox loads any HTML/SVG into an opaque origin so the page cannot
reach the viewer's cookie or /api/*. Images, PDFs, and fetch-driven
previews are unaffected (browsers ignore CSP for <img>/<embed> and
for fetch responses).

Companion: windmill-ee-private fix/s3-content-type-xss.

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

* chore: update ee-repo-ref to daffe7bb81cfcaca666c61de1ee838a44d60ebc2

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

Previous ee-repo-ref: e889b86ee1c68c2f7cf9b07ec4b8ba6e6b66a169

New ee-repo-ref: daffe7bb81cfcaca666c61de1ee838a44d60ebc2

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 13:59:39 +00:00
Diego Imbert 9c28bbfd69 feat(frontend): new path component (#9017)
* stash

* ui nits

* Fix contenteditable feedback look (duplicate typing)

* fix right icon wrong position with placeholder

* user editor in Path editor takes correct width

* nits

* nit

* chore: remove assets-operator changes (moved to separate PR)

These files were mistakenly included in this PR and belong in a dedicated PR
("Allow assets page to operators").

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

* chore: remove sidebar assets-operator change (moved to separate PR)

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

* fix disabled

* border nit

* Fix disabled styling

* Apply suggestion from @cubic-dev-ai[bot]

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* nit

* Update frontend/src/lib/components/text_input/TextInput.svelte

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Fix disabled tabindex and aria-disabled on contenteditable Select

The useContentEditable branch had an unconditional tabindex="0", keeping
a disabled Select in the tab order, and was missing aria-disabled.
Mirror the TextInput div branch.

Co-authored-by: Diego Imbert <diegoimbert@users.noreply.github.com>

* fix: drop obsolete hideFullPath prop from EditorHeader Path usage

* invalidate autocomplete paths on deploy

* nit pixel

* use Badge in auto complete

* nit prevent default

* fix(autocomplete): don't let stale fetch clobber forced refresh

A non-forced fetchWorkspacePaths() that started before invalidateWorkspacePaths()
could still resolve afterward, overwrite the cache, and clear forceNextFetch —
making the post-deploy refresh a no-op. Only write back from the promise that
is still the current pending one, and only clear the force flag when the
completing fetch was itself forced.

* refactor(path): drop unreachable 'group' branch in owner-kind setter

The Select only offers user/folder, so the 'group' branch was dead. Leave a
short note pointing at validateName which still accepts 'group' for
forward-compat.

* fix(path): respect disableEditing on owner-kind selector

Other path-editor controls disable on (disabled || disableEditing); the
owner-kind Select only checked `disabled`, so read-only users (trigger
editors with !can_write) could still toggle User/Folder and mutate the
bound path. Reuse the existing nameDisabled flag.

* Revert "fix(autocomplete): don't let stale fetch clobber forced refresh"

This reverts commit 6649975714.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.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 13:26:34 +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
centdix 55dcee2424 refactor: move anthropic proxy building (#9238) 2026-05-19 14:23:31 +00:00
Ruben Fiszel ad5ec293b5 fix: reject path traversal in MCP endpoint path parameters (#9211)
* fix: reject path traversal in MCP endpoint path parameters

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

* fix: narrow MCP path-param validator to structural escapes only

Codex review: rejecting whitespace/`:`/`@` regressed legitimate
Windmill paths (app paths with spaces, email-style usernames like
u/admin@windmill.dev/...). These are ordinary path-segment data in an
absolute URL and cannot redirect the request. Reject only structural
escapes: control chars, `\`, `%`, `?`, `#`, and `.`/`..`/empty segments.

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-18 23:33:39 +00:00
centdix bd32c5f951 refactor: move openai-compatible proxy building (#9133)
* refactor: introduce ai proxy request types

* refactor: move openai-compatible proxy building
2026-05-18 10:24:16 +00:00
Ruben Fiszel bd05bcadde fix: validate entrypoint override to prevent worker code injection (GHSA-wxjq-w5pj-jqhx) (#9204)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:01:05 +00:00
Ruben Fiszel 664edcdfb7 fix: enforce jobs:run scope on job preview and inline endpoints (#9198)
* fix: enforce jobs:run scope on job preview and inline endpoints

Preview/inline endpoints (run/preview, run/preview_bundle, run/preview_flow,
run/dynamic_select inline) execute arbitrary request-supplied code but only
checked folder/namespace read access, which is a no-op when path is null. A
token scoped to a specific script/flow could escape its scope and run any
code. Add a jobs:run scope check, matching other arbitrary-execution
endpoints. Advisory GHSA-vxc5-w28p-m9xw.

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

* fix: scope-check dynamic_select flow branch and inline preview

Address CI review: the dynamic_select Deployed{Flow} branch ran a deployed
flow's dynamic-select code without any scope check (only the Script branch
delegated to a scope-checked handler), and run_inline_preview_script executed
request-supplied code with no in-handler scope check. Add jobs:run:flows:{path}
to the flow branch and jobs:run to inline preview; correct the misleading
comment. Expand regression tests (preview_flow case, assert success for the
broad-token case). Advisory GHSA-vxc5-w28p-m9xw.

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

* test: remove preview scope enforcement test after local validation

The regression test passed locally (3/3) and validated the fix end-to-end;
removed from the PR per maintainer preference. Advisory GHSA-vxc5-w28p-m9xw.

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:57:24 +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 ab11c7747a fix: enforce folder ACL on flow run-by-version routes (#9202)
* fix: enforce folder ACL on flow run-by-version routes (GHSA-8mv7-hmrg-96xv)

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

* fix: don't echo resolved flow path in version-route NotAuthorized (cubic P2)

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

* chore: remove GHSA-8mv7-hmrg-96xv regression test (verified locally pre-removal)

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:24 +00:00
Ruben Fiszel 8bc2295b94 fix(mcp): validate oauth dynamic client registration redirect_uris (#9197)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:00:06 +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 5e909b2b4f feat(git-sync): sync extra_perms for flows/scripts/apps (#9162)
* feat(git-sync): sync extra_perms for flows/scripts/apps

* fix(git-sync): hash extra_perms, fix script no-op + up-to-date checks

* refactor(git-sync): route extra_perms through /acls/* instead of update endpoints

* fix(git-sync): dispatch raw_app perm changes via DeployedObject::RawApp

* fix(git-sync): wire applyExtraPermsDiff into pushRawApp + per-change logs

* feat(git-sync): opt-in tarball perms, no-op when yaml omits, audit logs, validation

* fix(git-sync): replace remaining bool literal in EE-only trigger export call

* fix(git-sync): raw_app /acls/* hits app table; refetch after create

* fix(git-sync): drop unnecessary post-deploy refetch (folder perms not merged)

* fix(git-sync): raw_app /acls/get; reject malformed local extra_perms

* audit: distinct raw_apps prefix so dashboards can split raw_app ACL events
2026-05-14 08:16:24 +00:00
Ruben Fiszel d870edc959 fix(mcp): sanitize and enrich nested resource schemas (#9158)
* fix(mcp): sanitize and enrich nested resource schemas

* fix(mcp): strip Windmill-internal keys inline during resource enrichment

* fix(mcp): strip resourceType unconditionally in schema sanitizer
2026-05-14 06:54:32 +00:00
Ruben Fiszel d666e8431c feat: read-only flag on API tokens (#9144)
* feat: read-only flag on API tokens, orthogonal to scopes

Add a per-token `read_only` boolean set at creation time. When true, the
token can only call HTTP methods classified as Read (GET/HEAD/OPTIONS).
Mutating methods and job-run actions are rejected with 403, regardless of
which scopes are attached. Surfaced as a prominent toggle in the standard
token-creation flow and a discreet `2xs` toggle in MCP mode (where users
often want write access, so we don't bias them toward enabling it).

MCP enforcement: read-only tokens hide all script/flow/hub tools from
`list_tools` and only see endpoint tools whose method is GET, and the
runner rejects `call_tool` on anything mutating.

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

* fix: review fixes for read-only token flag

- Exempt /api/mcp/* and /mcp/* paths from the read-only middleware check.
  MCP transport runs over POST (streamable HTTP / SSE), so otherwise the
  middleware would 403 every MCP request before the runner could enforce
  read-only at the tool-call level.
- Tighten is_endpoint_read_only to GET only, matching the read_only_hint
  that create_endpoint_annotations actually emits.
- Add unit test for check_read_only_for_route covering GET/HEAD/OPTIONS,
  mutating methods, and run paths.

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

* chore: bump ee-repo-ref to read-only-trigger-toggle

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

* fix(frontend): make read-only toggle discreet in both modes

Match the MCP-mode treatment in standard mode: text-tertiary, 2xs, shared
"Read-only" label. The tooltip switches per mode so the explanation still
fits the context.

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

* fix(frontend): gate read-only toggle behind Limit token permissions

The read-only toggle now only shows when the user has limited the token's
scopes (standard mode) or in MCP mode (which always picks an MCP scope).
Turning the limit off also resets read-only so it doesn't silently stick.

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

* feat(frontend): hide incompatible MCP tools when read-only is on

When the read-only toggle is on in MCP mode:
- Endpoint badges and the custom-mode endpoint MultiSelect filter to GET.
- Already-selected non-GET endpoints are pruned from the scope.
- The scripts/flows preview is replaced with a note explaining they're
  hidden (the runner already rejects script/flow runs for read-only).

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

* fix(frontend): place read-only toggle at top of limited scope area

The previous gate required at least one scope to be picked before the
read-only toggle appeared, which made it look missing while the user was
still building their scope list. Move the toggle inside ScopesPicker:
- Standard mode: sits directly under the "Limit token permissions" toggle
  whenever Limit is on, before the scope selector.
- MCP mode: sits at the top of the MCP scope block.

readOnly is now $bindable on ScopesPicker so CreateToken still owns the
value. The auto-reset on un-limit moves into ScopesPicker too.

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

* fix(frontend): nest read-only toggle inside the scope list card

Place the read-only toggle at the top of the scope list (between the
Selected Scopes summary and the bordered domain list) via a new optional
topSlot snippet on ScopeSelector. Keeps ScopeSelector decoupled from
read-only specifics; ScopesPicker fills the slot.

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

* chore: update ee-repo-ref to 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a

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

Previous ee-repo-ref: f53d26e6685dfd60bfa67686fbd7358169cfd130

New ee-repo-ref: 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a

Automated by sync-ee-ref workflow.

* fix: address CI review for read-only token flag

- P1 (Codex): narrow the MCP middleware exemption from "any /api/mcp/*"
  to just the streamable HTTP transport endpoints
  (/api/mcp/gateway, /api/mcp/w/{ws}/{mcp,sse,list_tools}). Without this,
  a read-only token could POST /api/mcp/gateway/oauth/server/approve and
  mint a follow-on non-read-only MCP token via the OAuth code/token
  exchange.
- P2 (Claude/cubic): fix test comment/assertion mismatch — the run-path
  assertion now exercises GET (which is what the RUN_PATH_ACTIONS
  elevation comment describes) in addition to POST. Add a regression
  assertion for /api/mcp/gateway/oauth/server/approve.
- P2 (cubic): short-circuit script/flow/hub-script/resource fetches in
  MCP list_tools when read_only is on — they would only be discarded
  below, so skipping the DB and resource fan-out is pure win.
- P2 (cubic): when scopes are pre-supplied via the CreateToken prop, the
  ScopesPicker isn't rendered, which previously hid the read-only
  toggle entirely. Render it next to the pre-supplied scopes display.

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

---------

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-13 15:26:21 +00:00
hugocasa 9c6cd8c852 offline (URL-bound) license keys (#9089)
* [ee] feat(license): offline (URL-bound) license keys

Offline keys are a 4-segment variant for air-gapped customers — no
phone-home, embedded seat/CU caps, locked to the instance's base_url.
Existing 3-segment online keys are unchanged.

Companion PRs:
- windmill-labs/windmill-ee-private (full design + EE impl)
- windmill-labs/windmill-customer-service (issuance + portal)
- windmill-labs/windmill-cf-worker-keygen (signing)

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

* [ee] refactor(license): bind offline keys via instance hash; simpler CU enforcement

- /settings/license_status now surfaces an `instance_hash` superadmins share
  with support when requesting an offline key
- OfflineMetadata: `hash` replaces `base_url`; OfflineCapStatus reports
  `current_cu` (last 2min) and drops the grace-period fields
- verify_license_key now takes a db so EE can recheck the hash
- InstanceSetting.svelte: hash copy-block + simpler status panel
- Bump ee-repo-ref

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

* [ee] chore(license): bump ee-repo-ref

Pulls in the current_cu clamp + prod public key restoration.

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

* [ee] refactor(license): split instance_hash endpoint; minimal cap UI; restore workers expiry toast

- `instance_hash` is no longer part of /settings/license_status responses; it
  lives at GET /settings/instance_hash (super-admin only) so it isn't re-emitted
  on every status poll. The UI doesn't show it — admins fetch it explicitly when
  requesting a key from support.
- InstanceSetting offline cap UI is now two compact green/red status lines
  (Seats X.X/Y and CUs X.X/Y) placed above the action buttons, matching the
  existing "Latest key renewal" badge style. The block-panel is gone.
- "Latest key renewal" line and the "Renew key" button are now hidden when an
  offline key is loaded (renewal is server-disabled for offline keys).
- Restore parseLicenseKey + checkLicenseExpiration toast on /workers
  (works for both 3- and 4-segment keys).

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

* [ee] chore(license): bump ee-repo-ref

Pulls in the plain-SHA256 instance hash + stats_ee revert.

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

* [ee] chore(license): bump ee-repo-ref

Picks up the alert wording change.

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

* [ee] chore(license): bump ee-repo-ref

Picks up the instance_uid cache so the periodic verify_license_key cycle
no longer hits global_settings.

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

* [ee] refactor(license): rename /settings/license_status → /offline_license_status

The endpoint was only used by the offline-license UI; the other fields it
returned (license_key_id, license_key_valid, kind, offline metadata) were
unused. Rename to clarify scope and flatten the response — it now returns
just the OfflineCapStatus (or null when no offline license is loaded).

Frontend uses `offlineCapStatus != null` as the "is offline" check.

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

* [ee] fix(ci): regenerate sqlx cache for the inline worker_ping query

After reverting unused stats_ee helpers (fetch_worker_pings*), the
inline `sqlx::query_as!(WorkerPingRecord, ...)` in get_stats_payload
lost its cache entry — CI's check_ee_full + cargo_test were failing
under SQLX_OFFLINE=true with E0282 type-inference errors.

Re-running update_sqlx.sh regenerates the cache file under its
current hash and prunes a couple of stale entries.

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

* [ee] fix(license): address cubic-bot review

- get_offline_license_status: propagate enforce_offline_caps errors as 500
  instead of swallowing into a "no offline license" (Option::None) response
- canonical_base_url: rewrite the doc to match the actual fallback behavior
  (lowercase + trailing-slash strip on URL parse failure); the original
  cross-service contract is gone since the customer-service no longer
  canonicalizes (treats the instance hash as opaque)
- check_seat_cap_for_new_user: take an email and short-circuit when the
  email is already in `usr ∪ workspace_invite` so net-zero invite upserts
  and invite→user transitions aren't spuriously blocked at cap. Mirrors
  the dedup rule the count itself uses.
- Bump ee-repo-ref to pull in the EE-side change

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

* [ee] chore(license): bump ee-repo-ref

Picks up the exact-delta seat-cap check (replaces the simple existence
short-circuit). Regenerates the new sqlx cache for the bool_and query.

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

* [ee] fix(license): propagate get_instance_hash errors; bump ee-repo-ref

- get_instance_hash: replace `.ok().flatten()` with map_err+? so DB errors
  during instance_uid lookup surface as 500 instead of silently returning
  `{"instance_hash": null}` (same pattern get_offline_license_status already uses)
- Bump ee-repo-ref to pull in the enforce_offline_caps cached-state preservation

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

* chore: update ee-repo-ref to c6cd1afe2d9e04809b30751cd1687b28a65e62b1

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

Previous ee-repo-ref: a6d91016ae0d43c46604313aecae3aa9c778c8e0

New ee-repo-ref: c6cd1afe2d9e04809b30751cd1687b28a65e62b1

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-11 22:09:21 +00:00
Ruben Fiszel 05172ac3bd fix: populate raw_code for flowscript and appscript runs (#9104) 2026-05-11 13:54:50 +00:00
centdix 27acbbf3d5 refactor: move ai sse plumbing to windmill-ai (#9059)
* docs: refine windmill ai refactor plan

* refactor: move ai sse plumbing to windmill-ai

* refactor: remove ai re-export shims

* fix: update ee ai memory ref

* chore: update ee-repo-ref to d3bc7fa85195b46b7a38d43c2f806520bf8b5454

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

Previous ee-repo-ref: ff35bf7cc198e13884b33654e1d6dbd8a8b314d3

New ee-repo-ref: d3bc7fa85195b46b7a38d43c2f806520bf8b5454

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-11 10:01:45 +00:00
hugocasa e74f06cb56 fix: handle singlestepflow zombies and stop filtering them from runs page (#9055)
* fix: handle singlestepflow zombies and stop filtering them from runs page

* fix: support singlestepflow in batch_rerun_jobs

Previous PR added singlestepflow to list_selected_job_groups so the BatchReRun
pane shows them, but batch_rerun_jobs_inner still joined on kind = 'script' /
'flow' with j.runnable_id (which is NULL for SingleStepFlow), so the rows were
silently filtered out — user sees the option, click Re-run, gets zero successes.

Mirror the norm_kind CTE projection from list_selected_job_groups inside
batch_rerun_jobs_inner: pull the wrapped runnable type and pinned script hash
from raw_flow.modules[id='a'], cast back to JOB_KIND so the existing handler
dispatch works unchanged. Path-based schema fallback so input_transforms still
resolve at rerun time.

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

* fix: project singlestepflow in batch-rerun schema lookups

Codex review pointed out two follow-on regressions from the previous fix:

(1) list_selected_job_groups returned schemas with script_hash=null and
schema=null for singlestepflow rows because the inner schemas subquery still
joined runnable metadata via j.runnable_id (NULL for SingleStepFlow). The
BatchReRun pane consumes every selected.schemas entry through
mergeSchemasForBatchReruns / buildExtraLibForBatchReruns, both of which
assume real schema objects.

(2) When use_latest_version=true, batch_rerun_handle_job re-fetched
latest_schema from v2_job filtering jb.kind='script' or 'flow' — neither
matched singlestepflow, so schema came back NULL and every input_transforms
entry silently no-op'd.

Both queries now project singlestepflow rows via raw_flow.modules[id='a'] —
norm_kind for dispatch and effective_hash for the schemas join, plus a
path-based latest-schema fallback so flow-wrapped SSF (no version pinning)
and any SSF whose pinned hash has been deleted still resolve.

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

* test: add batch_rerun integration tests, fix SSF hash hex parsing

Adds 11 integration tests against /jobs/run/batch_rerun_jobs and
/jobs/list_selected_job_groups (both endpoints had zero CI coverage).
Tests cover the full 4-kind × 3-mode matrix: regular Script and Flow
(baseline regression for the SQL refactor), script-wrapped and flow-
wrapped SingleStepFlow (regression for the bugs this PR fixes), and a
mixed-kind batch.

Writing the tests caught a real bug in the previous commit: ScriptHash
serializes as a 16-char hex string in raw_flow.modules[a].value.hash
(per the custom Serialize impl in windmill-types/scripts.rs), not as
an integer. The earlier `(m->'value'->>'hash')::bigint` cast worked
on the hand-inserted SQL fixture I'd used for live testing (which
embedded the hash as a raw integer) but failed in production where
all SSF jobs are pushed via JobPayload::SingleStepFlow's serialized
form. Replaced with `('x' || lpad(hex, 16, '0'))::bit(64)::bigint` —
preserves the twos-complement bit pattern so both positive and
negative i64 hashes round-trip correctly.

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

* Update SQLx metadata

---------

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-07 08:00:38 +00:00
hugocasa 6e5a21a9c7 fix(flows): inherit flow_env in sub-flow predicates (#9042)
* fix(flows): inherit flow_env in sub-flow predicates

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

* refactor(flows): align flow_env lookup with get_root_job_id and tighten gate

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

* refactor(flows): drop recursive CTE, root_job propagation suffices

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

* fix(flows): walk via flow_innermost_root_job to respect imported-flow scope

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

* refactor(flows): remove flow_env API endpoint, dead code from deno_core era

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-05 20:11:38 +00:00
Ruben Fiszel 1174d7d77f refactor: replace SELECT * with explicit column lists (#9010)
* refactor: replace SELECT * with explicit column lists

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

* chore: update sqlx offline query cache

* chore: update sqlx offline query cache

* chore: update sqlx offline query cache with EE support

* chore: update sqlx offline query cache, no deletions

* chore: update sqlx offline query cache after rebase

* fix: correct column names in explicit script query lists

- concurrency_limit → concurrent_limit (matches DB column name)
- runnable_settings → runnable_settings_handle (matches DB column name)

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

* fix: add missing delete_after_secs column to script queries

Also add integration test covering all explicit-column export queries.

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

* test: add workspace export integration test covering all explicit-column queries

Covers tarball_workspace (folder, script, resource, resource_type, variable,
schedule, usr, group_) and the mcp_oauth_client SELECT query from windmill-mcp.

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

* test: add tarball export integration test covering all explicit-column queries

Single test creates one of each entity type and exercises every runtime-checked
explicit-column query in tarball_workspace. Uses archive_type=tar to avoid
zip feature-gate in CI.

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

* docs: fix stale verification step and CI contradiction in update-sqlx skill

- Regenerate current_files.txt after EE cache restoration so step 4 reports accurate diff
- Scope "Never use SQLX_OFFLINE=true" to local prepare (CI legitimately uses it)

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

* docs: remove Co-Authored-By from commit skill template

* refactor: extract SCRIPT_COLUMNS const to single source of truth

Replaces 5 duplicated 44-column lists with a shared const in windmill-types.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-04 08:39:45 +00:00
Ruben Fiszel 392888d113 docs: add SAFETY comments to all dynamic SQL call sites (#9009)
* docs: add SAFETY comments to all dynamic SQL call sites

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

* docs: address review feedback on SAFETY comments

- Fix missed comment for obo_triggers loop in offboarding.rs
- Fix variable name in comment (table -> table_name) in offboarding.rs
- Fix api-settings comment to reference inline VALID_NAME regex, not validate_dbname()
- Add SAFETY comments to batch_execute calls in api-settings
- Fix db.rs comment: PG_SCHEMA is env var, not compile-time constant
- Add doc comments on RunnableSettingsTraitInternal constants

* docs: remove misleading SAFETY comment on static SQL

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-03 07:14:14 +00:00
hugocasa d60dd745e4 feat(forks): handle triggers and schedules in workspace forks (#8976)
* feat(forks): strip operational state from triggers/schedules on git-sync export

When the source workspace is a fork (`wm-fork-*`), the tarball export now
omits `mode` from triggers and `enabled` from schedules. The trigger update
handler also preserves the existing DB `mode` when both fields are absent
from the request, instead of falling back to the BaseTriggerData default.

This prevents a fork's git-sync round-trip from flipping the parent
workspace's enabled/disabled state when a merge applies the fork's YAML
back to main.

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

* feat(forks): opt-in fork_triggers flag clones triggers/schedules disabled

Adds `workspace.fork_triggers` (default false) and a matching field on
CreateWorkspaceFork. When the user opts in, fork creation also runs
clone_triggers_and_schedules: every row in schedule and the ten
*_trigger tables is copied to the fork with mode='disabled' /
enabled=false. Listener identifiers (group_id, replication_slot_name,
subscription_name, …) are copied verbatim — the runtime suffix that
prevents the fork from competing with the parent ships in a follow-up
PR.

native_trigger is intentionally skipped: those triggers manage external
webhook state we don't want duplicated.

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

* feat(forks): warn before enabling triggers/schedules that conflict with parent

set_trigger_mode and schedule's set_enabled now check whether the parent
workspace has the same path actively enabled. If so, the call is rejected
with a `fork-conflict:<kind>:<parent_id>` error unless the request includes
`force=true`. The frontend interprets the prefix to surface a confirm-to-
proceed dialog.

This is the placeholder safety net until the Phase 3 listener-suffix work
removes the conflict for the namespaceable kinds (Kafka/MQTT/NATS/Postgres/
Azure/GCP-CreateNew). For SQS, GCP-Existing, and schedules — where there's
no namespacing fix — the warning is the durable solution.

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

* feat(forks): UI: opt-in clone-triggers checkbox + confirm-on-fork-conflict

Adds the user-facing surface for the fork-trigger work:

- CreateWorkspaceInner: new "Clone triggers and schedules" toggle in the
  fork-creation dialog (default off). Sends fork_triggers in the request.

- forkConflict utility: detects the `fork-conflict:<kind>:<parent_id>`
  error string from the backend, shows a confirm() dialog explaining
  why the action is blocked, retries with `force: true` if accepted.

- Wires withForkConflictRetry into every trigger setMode and the
  schedule setEnabled call, both in the per-kind editor components and
  the +page.svelte list views (HTTP, websocket, kafka, NATS, SQS, MQTT,
  GCP, Azure, Postgres, email, schedule).

OpenAPI spec gains the `force` field on each setmode/setenabled body.

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

* feat(forks): CLI --fork-triggers flag, fork-trigger docs, skill update

- Adds --fork-triggers boolean to wmill workspace fork; passes
  fork_triggers through to the create_fork API call.
- New docs/fork-triggers.md describing the model end-to-end (default,
  opt-in clone, merge-direction filter, conflict warning, future
  runtime-suffix work).
- Updates the adding-a-trigger SKILL.md to mention the fork-export
  ignore-keys participation and the clone_triggers_and_schedules
  block that new trigger kinds must extend.

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

* chore: regenerate sqlx offline query cache for fork-trigger SQL

* fix(forks): replace browser confirm() with ConfirmationModal for fork conflict

The fork-conflict warning previously used the browser's native confirm()
which doesn't match Windmill's design system. Switches to a singleton
ConfirmationModal mounted at the (logged) layout root, driven by a new
forkConflictModal store. The withForkConflictRetry helper now sets the
store and awaits the user's choice via a Promise, instead of blocking
on window.confirm.

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

* fix(forks): filter unchanged triggers in merge UI, add diff view, surface parent-only ones

The fork merge UI listed every trigger from the fork as a deployable item
regardless of whether it differed from the parent — so a fork created with
fork_triggers=true (which clones triggers in disabled state, otherwise
identical) showed every trigger as a "Fork-only" change. The 'Update
current' tab also missed triggers newly created in the parent that the
fork hadn't pulled yet.

This refactor:

- fetchAllTriggers now lists both fork and parent in parallel for each
  trigger kind, then merges by path.
- Computes a per-trigger `changeKind` (new / modified / deleted-in-source)
  using a JSON comparison that strips runtime + fork-local fields
  (mode/enabled/server_id/last_server_ping/edited_at/edited_by/etc.) so
  the disabled-on-clone difference doesn't show up as a change.
- Filters the trigger items in deployableItems by the current direction:
  Deploy mode shows fork-side new/modified, Update mode shows parent-side
  new/modified.
- Replaces the always-on "Fork-only" badge with proper New/Modified
  badges and surfaces a Diff button (modal Drawer + Monaco DiffEditor)
  for modified triggers — the diff strips the same ignored fields so
  users see only the meaningful config differences.

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

* fix(forks): always clone triggers/schedules disabled, drop opt-in flag

Disabled triggers and schedules are inert — no listener attaches, no cron
fires — so cloning them by default is safe by construction. Drops the
fork_triggers opt-in flag introduced earlier in this PR:

- Drops workspace.fork_triggers column (migration removed)
- Removes fork_triggers from CreateWorkspaceFork (API + OpenAPI)
- Removes the conditional in create_workspace_fork — clone always runs
- Removes the toggle from the fork-creation dialog
- Removes --fork-triggers from `wmill workspace fork`
- Updates docs/fork-triggers.md and adding-a-trigger SKILL.md

The merge UI continues to exclude triggers from the deploy/update default
selection, so a routine merge from a fork doesn't accidentally push
trigger config the user hasn't intentionally changed.

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

* fix(http-triggers): scope route exists check by workspace, skip non-workspaced clones in forks

The non-CLOUD branch of `route_path_key_exists` self-excluded by trigger
path alone, which silently masked cross-workspace collisions once forks
started cloning trigger rows verbatim. Tighten it to exclude only the
exact `(workspace_id, path)` row.

Fork creation also now skips non-workspaced HTTP triggers — their URL
has no workspace prefix, so a clone collides with the parent at the
matchit router (which silently drops one of two duplicates) and there is
no namespacing escape hatch. The clone copies all rows when CLOUD_HOSTED
or HTTP_ROUTE_WORKSPACED_ROUTE forces every route workspaced regardless.

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

* fix(forks-ui): silent cancel on enable conflict, clean up trigger rows in compare view

forkConflict helper now returns undefined when the user dismisses the
modal instead of throwing, so the redundant 'Cannot enable: undefined'
toast no longer appears.

CompareWorkspaces trigger rows now mirror the script row layout: drop
the redundant Disabled badge and the Trash/Details buttons (both belong
on the dedicated trigger pages, not in the deploy/compare view); pass
triggerKind through so RowIcon picks the right kind-specific icon; move
extraLabel into the summary line; replace the yellow Modified badge
with the same green ↗ ahead / blue ↘ behind treatment scripts use.

Trigger diff drawer: switch JSON → YAML for parity with DiffDrawer, fix
zero-height monaco render with className=!h-full, drop the redundant
Original/Modified label banner above the diff.

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

* fix(email-trigger): scope local_part exists check, skip non-workspaced clones in forks

Mirrors the HTTP route fix for the email-trigger non-CLOUD `email_exists`
check (in EE) which had the same path-only self-exclusion bug, and the
fork clone of `email_trigger` rows which copied non-workspaced
`local_part` verbatim. Skip non-workspaced rows in the clone unless the
instance is CLOUD_HOSTED (where lookup is workspace-scoped natively).

EE companion change in windmill-trigger-email/src/handler_ee.rs.

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

* chore: update ee-repo-ref to 78512dd73b4a1c9f70574cff863374179e3a621b

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

Previous ee-repo-ref: 1ac77f50747b58e720a11162dfd309bc252a24ab

New ee-repo-ref: 78512dd73b4a1c9f70574cff863374179e3a621b

Automated by sync-ee-ref workflow.

* fix(forks): always-warn on parent row, kind-specific modal copy, cancel-aware toggles

- Conflict check now fires whenever the parent has the path (regardless of
  parent's mode), since the cloned upstream identifier is shared by
  construction; closes the Postgres slot-takeover gap when the parent is
  disabled. Schedule's set_schedule_enabled gets the same treatment.
- Skip the warning entirely for HTTP and Email via a new
  TriggerCrud::FORK_CONFLICT_ON_ENABLE const — both kinds are workspace-
  scoped at runtime so cloned rows can't collide with the parent.
- Modal copy branches by failure family: split-events (Kafka/NATS/MQTT/SQS/
  GCP/Azure), duplicate-firing (Websocket/Schedule), slot-takeover
  (Postgres). Generic fallback for unknown kinds.
- withForkConflictRetry now returns boolean (true=committed, false=
  cancelled). TriggerModeToggle reuses its existing innerTriggerMode local
  state via a function binding for the regular Toggle, snapping back to
  the prop when onToggleMode signals a cancel — needed because the native
  bind:checked diverges from the parent's prop after a click and Svelte's
  reactivity won't re-push a same-valued prop down. Schedule list page
  uses {#key} on a reset version since it renders Toggle directly.
- Editor inners revert mode = previousMode on cancel; list pages skip the
  re-fetch (loadTriggers/loadSchedules) on cancel to avoid pointless
  network traffic and the schedule "Job stats loading..." flash.
- Drop withForkConflictRetry from HTTP and Email editors + list pages
  since the backend never emits the conflict for those kinds.

* fix(forks-ui): widen onToggleMode types, scope schedule toggle reset by path

- TriggerEditorToolbar and TriggerSuspendedJobsModal forwarded
  onToggleMode as `(mode) => void`, dropping the new boolean return so
  any caller wired through them would silently no-op the cancel-revert.
  Match the wider TriggerModeToggle signature.
- Schedule list page used a single resetVersion counter for every row's
  {#key}, so cancelling on any one schedule remounted every <Toggle> on
  the page. Switch to a per-path Record<string, number> bumped only for
  the affected row.

* chore: bump ee-repo-ref to c3a4553 (email FORK_CONFLICT_ON_ENABLE override)

* fix(forks): include Suspended in conflict gate, use parent_workspace_id for fork detection

Three fixes from the Claude review on PR #8976:

- Suspended mode still attaches the listener (it just pauses auto-run of
  queued jobs); two suspended fork+parent listeners would still split
  Kafka events / share a PG slot. Gate set_trigger_mode on
  `mode != Disabled` instead of `mode == Enabled` so Suspended also
  surfaces the warning.
- workspaces_export.rs::fork_*_ignore_keys keyed off the wm-fork-* prefix
  while set_trigger_mode and set_schedule_enabled key off
  parent_workspace_id. Switch the export filter to query
  parent_workspace_id once at the top of tarball_workspace and pass
  is_fork through. The column is the contract; the prefix is a
  creation-time naming convention that could in principle drift.
- TriggerModeToggle's suspend-dropdown action reassigned the non-bindable
  `triggerMode` prop instead of the local `innerTriggerMode` mirror,
  leaking inconsistent state if the dispatch was cancelled. Now writes
  to innerTriggerMode like the Toggle's on:change handler does.

* fix(cli): skip setScheduleEnabled when local YAML lacks `enabled`

Tarball export from a fork strips `enabled` from schedules so the
fork→parent git-sync round-trip can't flip the parent's operational
state. The CLI's pushSchedule called setScheduleEnabled whenever
`localSchedule.enabled != schedule.enabled`, which evaluates truthy
when local is undefined (fork-pulled YAML) and remote is true/false —
sending `{ enabled: undefined }` that serializes to `{}` and gets
rejected by the backend (`SetEnabled.enabled` is required).

Skip the call when `localSchedule.enabled === undefined` so a sync push
of fork-pulled YAMLs preserves the target's existing enabled state
instead of erroring out. Trigger updates were already safe — the
backend's update_trigger preserves `mode` when the request omits it.

* Revert "fix(cli): skip setScheduleEnabled when local YAML lacks `enabled`"

This reverts commit 23ba7e72fc.

* feat(cli): --force flag and friendlier error on fork-conflict for schedule enable

`wmill schedule enable foo/bar` against a fork whose parent has the same
path used to surface the raw `fork-conflict:schedule:<parent>` error
body. The CLI now:

- accepts `--force` to bypass the warning (mirrors the API field and the
  UI's "Enable anyway" confirmation),
- detects the `fork-conflict:` prefix on errors and prints a one-screen
  explanation pointing at --force instead of the raw body.

Disable doesn't trigger the warning (the gate fires only on transitions
to listener-attaching modes), so no flag there. Trigger enable/disable
isn't exposed as a standalone CLI command — sync push goes through
updateTrigger which has its own backend mode-preservation, so no
fork-conflict surfaces from the CLI for those.

* chore: regenerate cli-commands docs after adding --force to schedule enable

* chore: update ee-repo-ref to 967f961f0a88b027d894aebd03977181129477a8

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

Previous ee-repo-ref: c3a4553296473932e15392a06415dd7fb9aa6591

New ee-repo-ref: 967f961f0a88b027d894aebd03977181129477a8

Automated by sync-ee-ref workflow.

* fix(forks): address CI dead-code, claude/cubic review feedback

- backend: cfg-gate `fork_trigger_ignore_keys` to match its already-gated
  callsite. CI compiles with `-D warnings`, so the unused-fn under feature
  combos that disable all trigger crates was breaking check_oss/check_ee/
  cargo_test/test-linux/test-windows.
- cli: re-apply the `pushSchedule` undefined-skip (originally 23ba7e7,
  reverted in 4d172a1). Tarball export from forks strips `enabled`, so
  fork-pulled YAMLs that get sync-pushed back via `wmill schedule push`
  would otherwise serialize `{ enabled: undefined }` → `{}` and the
  backend's required `SetEnabled.enabled` rejects the body. Skipping
  preserves the target's existing flag, which is the round-trip-safe
  behavior. (`wmill workspace merge` extension to triggers/schedules is
  tracked in #9001 — until then sync push is the only CLI path.)
- TriggerModeToggle suspend-dropdown action awaits onToggleMode and
  resets `innerTriggerMode = triggerMode` on cancel, matching the Toggle
  on:change handler. Without this, dismissing the fork-conflict modal on
  a Suspend transition leaves the toggle stuck in 'suspended'.
- forkConflict: when a new modal opens with a previous resolver still
  pending, resolve the older promise to false. Avoids a dangling promise
  if the user clicks toggles on two rows in quick succession.
- schedules list: bump `toggleResetVersions[path]` on the
  permission-denied branch so the Toggle re-mounts back to the prop's
  `enabled` value. Without this, a user without write permission could
  click the toggle and have it stick visually flipped.
- docs/fork-triggers.md: switch the merge-direction filter description
  from `wm-fork-*` prefix to `parent_workspace_id IS NOT NULL` (matches
  the code after 4dd38fe). Drop the misleading "merge-direction filter
  strips identifier columns too" line in Future Work — the runtime
  suffix is applied at listener attach, the stored column never carries
  it, so no export filtering is needed there.

---------

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-01 20:55:24 +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 4483d0cab9 fix(workspaces): split get_settings into admin-only + public endpoint (#8990)
* fix: redact GitHub App tokens and Slack OAuth secret for non-admins

`GET /workspaces/get_settings` returned the full `git_app_installations`
JSONB to any workspace member. That column caches the GitHub App JWT and
installation token used by git-sync; the installation token is refreshed
on every git-sync action and valid for ~55 minutes, so the value sitting
in the DB is essentially always live. Null it out for non-admins,
matching the existing `slack_oauth_client_secret` redaction.

The tarball export's v2 settings format (added in #8935) included
`slack_oauth_client_secret` with no admin gating, regressing the same
redaction. Mirror the admin check there.

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

* refactor: split get_settings into admin-only + public endpoint

Adds `WorkspacePublicSettings` and `GET /workspaces/get_public_settings`,
which returns only fields safe for any workspace member to read
(workspace_id, slack/teams team identity, mute_critical_alerts, deploy_ui,
large_file_storage, datatable). `get_settings` is now admin-only via
`require_admin`.

Migrates frontend callers: every caller that read non-sensitive fields
(deploy_ui on trigger pages, mute_critical_alerts on the root layout, slack
team identity for handler pickers, etc.) now uses `getPublicSettings`. The
admin-managed settings UI, git-sync admin context, operator settings,
checkout polling, and full settings page stay on `getSettings`.

This replaces the field-level redactions added in the previous commit:
the type system itself defines the public surface, so adding a sensitive
column to `workspace_settings` no longer defaults to leaking — it stays
out of `WorkspacePublicSettings` unless explicitly added.

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-30 14:02:54 +00:00
Ruben Fiszel de0b6b1528 feat: workspace-shared ui/ folder reusable across raw apps (#8974)
* feat: add workspace-shared ui/ folder reusable across raw apps

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

* feat: add shared ui/ drawer in raw app editor sidebar

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

* feat: forward workspace shared ui/ to raw app editor iframe

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

* all

* all

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:54:55 +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
hugocasa c95642863e feat: support restart from steps inside BranchOne, ForLoop, Subflow (#8955)
* feat: support restart from steps inside BranchOne, ForLoop, Subflow

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

* fix: preserve original job kind in nested restart, support expanded subflow steps

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

* fix: read selected iteration from graph state for nested ForLoop restart

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

* feat: iteration selectors per ForLoop in restart popup, more nested restart tests

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

* refactor: extract useNestedRestartState composable

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

* test: cover deployed-subflow + FlowDependencies path in nested restart

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

* chore: update sqlx prepare cache

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

* fix: detect BranchOne/ForLoop ancestors inside expanded subflows for nested restart

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

* fix: hide restart button for non-restartable steps (parallel containers, untaken branches)

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

* fix: address review feedback on nested restart PR

- preview FlowRestartButton: hide nested case (chain UUIDs aren't resolvable in
  preview path; users can use the run page for nested restart instead)
- branchOneAncestorMatchesOriginal: be permissive when status isn't reachable
  (don't hide the button for BranchOnes nested deeper than top-level)
- worker_flow.rs: apply nested_restart_payload swap on the is_simple ForLoop
  fast path too, so simple iterations don't bypass restart spawn interception
- FlowStatusViewer: reset expandedSubflows cache on jobId change; drop
  $bindable({}) banned pattern for the new prop
- API resolver: validate the leaf step exists before returning (fail-fast)
- doc fix: branch_or_iteration_n is 0-based, not 1-based
- selectedJobStepIsTopLevel reset on early-return in composable
- comment iterationCounts collision caveat
- new HTTP-level integration tests covering the API endpoint contract:
  happy path (top-level + nested), unknown step, out-of-range iteration,
  parallel-loop rejection

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

* revert: remove unreachable nested-restart swap on is_simple ForLoop fast path

The swap is unreachable in valid flows: `is_simple_modules` requires the body
to be a single `script` / `rawscript` / `flowscript` (per `FlowModule::is_simple`),
none of which spawn flow-kind children. Any nested-restart chain targeting a
leaf inside such an iteration is rejected by the API at leaf validation. Even
if a chain reached the worker via `JobPayload::RawFlow.restarted_from`, the
resulting `RestartedFlow` would fail to push (script kind isn't a flow kind).

Replaced the swap with an explanatory comment so the next reader knows why
the symmetry with the non-simple path was deliberately not added.

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

* fix: handle undefined expandedSubflows + tighten branchOne match check

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-28 20:00:03 +00:00
Alexander Petric 95d4c6a94d feat(cli): non-interactive Slack connect/disconnect + sync round-trip fixes (#8935)
* feat(cli): non-interactive Slack connect/disconnect

Extract create_slack_workspace_artifacts / create_slack_instance_artifacts
from the browser OAuth callbacks and expose them via two new endpoints that
accept a pre-minted xoxb bot token:

- POST /w/{workspace}/workspaces/connect_slack (admin)
- POST /oauth/connect_slack_instance (super-admin)

Both produce bit-for-bit identical DB state to the UI browser flow.

Wire three CLI commands as thin wrappers:
- wmill workspace connect-slack
- wmill workspace disconnect-slack
- wmill instance connect-slack

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

* fix(cli): round-trip stability for workspace settings handlers

wmill sync push was destroying UI-configured error_handler/success_handler
state on every deploy. Two orthogonal bugs:

(a) pushWorkspaceSettings called editErrorHandler with `path: undefined`
    when the YAML lacked the handler block, which the backend treats as a
    clear — so syncing settings.yaml that didn't mention the handler wiped
    the DB row. Fix: skip the call entirely when absent from YAML.

(b) edit_error_handler omitted muted_on_cancel / muted_on_user_path when
    false, but the CLI always sends them, causing perpetual deepEqual
    drift and a spurious editErrorHandler call on every sync push. Fix:
    always persist both booleans.

migrateToGroupedFormat now preserves explicit `null` on
error_handler / success_handler as a "clear remote" signal distinct from
absence. Widen ErrorHandlerConfig | null / SuccessHandlerConfig | null to
make this explicit in the type.

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

* feat(cli): sync support for workspace-level Slack OAuth override

Add slack_oauth_client_id and slack_oauth_client_secret to the v2 tarball
export and to pushWorkspaceSettings, so the workspace-level OAuth override
is now fully managed as code through settings.yaml.

Semantics:
  - both defined and truthy → setWorkspaceSlackOauthConfig (upsert)
  - both defined but falsy (e.g. empty strings) and remote has a value
    → deleteWorkspaceSlackOauthConfig
  - either omitted → leave remote alone ("not managed by git")

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

* refactor(cli): normalize workspace settings sync to "omit = clear"

Earlier commits on this branch introduced an "omit = keep" rule for
error_handler / success_handler / slack_oauth_client_{id,secret} that
diverged from every other workspace setting (webhook, deploy_to, etc. all
treat YAML as canonical: absence = clear). Normalize:

- v2 tarball always emits these 4 fields (null when remote is NULL) so
  round-trip is bijective and settings.yaml is a complete snapshot.
- pushWorkspaceSettings drops the absent-from-YAML guards; YAML is
  canonical. Absence and explicit null both clear the remote — same rule
  as every other field.
- set_slack_oauth_config / delete_slack_oauth_config now fire
  handle_deployment_metadata so UI mutations reach git-sync-enabled
  workspaces' committed settings.yaml.

Policy for users: pull before push (same as every other setting). On first
post-upgrade pull, explicit `null` keys appear for any workspace whose
handlers / oauth override are unset — one-time YAML diff, no semantic
change.

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

* test(cli): add unit + integration coverage for Slack settings sync

Unit tests (settings_unit.test.ts): cover migrateToGroupedFormat preserving
explicit `null` on error_handler / success_handler, and passthrough of
slack_oauth_client_id / _secret (both populated and null values).

Integration tests (slack_settings_sync.test.ts, skipped on CI per the same
convention as datatable_settings_sync.test.ts): exercise the full backend
via withTestBackend to verify

  1. pull emits null for unset error_handler / success_handler /
     slack_oauth_client_id / _secret;
  2. round-trip with all-null handlers is idempotent;
  3. push of populated slack_oauth_config upserts;
  4. omitting the slack_oauth keys from YAML clears remote (universal
     "omit = clear" rule);
  5. explicit null error_handler in YAML clears remote;
  6. round-trip preserves a populated error_handler exactly, including the
     always-persisted muted_on_cancel / muted_on_user_path booleans.

Also feature-gates `use crate::oauth2_oss::workspace_connect_slack` and its
route registration behind `cfg(feature = "oauth2")`: the import caused a
build failure on subsets of the workspace without the oauth2 feature,
surfaced by the integration test harness.

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

* chore: bump ee-repo-ref to 59b6123

Pins windmill-ee-private to the tip of branch alp/slack_cli, which
contains the companion EE changes (helper extraction, non-interactive
Slack connect handlers, git-sync for Slack settings mutations).

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

* Update SQLx metadata

* chore: regenerate system prompts for new slack CLI commands

Captures the new workspace connect-slack, workspace disconnect-slack,
and instance connect-slack commands in the auto-generated files that
CI enforces via system_prompts/check-freshness.sh.

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

* chore: update ee-repo-ref to b4a5ca11e3b96ff03793c2bd396dbc1fe6ea1022

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

Previous ee-repo-ref: d7e44d0519327ec9077625130365e887826f324b

New ee-repo-ref: b4a5ca11e3b96ff03793c2bd396dbc1fe6ea1022

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>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-04-24 17:14:08 +00:00
Ruben Fiszel 4cf53a44bb feat: add auto-login SSO provider instance setting (#8929)
* [ee] feat: add auto-login SSO provider instance setting

Adds an instance-level `auto_login_provider` setting that, when set to
an OAuth provider key (e.g. "okta") or "saml", causes the login page
to auto-redirect users to the configured SSO flow on mount.

Useful for orgs with a single SSO where the provider button grid adds
a pointless extra click.

- Backend: new global setting constant, read from DB in list_logins
  handler and returned as the `auto_login` field in the response
- Frontend: Login.svelte auto-redirects in loadLogins() when the
  configured provider is actually present in the response
- Escape hatch: `?no_sso=1` skips the auto-redirect and shows the
  normal login form (admin fallback when SSO is broken)
- No redirect loop: if the `error` prop is set (SSO callback failed),
  the redirect is skipped
- Admin UI: new text field under Auth/OAuth/SAML in instance settings

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

* fix: skip auto-redirect on /user/login page

Auto-redirect should only fire on embeds where the user did not
explicitly navigate to a login screen (public app popup, approval
pages). Visiting /user/login is an explicit sign-in action — often by
an admin who needs password fallback — so we must never hijack it.

Gate the logic on a new `autoRedirect` prop (default true). The main
login page passes `autoRedirect={false}`.

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

* chore: update ee-repo-ref to b7157d55fb9f8d8f7aeb7b1fb69bc935af895a2f

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

Previous ee-repo-ref: e32a48499d206a24e0c12817b465775321b0ee41

New ee-repo-ref: b7157d55fb9f8d8f7aeb7b1fb69bc935af895a2f

Automated by sync-ee-ref workflow.

* fix: handle popup-blocked auto-redirect in popup mode

When Login is embedded with popup=true (public app), auto-redirect
funnels through window.open() without a user gesture — browsers block
it by default, leaving the user stuck on "Signing you in…".

Detect window.open returning null, clean up listeners, reset
autoRedirecting so the provider button grid re-renders, and surface a
toast pointing users at the manual button. The grid click retains its
user gesture and passes the popup blocker.

Also extracts a redirectSaml() helper so the SAML auto-redirect path
and the SSO button click share the same logic.

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

---------

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-04-24 04:20:01 +00:00
hugocasa d6c642b170 feat: add Azure Event Grid triggers (#8888)
* feat: add Azure Event Grid triggers (EE)

Introduces a new enterprise trigger kind `azure` that supports three
modes via a single unified trigger type:
- basic_push: Azure Event Grid basic — custom topics, system topics
  (Storage, Resource Manager, Key Vault, etc.), domains (push only)
- namespace_push: Event Grid Namespace topics (CloudEvents over HTTP push)
- namespace_pull: Event Grid Namespace topics (HTTP pull with lock-token
  ack/reject for dead-lettering)

Auth uses a Service Principal resource (tenant_id, client_id,
client_secret, subscription_id). Subscriptions are created in
CloudEvents 1.0 schema so the push webhook handler and the pull listener
share one payload parser.

Backend
- New crate `windmill-trigger-azure` (OSS stubs + EE impl symlinked from
  windmill-ee-private)
- Migration `azure_trigger` table with CHECK constraints enforcing
  mode/columns coherence
- `TriggerKind::Azure`, `JobTriggerKind::Azure`,
  `DeployedObject::AzureTrigger` variants
- Push route `/api/azure/w/{workspace}/*path` handles classic
  Event Grid SubscriptionValidation handshake and CloudEvents 1.0
  abuse-protection OPTIONS handshake
- Optional inbound JWT validation (audience check only for v1)
- Feature flag `azure_trigger` propagated through windmill-api,
  windmill-store (resource helper), and added to ee_core

Frontend
- `triggers/azure/` editor with mode toggle (basic/namespace-push/
  namespace-pull) and per-mode config (topic ARM id / namespace +
  topic name / subscription / filters / push auth / pull options)
- Registered in icon map, display names, save functions, badge,
  wrapper, editor, add-trigger menu

OpenAPI
- `AzureTrigger`, `AzureTriggerData`, `AzureMode`,
  `AzureSubscriptionMode`, `AzureDeliveryConfig`, `TestAzureConnection`
  schemas; `/azure_triggers/*` endpoints; client regenerated

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

* chore: update ee-repo-ref to eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8

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

Previous ee-repo-ref: 9689014e8c12c36c1059fd8fa5758d550b8b8bc9

New ee-repo-ref: eaa7c3a9cb37a9ccc93f10a2535d929365acd2d8

Automated by sync-ee-ref workflow.

* feat(azure-trigger): secret-auth push, ARM discovery, capture isolation, CLI + parity

Frontend:
- Split mode selector into Namespace/Basic + Pull/Push
- ARM resource dropdowns (namespaces, Basic topics, namespace topics)
  populated from the service principal; cascade with stale-selection
  reset on SP / edition change
- Remove stale authenticate toggle + audience input (server-managed
  push_auth_config has replaced them)
- Azure listing page: "Create from template" button; "Also delete Azure
  subscription" toggle in the delete modal; simplified trigger label
  falling back to path
- AzureCapture.svelte: "Test subscription name" with -wm-capture suffix
- CompareWorkspaces.svelte: wire Azure for fork/compare
- Drop Trigger-deployed/event-loss warning (capture subscription is
  isolated with -wm-capture)

Backend:
- Shared-secret push auth (see EE crate for detail)
- JSONB push_auth_config column (renamed from delivery_config), #[serde(skip)]
  so clients/CLI/exports never see it
- Drop redundant enabled column; mode supersedes
- Azure capture infra: AzureTriggerConfig + set_azure_trigger_config +
  azure_payload route + TriggerKind::Azure arm; PT15M queue TTL on
  capture subscriptions so they bound storage after tab close
- Granular ACLs, users offboarding, trash, git-sync deployed-object:
  all include azure_trigger

CLI:
- Add azure to TRIGGER_TYPES, pushObj dispatch, getTypeStrFromPath,
  trigger commands (get/update/create/list/template), sync delete
  switch + regex; e2e test for `trigger new --kind azure`
- system_prompts: SCHEMA_MAPPINGS + schema_names include AzureTrigger;
  auto-generated/* regenerated

Skill:
- .claude/skills/adding-a-trigger/ checklist covering every file that
  needs editing when wiring a new trigger type (learned from this PR)

ee-repo-ref bumped to b0e490cbf3724b7b64c6a5b010e3bdf24acd873c.

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

* fix(azure-trigger): ci — ShareModal Kind + regenerated system_prompts

- frontend/src/lib/components/ShareModal.svelte: add 'azure_trigger'
  to the Kind type so the listing page's "Permissions" action compiles
  (ts2345 — caught by npm_check on CI, missed by fast-check locally).
- system_prompts/auto-generated/: regenerate to drop the stale
  delivery_config / AzureDeliveryConfig fields from the Azure schema
  (check-freshness on CI).

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

* refactor(azure-trigger): use workspace constant_time_eq crate

Drop hand-rolled constant-time compare in favour of the workspace
constant_time_eq crate (same one used by http_trigger_auth).

ee-repo-ref bumped to 9659382d47286e7f7f66d01b6f5dd8d4ed34848b.

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

* fix(azure-trigger): pass placeholder + disabled via inputProps

`TextInput`'s `placeholder` and `disabled` go through its `inputProps`
prop — CI's `npm run check` caught the stale top-level passing that
`npm run check:fast` missed. Align with the DefaultEmailConfigSection
pattern.

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

* fix(azure-trigger): correct LATEST_GIT_SYNC_SCRIPT_PATH version to 28213

The hub deploy of the azure-aware sync-script is version 28213, not
28214. Backend was pinning a non-existent hub script, which broke the
git_sync_e2e suite (every deploy's sync step 404'd).

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

* fix(azure-trigger): add azure_triggers to token scope selector + skill

- windmill-api/src/token.rs: `build_trigger_scope_domains` was missing
  `("azure_triggers", "Azure Event Grid")`, so the CreateToken UI's scope
  selector didn't surface azure_triggers:read/write. Backend already had
  `ScopeDomain::AzureTriggers` wired (scopes.rs), this just exposes it.
- .claude/skills/adding-a-trigger/SKILL.md: capture both scope-related
  files under the hardcoded-arrays section so future triggers don't miss
  the UI surface.

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

* docs(adding-a-trigger-skill): clarify token.rs scope effect

Not a regression — nothing was working before. Skipping TRIGGER_DOMAINS
just means the scope works via API/CLI but has no UI checkbox.

* docs(adding-a-trigger-skill): trim token.rs bullet

* fix(azure-trigger): regen openapi-deref + swap textarea for TextInput

- Run build_openapi.sh to regenerate openapi-deref.{yaml,json} with the
  12 azure_triggers paths + schemas. These files are served by the
  runtime (include_str! in windmill-api/src/lib.rs) to external SDK
  consumers; without this regen the new endpoints wouldn't be advertised.
- Replace the raw <textarea> for event type filters with the
  design-system TextInput in textarea mode (frontend/CLAUDE.md bans raw
  HTML elements).

Addresses cubic + claude PR review items.

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

---------

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-04-23 16:30:18 +00:00
centdix 07951e81ae fix: include endpoint descriptions in mcp tools (#8925)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-23 16:21:15 +00:00
Ruben Fiszel dac29e7d23 fix: load job metadata on approval page via approval token (#8924)
* fix: load job metadata on approval page via approval token

The approval page polled getJob without auth, which 400s for non-anonymous
jobs. The page swallowed the error so approvers saw the form but no flow
args, metadata, or graph. Accept the existing approval token on getJob and
skip the non-anon-user check when it validates against the job's flow.

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

* fix(approval): address review feedback

- Validate approval token against URL job id directly before resolving
  the parent flow, saving a DB roundtrip on the happy path (approval URLs
  always carry the flow id).
- Request getJob with no_code/no_logs from the approval page so a
  token-bearer only sees what the UI renders (args, raw_flow, metadata).
- Tighten OpenAPI description for the approval_token query param.

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-23 15:36:56 +00:00
Ruben Fiszel e73770c247 fix: derive debug signing key deterministically from JWT_SECRET (#8917)
Previously each API replica generated a random Ed25519 signing key at
startup (unless DEBUG_SIGNING_KEY_SEED was set). In multi-replica
deployments this caused "Invalid JWT signature" rejections in the
multiplayer server: the browser could sign a token on pod A while
`windmill-extra` had cached the JWKS public key from pod B.

Derive the seed deterministically from the DB-backed JWT_SECRET using
SHA-256 with a domain-separation tag so all pods agree without
coordination. Re-derive on JWT_SECRET rotation. The
DEBUG_SIGNING_KEY_SEED env var is still honored as an override.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:05:08 +00:00
Ruben Fiszel bbb564c142 fix: omit default_permissioned_as from tarball export when empty
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 19:31:37 +00:00
centdix aea74445a3 fix: add flow conversation token scope (#8903)
* fix: add flow conversation token scope

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

* refactor: make flow conversations scope plural

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

* fix: update flow chat service import

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-04-22 13:42:22 +00:00
centdix 26a6d1e4ce refactor: create windmill-ai crate (part 1 — types, traits, base modules) (#8530)
* refactor: create windmill-ai crate and move base AI types from windmill-common

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

* refactor: move worker AI types to windmill-ai crate

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

* refactor: move QueryBuilder trait and StreamEventSink abstraction to windmill-ai

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

* fix: add base64 dependency to windmill-ai for bedrock PDF support

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

* docs: add windmill-ai refactor plan

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

* refactor: address PR review — remove dead bedrock feature, add boxed_sink helper, move plan to docs

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-22 11:20:27 +00:00
Ruben Fiszel aaf3a19747 feat: async dep endpoints and queue-position logs in cli (#8895)
* feat: async dep endpoints and queue-position logs in cli

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

* refactor: share logQueueStatus between dev.ts and job_polling.ts

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

* fix: continue polling on transient errors in job_polling.ts

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-21 15:06:46 +00:00
Ruben Fiszel 10d1a932d5 fix: batch cancel dropping jobs from other workspaces (#8887)
* fix: batch cancel silently dropping jobs from other workspaces

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

* fix: gate cross-workspace batch cancel on all_workspaces flag

Enforce path workspace unless all_workspaces=true, so cross-workspace
selections only succeed when the UI is actually in all-workspaces mode.

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

* sqlx

* fix: gate cancel_selection all_workspaces on admins workspace

Matches the convention used by count_queue_jobs and count_completed_jobs_detail
in the same file — cross-workspace scope is only honored when the path
workspace is "admins", preventing clients in regular workspaces from dropping
workspace scoping by passing all_workspaces=true.

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>

---------

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: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
2026-04-20 20:00:34 +00:00
Ruben Fiszel 0cfa131254 feat: add disable_password_login global setting (#8873)
Adds an instance-level toggle that hides the email/password form on the
login page and rejects password login, password reset request, and
password reset endpoints server-side. Useful for OAuth/SAML-only
deployments.

- New `disable_password_login` global setting + lazy_static AtomicBool
- `load_disable_password_login` loader wired into monitor initial_load
  and notify_global_setting_change listener
- Unauthenticated `GET /auth/is_password_login_disabled` endpoint so the
  login page can hide the password form when enabled
- Toggle in Instance Settings → Auth/OAuth/SAML
- Login.svelte hides the password form and the "Log in without
  third-party" toggle when the setting is on

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 18:12:30 +00:00
hugocasa 4f998cc231 feat: add GitHub as a native trigger service (#8856)
* feat: add GitHub as a native trigger service

Add GitHub webhooks as a native trigger, allowing users to trigger
scripts/flows from repository events (push, PR, issues, etc.) via
OAuth-based webhook management.

Backend:
- DB migration adding 'github' to native_trigger_service, TRIGGER_KIND,
  and job_trigger_kind enums
- Full External trait implementation: create/update/delete/get webhooks,
  per-trigger sync verification, webhook payload preparation
- Paginated repos endpoint (up to 1000 repos)
- OAuth flow with admin:repo_hook and read:user scopes

Frontend:
- GitHub trigger form with repo picker and MultiSelect event selector
- Workspace integration settings with setup instructions
- Trigger badge, editor, and wrapper integration
- GithubIcon updated to support size/class props (matching other icons)
- Hub template reference for starter scripts

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

* fix: show GitHub in sidebar when triggers exist

Add github_used to the getUsedTriggers endpoint so the sidebar picks up
GitHub as an active trigger kind. Also document this step in the native-
trigger skill so future services don't miss it.

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

* feat: on-demand GitHub repo search instead of bulk fetch

Replace the upfront pagination through all repos with a debounced search
flow: load 30 most-recently-updated repos by default, then query GitHub's
/search/repositories API (scoped to the authenticated user via user:@me
and restricted to name matches via in:name) as the user types.

Frontend uses runed's Debounced + resource to wire the Select's filterText
to the backend query with 300ms debouncing.

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

* fix: request `repo` OAuth scope to list private GitHub repos

`admin:repo_hook` grants webhook management but not repo listing — so
/user/repos and /search/repositories returned only public repos. Switch
to `repo` (full repo scope, which is a superset and also covers webhook
management).

Users who already connected GitHub need to disconnect and reconnect to
pick up the broader scope.

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

* revert: fetch all GitHub repos upfront instead of searching on demand

Revert the debounced search flow — paginate through /user/repos (up to
1000) on form open. Simpler UX: repos are all there from the start, the
Select's built-in client-side filter handles finding one.

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

* fix: typed 404 detection + add GitHub flow template reference

Replace fragile e.to_string().contains("404") matching with a proper
http_error_status helper that downcasts through anyhow to the typed
HttpRequestError and reads the StatusCode.

Also wire the hub flow template (id 80) into NATIVE_TRIGGER_SERVICES.

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

* fix: update GitHub script template hub ID to 28202

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

* fix: align GitHub trigger with Nextcloud/Google patterns

Addresses review feedback from Claude and cubic.

Backend:
- `delete()` now only swallows NotFound (DB missing row) and 404 (API
  webhook already deleted); non-404/DB errors propagate so callers know
  cleanup failed. Matches Nextcloud's delete pattern exactly.
- `get_owner_repo_from_db` returns `Result<Option<(String, String)>>`
  instead of an error on missing row (matches Google's delete flow).

Frontend:
- `loading: boolean` (required) + `$bindable()` with no default — matches
  Nextcloud, satisfies CLAUDE.md banned-pattern rule.
- Wrap `loadRepos()` in `$effect` reacting to `$workspaceStore` so repos
  load once the store is available and refresh on workspace switch.
- Replace raw `fetch('/api/.../native_triggers/github/repos')` with the
  generated `NativeTriggerService.listGithubRepos(...)` typed client.
  Adds `/repos` route + `GithubRepoEntry` schema to openapi.yaml.

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-17 17:01:55 +00:00
Ruben Fiszel 8514347784 fix: serve populated jwks at /.well-known/jwks.json for vault (#8865)
* fix: serve populated jwks at /.well-known/jwks.json for vault

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

* fix: gate jwks route on private feature and use oidc_oss

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-17 14:11:09 +00:00
Alexander Petric b1a4c780dc feat: migrate slack OAuth to v2 (#8859)
* feat: [ee] migrate slack OAuth to v2

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

* fix: regenerate openapi-deref and make SlackToken.team optional

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

* chore: update ee-repo-ref to a28f3509d0aa7c0e17fa6dcb1d03d935a7a2a11c

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

Previous ee-repo-ref: d149fa6fcb90c4833bbdbd876c0466b5a6196c1c

New ee-repo-ref: a28f3509d0aa7c0e17fa6dcb1d03d935a7a2a11c

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-04-17 12:16:09 +00:00