mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
1fc355709c
* Db draft removal * refactor: drop unsaved-changes confirmation modal from editors * fix: remove nodraft from flow row edit link * fix: remove nodraft from app and raw app edit buttons * fix: remove nodraft from all edit links * fix: merge backend defaults into legacy autosaves to avoid spurious restore toast on raw apps * feat: add username column to draft table for user-scoped drafts * feat: add sync_drafts and list_users_with_draft_on_path endpoints * feat: add UserDraftDbSyncer service for bi-directional draft sync * feat: wire UserDraft.save through DbSyncer + conflict modal * refactor: gate useLocalStorageValue nested-update effect behind opt-in flag * refactor: move sync force flag from request-level to per-entry * feat: sync all userdraft kinds, switch draft owner to email FK, add id PK, scope draft list to readable paths * refactor: route draft permission check through authed.folders + RLS, drop client-supplied email * feat: support draft deletion via sync (value: null) with same conflict semantics * feat: surface other users' drafts in editors with diff+fork action * refactor: unify draft schema migrations and type kinds via DRAFT_KIND enum * perf: add (workspace_id, email, created_at) partial index for sync hot path * chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private. Previous ee-repo-ref: 55c19293232be379a3044eb78f677b545882ffd6 New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd Automated by sync-ee-ref workflow. * fix(userdraft): trigger sync on deep mutations via readFieldsRecursively * Rollback UserDraft * remove queuing logic * pushDrafts * refactor: remove draft sync layer and conflict modal * feat: add save_draft, list_drafts, get_draft routes * feat: add get_draft overlay to getScriptByPath * feat: extend get_draft overlay to flow, app, resource, variable, schedule, triggers * feat: support null value in save_draft for deletes * readLastSyncMap * feat: redirect /add pages to /edit/draft_uuid with new_draft flag * fix: inline get_draft query field instead of flattening * fix: drop dangling nobackenddraft assignment in flows edit * feat: include user drafts in list endpoints with is_draft flag * fix: prefix draft paths with u/{user} and seed editor state on new_draft * fix: route draft-only deletes through UserDraftDbSyncer on home page * feat: delete user drafts when their underlying item is deleted * fix: empty path seed on new_draft so friendly auto-name fires * feat: re-add Draft and Draft only badges on home page rows * fix: synthesize value wrapper on draft-only raw_app response * fix: tolerate missing latest-version on draft-only flow reload * fix: skip first observable change in DB sync effect to match LS persist * fix: remove URL-hash sync from script editor (already marked TEMP) * refactor: drop localStorage layer from UserDraft * refactor: drop vestigial LS-era code from UserDraft * feat: migrate localStorage drafts to DB on layout mount * fix: migrate session runtime + script view to per-user draft API * feat: add 'Reset to deployed' action on draft-loaded toast * feat: hide 'Reset to deployed' action when no deployed version exists * createCoalescingKeyedRunner * example ts doc * createDebouncerByKey * refactor: drop await on draft-delete in reset flows, refetch deployed directly * fix: bridge saved-draft shape to wire shape in apps/resources/variables loaders * feat: route UserDraftDbSyncer.save through debouncer + coalescing runner * feat: add immediate-save bypass that cancels pending debouncer + runner tasks * fix: seed UserDraft cell from spec defaultValue on acquire * fix: redirect /add routes at load phase to eliminate white flash * fix: drop +page.js files in /add routes that conflicted with +page.ts * refactor: send draft as separate .draft field instead of deep-merging onto deployed * feat: surface draft path in home list when user typed one different from URL * feat: add UserDraft.stopSync/restartSync, wire on script + low-code app /add init * fix: thread URL path into ScriptBuilder.stopSync (was using empty initialPath) * fix: also stopSync in route's new_draft branch + queue pre-acquire suspensions * feat: add AutosaveIndicator backed by reactive UserDraftDbSyncer.getState * refactor: drop draft-loaded toast in non-route editors, banner now compares draft vs deployed * fix: gate per-user draft-only rows in listings on include_draft_only flag * feat: flush pending draft saves via keepalive fetch on tab hide / pagehide * autosave indicator nits * fix: route create-vs-update on /add deploys; seed policy.execution_mode; sync script template * chore: add [draft-sync] console logs to trace script bootstrap autosave * fix: seed auto-generated path in script new-draft route to suppress Path widget's autosave-triggering mutation * fix: defer script restartSync until script.path lands (Path widget gated on $userStore + $workspaceStore) * fix: poll script.path via tick() until Path widget settles before restartSync * chore: log inferArgs underlying error on deploy to diagnose 'Could not parse code' toast * fix: wait for script.path to stabilize across two ticks before restartSync * revert: drop unsuccessful path-stabilization heuristics + leftover [draft-sync] logs * fix: seed new-draft script schema as emptySchema() so inferArgs doesn't trip on undefined properties * fix: heal legacy drafts with schema={} (no .properties) on deploy * autosave indicator * refactor(editors): drop UnsavedConfirmationModal mount + Show diff button * feat(drafts): collaboration banner, cross-tab conflict detection, raw app template picker - Other-users-drafts banner (Modal2): the deployed-overlay response now carries `other_drafts_users` (workspace usernames only, never emails); each row offers View JSON + Fork. Drops the standalone `listUsersWithDraftOnPath` endpoint; `getDraftForUser` now takes a workspace `username` query param (resolved to email server-side). - Cross-tab/browser save conflict detection: the syncer attaches `last_sync` to every save (defaults to non-force); on a `conflict` response it parks a snapshot in a reactive map. Each route mounts a `DraftSyncConflictModal` and seeds the per-tab `last_sync` via `recordRemoteSync(query, draft_saved_at)` on every `get_draft` load. Keepalive flush also respects optimistic concurrency. - Raw app template picker re-added after the /add ⇒ /edit refactor: framework (React 19 / 18 / Svelte 5), data table + schema config, and optional AI prompt — extracted into `RawAppTemplatePicker.svelte` and driven by `new_draft=true` on the edit route. * fix(drafts): suppress autosave during /add template seeding on script + raw app editors - ScriptBuilder: delay `restartSync` 500ms past `initContent` + stores- ready so the Path widget's `$workspaceStore && $userStore`-gated `initPath → reset → onMetaChange → bind:path` cascade lands inside the suspension window. Two `tick()` waits weren't enough — the bind:path mutation fired ~100ms after the prior `restartSync` and posted as a "user edit". - apps_raw route: suspend autosave on `new_draft=true` and resume only after the framework picker closes (via `onStart` or X dismissal), with a two-tick settle so the picker's seeded `files/runnables/data/policy` mirror to `draftHandle.draft` observably advances `lastSerialized` before sync re-arms. * fix(drafts): land /add redirects on the real workspace username, not "me" The `/add` → `/edit/u/{username}/draft_{uuid}` redirects ran during SvelteKit's load phase, BEFORE the (logged) layout's async `getUserExt` populated `userStore`. `get(userStore)?.username` returned undefined and fell back to the `'me'` placeholder on every fresh nav, producing `u/me/draft_{uuid}` paths instead of the user's real namespace — broke ownership checks against `authed.username` and silently scoped autosaves under the wrong path. Layout now persists `username` to localStorage on every successful `getUserExt`, and `getUsernameForNamespace` (new shared helper, used by all four `/add/+page.ts` files) reads the live store first, falls back to the cached value, and only then to `'me'` for true first-ever loads. * fix(drafts): key low-code app autosave on the URL path, not the empty string `AppEditor` keyed its `UserDraft.use` handle on `newApp ? '' : path` — a legacy leftover from when `/apps/add` was its own URL (no path). With the `/add` ⇒ `/edit/u/{user}/draft_{uuid}` redirect, `newApp=true` made autosaves land on the `('app', '')` row instead of the URL path: - The `apps/list?include_draft_only=true` query joins drafts onto `app.path`, surfacing drafts at the URL path. The empty-path row didn't match the user's URL so the draft never appeared in the home list. - Refreshing `/apps/edit/u/{user}/draft_{uuid}` re-fetches at the URL path with `?get_draft=true`, finds nothing, and 404s. Drop the ternary so the handle always uses `path` — the same as scripts/flows/raw_apps. The route's `?new_draft=true` branch already seeds the empty-template baseline, so there's no longer a "the draft sits under '' until first save" race to worry about. * fix(raw_app): propagate template picker X / Esc dismissal so autosave resumes The picker mounted `<Modal kind="X" open ...>` (one-way prop, not `bind:open`). When the user dismissed via X / Esc / click-outside, the inner Modal flipped its own local `open` to false (hiding the UI) but never wrote back to the picker's `open` $bindable. The route's `templatePicker → false` watcher — the one that calls `restartSync` two ticks after the picker closes — never fired, so autosave stayed suspended and the user's edits after dismissal were silently dropped. Switch the inner Modal to `bind:open` so the dismissal bubbles all the way up to the route's state. "Start without AI" already worked because its `onStart` handler explicitly sets the picker's `open = false`. * nit unused * fix(drafts): make the home-page View/Edit JSON action work on draft-only apps The "View/Edit JSON" entry on the home page called `AppService.getAppByPath` without `get_draft=true`, so for draft-only items at `u/{user}/draft_{uuid}` the backend 404'd with "App not found at path …". Pass `get_draft=true` and render the synthesized stand-in's editable shape: - App drafts come back as `{summary, value, path, policy, ...}` — `value` is the App definition the editor was working on; show that. - Raw-app drafts come back as the flattened `{files, runnables, data, summary, policy, ...}` with no nested `value`; show the whole shape. On save, draft-only items can't go through `updateApp` (no deployed row). Route the edit through `UserDraftDbSyncer.save` (with `immediate: true` so `await` resolves after the POST lands) and relabel the button "Save draft" + Save icon. Deployed items keep the existing "Deploy" flow unchanged. * fix(drafts): render the right shape in View/Edit JSON for draft-only items The previous fix landed `fapp.value` into the editor, but the deployed-overlay flattens the bare editable shape into `inner`/the top-level response — drafts have no nested `.value`. So: - App drafts (`{grid, breakpoints, hiddenInlineScripts, …}`) rendered as empty (`fapp.value` was undefined). - Raw-app drafts 404'd outright: `get_draft=true` with no `rawApp` flag can't tell which draft kind to look up, defaults to `app`, doesn't find one. Thread the row's `raw_app` flag from AppRow → `appExport.open(path, rawApp)` → `getAppByPath({..., rawApp})` so raw-app drafts resolve to the right `UserDraftItemKind`. Read `fapp.draft` (the bare editable shape from `fetch_draft_only`) into the JSON editor for draft-only items — clean payload, no `is_draft` / `no_deployed` / overlay noise. Save the same bare shape back through the syncer so the regular editor reads it unchanged on the next mount. * fix(drafts): skip public-secret-URL fetch in the Deploy drawer for draft-only apps Opening the Deploy drawer on a `/edit/u/{user}/draft_{uuid}` app fired `AppService.getPublicSecretOfApp` immediately because the gating effect only checked `appPath != ''` + `savedApp`. The `/secret_of/{path}` route plain-SELECTs `app.id`, so a draft-only path 404'd with "App not found at name …" and the public-URL ClipboardPanel spun forever waiting on `secretUrl`. Thread the existing `newApp` signal (already on `AppEditorHeader` / `RawAppEditorHeader`) into `AppEditorHeaderDeploy`, gate the fetch behind `!newApp`, and render the existing "Deploy this app once to get the public secret URL" placeholder instead of the spinner for draft-only items. * fix(drafts): disable Diff button on draft-only items across the 4 editors Diff has no baseline to compare against on draft-only items — the button used to be gated by the pre-PR `/add` route's own state, but the `/add → /edit` redirect landed everything under the regular `/edit` page where the gate was missing. - ScriptBuilder: gate the topbar Diff on `savedScript.no_deployed`; seed `no_deployed: true` on the route's `new_draft` empty NewScript so the gate fires before the first deploy. - FlowBuilder: gate the topbar Diff on `newFlow` (route already sets it from `backendFlow.no_deployed` and the new-draft branch). - AppEditorHeader: gate both the "Diff" dropdown action and the Deploy-drawer's "Diff" button on `newApp`. - RawAppEditorHeader: gate the topbar Diff + the Deploy-drawer's "Diff" button on `newApp`. Each gate also rewrites the tooltip ("Deploy this … once to compare against the deployed version") so the hover state explains why. * fix(drafts): disable the "No login required" toggle on draft-only apps Flipping the toggle called `setPublishState`, which POSTs the new `policy` through `AppService.updateApp` — that handler's `UPDATE app ... RETURNING path` finds nothing on a draft-only path and `not_found_if_none` 404s with "App not found at name …" (apps.rs:1975). Gate the Toggle on `!newApp` too so the user has to deploy once before configuring the publish state. * refactor(drafts): drop dead draft_path field from list responses The draft-only listing branches in scripts/flows/apps computed a `draft_path` from the draft JSON (when the user-typed path differed from the URL's autogenerated `u/{user}/draft_{uuid}`), and `{Script,Flow,App} Row.svelte` preferred it over `path` for the row title. In practice that path is never written: the app, raw-app and flow editors all warn "Deploy the X to make the path change effective" — the rename only lands on deploy, never in the draft. So the field is always None and the home rows always show the autogenerated slot anyway. Drop the field from the three `Listable*` structs, the three draft-only push sites, the three OpenAPI response schemas, and the three frontend row components. Client regenerated. * fix(drafts): seed a friendly name on /flows/add The flow route passed `initialPath={page.params.path ?? ''}` to FlowBuilder, so on the `/flows/add → /flows/edit/u/{user}/draft_{uuid}` redirect the Path widget's `initPath` saw a non-empty `initialPath` and skipped the `reset()` branch that auto-generates the friendly `<random_adj>_flow` name. The other three editors all clear `initialPath` in their `new_draft` branch for exactly this reason. Track `initialPath` as route-owned state (defaults to the URL path) and clear it to '' inside the `new_draft` branch, then bind it through to FlowBuilder so any post-deploy update from the editor still propagates. * feat(drafts): render friendly user-typed path on home list for all 4 kinds Reinstate `draft_path` on `Listable{Script,Flow,App}` so the home rows prefer the user-typed name over the autogenerated `u/{user}/draft_{uuid}` URL slot, with two source rules — one per how each editor wires the Path widget: - Scripts already work: `ScriptBuilder` binds the Path widget directly to `script.path`, so the typed path round-trips through the draft JSON's own `path` field. Backend extracts `v["path"]` when it differs from `row.path`. - Flows / apps / raw apps don't write the typed path into the autosaved value (`Flow.path` is one-way-bound to `$pathStore`; the bare `App` / raw-app value has no `path` field at all). Introduce an explicit `draft_path` field on the draft JSON, written by the editor ONLY when the typed path differs from the deployed/seeded `savedX.path`: - FlowBuilder: $effect on `$pathStore` mutates `flow.draft_path`. - AppEditorHeader: $effect on `newEditedPath` mutates `$app.draft_path`. - RawAppEditorHeader: $effect surfaces `pendingDraftPath` up via the bind chain (RawAppEditor → route); the route's draftHandle.draft spread includes `draft_path` when set. Backend extracts `v["draft_path"]` and `None` when unchanged or after deploy (deploy clears the whole draft, so the field naturally disappears post-deploy without bookkeeping). Flow route's `new_draft` branch now stops sync around the Path widget cascade, with a 700ms scheduled `restartSync` (mirrors the existing scripts/apps/raw_apps stoppers) — the new draft_path mutation lands inside that window so `/flows/add` no longer fires an autosave before the user's first edit. openapi/sqlx regenerated. * fix(drafts): preserve the user-typed draft_path on reload of draft-only items The flow / app / raw-app editors all dropped the saved `draft_path` back to the URL's `u/{user}/draft_{uuid}` slot the moment the user reloaded a draft-only edit page: the route sourced the Path widget's initial path from `page.params.path` instead of the previously-saved `draft_path`, and the first user edit then mirrored that URL path back into the autosaved draft — silently overwriting the friendly name in both the row and the editor. - Flow route: after computing `effectiveFlow`, override `flowInitialPath` with `effectiveFlow.draft_path` when set. - App route: pass `newPath={(app.value as any)?.draft_path ?? app.path}` through to `AppEditor`; AppEditorHeader's `newEditedPath` default now prefers a non-empty `newPath` over the random `<adj>_app` seed (the `newApp && !newPath` branch keeps the `/apps/add` friendly auto-name). - Raw-app route: surface `savedRawAppDraft.draft_path` onto `backendApp` so the `extractRawApp` path seeds `newPath` with the friendly name. Reload + a subsequent edit now leaves `draft_path` intact for all three kinds; verified end-to-end via the `/drafts/get_draft/...` endpoint. * fix(ui): default Modal2 target to 'body' so omitting the prop doesn't throw Modal2 defaulted `target = ''` and forwarded it to `Portal`, which calls `document.querySelector(target)` — an empty selector throws "Failed to execute 'querySelector' on 'Document': The provided selector is empty" and the modal silently fails to mount. That's why `OtherUsersDraftsModal` (and `DraftSyncConflictModal`) never appeared on editors where another user had a draft — both omit the `target` prop. Other Modal2 callers (StorageSettings, CriticalAlert, CustomInstanceDbWizardModal, …) pass an explicit `target="#content"` and were unaffected. Match Portal's own default of `'body'` so omitting the prop is now a no-op rather than a runtime throw. * fix(drafts): Reset to deployed no longer resurrects the draft The toast's "Reset to deployed" callback POSTed `value: null` to the syncer, then handed control to the route's `onResetToDeployed` (which wipes the in-memory handle and reloads the deployed payload via `getDraft: false`). Both writes flowed through the reactive sync effect: the wipe scheduled a delete, the reload scheduled a re-save of the deployed value as the new draft. Coalescing collapsed them and the draft came back — making the "discard" action effectively a no-op. Wrap the whole callback in `UserDraft.stopSync` / `restartSync`. The explicit `value: null` POST still goes through (it's a direct `UserDraftDbSyncer.save` that doesn't depend on the reactive effect), the route's wipe-then-reload mutations advance `lastSerialized` silently under suspension, and the next user edit (after two ticks past the deployed-seed write) is the first real save again. * ui nit * feat(drafts): autosave-indicator popover with Reset-to-deployed action Click the cloud icon → popover with "All changes are saved as a draft on the server. The draft is per-user — your teammates' editors keep their own." When the editor isn't on a draft-only path AND the user has a draft (UserDraft.has returns true), a "Reset to deployed" button mirrors the load-time toast action — stops sync, POSTs `value: null`, runs the route's reload-without-draft callback, restarts sync past two ticks so the deployed-seed write doesn't resurrect the draft. Threaded `onResetToDeployed` from each route down to its builder (ScriptBuilder / FlowBuilder / AppEditorHeader / RawAppEditorHeader) and into the indicator. `draftOnly` is wired from `savedScript.no_deployed` / `newFlow` / `newApp` so the action hides where there's nothing to fall back to. The indicator's trigger now has a hover affordance + matches Portal's default target ('body') via Modal2's earlier fix. * fix(drafts): wait for the fork POST to land before navigating OtherUsersDraftsModal's Fork action called UserDraft.save, which routes through the autosave debouncer (1500ms). The subsequent goto fired within the same tick, so the destination editor's get_draft=true read ran before the POST landed and 404'd — refreshing worked because by then the debounced save had fired. Call UserDraftDbSyncer.save with immediate: true and await it. The syncer cancels any queued debouncer task for the key and resolves the promise only after the POST completes, so the route load can find the forked draft on the first try. * fix(drafts): conflict detection — keep last_sync map tab-local instead of in localStorage Two tabs editing the same draft both load with last_sync = T0. Tab-1 saves; the server accepts, returns T1, and the syncer wrote T1 into localStorage. Tab-2 then tries to save: it reads the SHARED localStorage map, sees T1 instead of its own baseline T0, sends last_sync = T1, and the backend's WHERE clause (`created_at <= last_sync`) is true → tab-2 clobbers tab-1's edit without ever seeing a conflict. Move the map to tab-local memory (`new Map<string, …>`). Reload of the tab now starts with an empty map; that's fine because the editor's load path calls `recordRemoteSync(query, draft_saved_at)` right after `get_draft=true` returns, reseeding from the authoritative server timestamp before any user edit could fire a save. * fix(drafts): OtherUsersDraftsModal — close on Fork, don't leak clicks through nested JSON Two bugs in the per-editor "another user has a draft" banner: - Fork landed the immediate save but didn't close the banner before navigating. Svelte hadn't torn down the previous route's components by the time goto returned, so the banner lingered on top of the destination editor. Comment the explicit isOpen=false on the happy path so it's clear it MUST run before goto. - Clicking anywhere on the screen while the View JSON drilldown was open closed the underlying banner too. Modal2's clickOutside action fired on every Modal2 instance — both the JSON modal and the underlying banner — because both attach their own listener at the document level. Add `closeOnOutsideClick` opt-out on Modal2 and pass `closeOnOutsideClick={!jsonOpen}` to the outer modal so clicks outside the JSON drilldown only close the drilldown. Drive-by: Modal2's keydown handler now ignores Escape when its own isOpen is false (was a no-op closer that would still preventDefault on every key press, swallowing key events for any siblings). * fix(drafts): conflict modal wording — drafts are user-scoped, not teammate-scoped * fix(drafts): defer reset-to-deployed restart until first user interaction Two-tick `restartSync` was too aggressive: editor remounts emit a tail of cascading writes (Monaco setValue acks, schema re-infer, UI Builder iframe handshakes, schedule-config recomputes, …) that land well after two ticks and would clobber the just-deleted draft with an upsert of the deployed value — making "Reset to deployed" a no-op in practice, the user kept seeing the draft come back. Centralise the suspension lifecycle in a new `runResetToDeployed` helper. It stopSyncs around the reset, POSTs the explicit delete, runs the route's wipe-and-reload, and then arms a one-shot listener on document keydown / input / pointerdown that restartSyncs on the user's next real interaction. A 5-second fallback re-arms sync if the user walks away without touching the editor, so suspensions don't leak. Use it from both the load-time toast (`notifyDraftLoaded`) and the autosave-indicator popover so the two stay in sync — fixes both entry points. * indicator ui nits * fix(drafts): split tab-switch and unload flushes — kill self-conflict on visibility change The single keepalive flush bound to both `visibilitychange → hidden` and `pagehide` self-conflicted on tab switch: visibilitychange fires on every tab/app switch with the page still alive, the keepalive POST advanced the server's `created_at` to a fresh `now()`, the client discarded the response (no listener), the local `lastSync` stayed at the old value, and the next foreground autosave sent that stale timestamp → server saw `created_at > last_sync` → conflict modal for the user's own background-tab write. A still-pending debouncer task made it worse: it fired a second runner POST after the keepalive with the same stale `last_sync`, the second self-conflicted too. Split into two paths: - `visibilitychange → hidden` → `flushOnVisibilityHidden`: route through the normal runner pipeline. The page is alive, so the response can land and `setLastSync` keeps the baseline current. Call `debouncer.cancel(key)` first so a queued keystroke can't double-fire with the same stale `last_sync`. - `pagehide` → `flushOnPageHide`: keep the `keepalive: true` raw fetch for the genuinely-going-away case (the JS context is torn down, the response is necessarily discarded). Same `debouncer.cancel(key)` guard. On the next mount, the route's `recordRemoteSync(query, draft_saved_at)` reseeds `lastSync` from authoritative server state before any user edit can fire a save. * fix(drafts): drop the visibilitychange flush — debouncer keeps running on hidden tabs Tab switching just hides the page; the JS context survives and the debouncer's `setTimeout` keeps counting down. When it fires, the runner POSTs normally and the server's response updates `lastSync`. There's nothing left for a visibilitychange-driven flush to do that the ordinary pipeline doesn't already handle, and adding one only creates extra POSTs to reason about. `pagehide` remains the single trigger for the keepalive flush — that's the case where the JS context is actually being torn down and the runner's pending fetch would otherwise be killed mid-flight. * nit * refactor(drafts): drop LS-era pipeline; backend is canonical on load The PR's iteration left behind a meta/staleness pipeline carried over from the localStorage era — per-rev tracking, a LocalDraftStaleModal, a 'Restored from local storage' toast, and a localDraft-vs-backend comparison branch in every editor loader. With drafts now living in the DB and the optimistic-concurrency lastSync check handling divergence, that whole stack is dead weight. Worse, the comparison branch caused 'Load from server' in the conflict modal to do nothing: the loader preferred the in-memory cell over the backend, so the user-clicked 'load from server' just re-displayed the local edits AND fired two confusing toasts (Restored from local storage + Loaded your saved draft). The rip: * userDraft.svelte.ts: drop UserDraftMeta, StoredDraft.meta, checkStaleness, UserDraftStalenessCause, normalizeForCompare, localDraftDiffers, saveMeta, getMeta, setDraftAndMeta, setMeta, handle.meta/setDraftAndMeta/setMeta, force option. Handle is now just { draft }. * userDraftToast.ts: drop notifyRestoredFromLocal + RestoreFromLocalActions. Update copy. * LocalDraftStaleModal.svelte: deleted. * AppEditor.svelte: drop initialRevs prop and the firstMirror wipe-then-restore dance (it existed only to consume the meta-mismatch skip slot). * All 4 editor routes: backend is canonical on load — the in-memory cell is overwritten with the deployed+draft overlay, the syncer's seed guard swallows the first write so we don't POST it back. * VariableEditor / ResourceEditor: drop the staleness pipeline + rev bookkeeping; backend wins on open. * useTriggerDraftSync.svelte.ts: inline the JSON-normalize + deepEqual utility as a private cfgDiffers helper (kept for the form-vs-deployed dirty check, which is a genuine semantic compare, not LS legacy). * copilot core.ts / userDraftAdapter.ts: drop meta argument from saveAppDraft, loadAppDraftValue, write*Draft. Test assertions on getMeta dropped. Net: -22 typecheck errors, fewer moving parts, conflict modal works. EOF ) * refactor(drafts): remove dead endpoints + UserDraftDbSyncer.getLastSync The list_drafts and get_draft (own) routes were added during PR iteration and never wired up to any frontend caller — the editor overlay path uses the per-kind get-by-path getDraft query parameter, and the home page lists drafts via the per-kind list endpoints, not via /drafts. Drop both routes (+ sqlx caches + OpenAPI entries). UserDraftDbSyncer.getLastSync was a peep-hole for callers that never materialised — the per-tab lastSync map is only ever read by postSave internally, where the bookkeeping already lives inline. * refactor(drafts): extract DraftEditorModals trailer block The four editor routes (scripts/flows/apps/apps_raw) mounted an identical pair of trailer modals — DraftSyncConflictModal + OtherUsersDraftsModal — wrapped in the same guard chain and {#key path} remount. Lift the markup into one component; routes thread their itemKind, path, editPathFor, and loader callback. Pure markup extraction, no state ownership change. Drops the unused userStore import where the trailer was the only consumer. * refactor(drafts): UserDraft.useReactive — kill array-of-one boilerplate The script + flow routes both wanted a handle that re-keys when the URL path changes. UserDraft.use() can't do that (its opts getter is untracked), so each route hand-rolled the same useMany-array-of-one + proxy idiom: const handles = useMany(() => [{ kind, path: reactive }]) const handle = { get draft() { return handles[0]?.draft }, ... } Add UserDraft.useReactive(getSpec) that internally wraps useMany with a single spec and returns the stable proxy. Callers collapse to one line. * refactor(drafts): unify bootstrap suspension via armRestartOnFirstInteraction The flow and raw-app routes each rolled their own end-of-bootstrap resume: a 700ms setTimeout for flows and a templatePicker watcher with double-tick gating for raw-apps. Both are timing-fragile (the comments admit it) and drift from each other. armRestartOnFirstInteraction already existed in userDraftToast.ts for reset-to-deployed: keydown/input/pointerdown listeners (capture phase) that fire restartSync on the first real user touch, with a 5s belt-and-braces fallback. Export it and use it everywhere we'd previously have picked a magic number. For raw-apps this is a tiny behavioural change: the user's template choice now POSTs immediately (the pointerdown that picks the template also resumes sync, so the picker's onStart write rides the wake-up). Previously the choice only persisted on the user's NEXT edit. That's strictly better — navigating away preserves the choice now. * refactor(drafts): type App.draft_path; drop the as-any cast The audit asked for the three editors to converge on one draft_path injection pattern. For App and Flow, the in-builder $effect-mutates- the-store idiom is wedged into a shape that doesn't natively own the field — App's editor type genuinely has no draft_path so the writer had to cast through `as any`, and consumers downstream did the same. The minimum viable fix: declare draft_path on the local App type (it's already a field on the autosaved JSON). Lifting the writes upward into a route-side merger would mean restructuring the AppEditor mirror $effect and the FlowBuilder pathStore plumbing — larger change for the same shape, deferred to a follow-up. Flow already has the typed cast localised at one site. Will get the OpenAPI-level draft_path field as part of task 47 (drop as-any casts on backend overlay reads). * refactor(drafts): extract makeDraftAddLoad helper Four identical /add/+page.ts files differing only by the edit-route prefix. Lift the redirect into a factory, slim each entry point to two lines. * refactor(drafts): type UserDraftOverlay.other_drafts_users in the OpenAPI The backend response carried other_drafts_users on every get-by-path that supports the draft overlay, but the OpenAPI schema didn't declare the field. Each route had to cast the typed response to `any` to read it (and the sibling draft_saved_at), which obscured the real shape from the type system and rotted the discoverability of the draft surface. Add it to UserDraftOverlay. Frontend casts collapse to plain property reads in the three editor routes. * feat(drafts): list & open draft-only items for variables, resources, schedules, triggers For scripts/flows/apps the list and get-by-path endpoints already surface per-user drafts that have no deployed counterpart — that's what gates the home page from 404'ing on an AI-agent-created draft. Extend the same support to the other UserDraftItemKinds: Backend (list endpoints): - Add include_draft_only to ListVariableQuery, ListResourceQuery, ListScheduleQuery, StandardTriggerQuery (the latter covers the 11 trigger kinds via the generic TriggerCrud). - Append per-user draft rows whose path has no deployed row. Same gate as scripts/flows/apps: non-operators, page 0, no narrowing filters. Synthesis is per-kind: ListableVariable/Resource get field-for-field synthesis; ScheduleLight reads NewSchedule shape; Trigger<T> uses a best-effort JSON merge + serde_json::from_value (rows skipped on deserialize failure rather than failing the list). - Add draft_only: Option<bool> with sqlx(default) to each row type so it serializes as the column is opt-in. Backend (get-by-path endpoints): - get_variable, get_resource, get_schedule, get_trigger<T> fall back to fetch_draft_only when the deployed row is missing and the caller passed get_draft=true. Mirrors scripts/flows/apps. OpenAPI: - Shared IncludeDraftOnly parameter under components/parameters, wired into the 11 trigger list endpoints + listRawApps. Inline declarations on listVariable / listResource / listSchedules / listAzureTriggers. - draft_only field on ListableVariable, ListableResource, Schedule, TriggerExtraProperty. Frontend: - variables, resources, schedules, and the 10 trigger list pages (routes + 9 *_triggers) pass includeDraftOnly: true on the initial fetch and render <DraftBadge draft_only> on synthesized rows. Trigger pages got a sed/perl bulk update — pattern is the same across kinds. * fix(drafts): swap crypto.randomUUID() for the project's randomUUID helper crypto.randomUUID() is gated on a secure origin (HTTPS or localhost). Self-hosted Windmill instances often run on a bare HTTP origin or a LAN IP where the WebCrypto API is unavailable, so the /add redirect would throw before issuing the 307. Use the existing RFC4122 v4 helper in FlowChatManager that the rest of the codebase already imports for this exact reason. * fix(editor): leading-edge fire + max-wait cap on Monaco debounce The Editor debounced `onDidChangeModelContent` purely on the trailing edge — every keystroke rescheduled a 500ms timer, and uninterrupted typing held the bindable `code` prop stale until a pause. Stacked behind our 1.5s autosave debouncer that meant our clock didn't even start ticking until 500ms after the user paused, and the `code` binding never updated mid-burst for downstream consumers (lint, live preview, change listeners). Switch to leading + trailing + max-wait: * First keystroke of a burst fires `updateCode` synchronously, then stamps a wall-clock chain start. * Each subsequent keystroke (re)arms a trailing timer at `min(now + changeTimeout, chainStart + maxChangeTimeout)` — the cap is what makes continuous typing materialize at least once per maxChangeTimeout window instead of indefinitely. * When the trailing fires it resets the chain so the next keystroke after a pause is a fresh leading fire. New prop `maxChangeTimeout` (default 1000ms) sits next to the existing `changeTimeout` (default 500ms). Dispose path clears the chain stamp alongside the timer. * feat(drafts): wire Ctrl/Cmd+S to flush the pending autosave immediately Each builder already had a Ctrl/Cmd+S keybinding routed through a saveDraft() no-op left over from the LS-era — the comment said "persistence happens via the page-level UserDraft autosave" but the shortcut was the user's only way to actually force a save without waiting for the 1.5s debounce. Restore the intent. * UserDraftDbSyncer.flush({ workspace, itemKind, path }) — new method that re-submits whatever's queued in pendingSaveOpts with immediate: true. No-op when nothing's pending. * Editor.svelte.flushPendingChanges() — exposes a synchronous updateCode() with chain reset, so callers can drain Monaco's own trailing debounce before asking the syncer to flush. Without this step a Ctrl+S within ~500ms of typing would POST the pre-burst content. * ScriptBuilder.saveDraft() — editor?.flushPendingChanges() → await tick() → UserDraftDbSyncer.flush(). Toast on result. * FlowBuilder.saveDraft() — no direct Monaco ref (flows have many per-module editors); just flushes the syncer. Editor.svelte's new 1s max-wait cap means at most the last <1s of typing in a module Monaco won't be in this POST; it follows in the next autosave round. * RawAppEditor.handleKeydown — adds a 's' case that flushes before the focus guard, so the shortcut fires regardless of where focus is in the editor pane. * fix(drafts): low-code apps — drop spurious autosave on /edit + remount on Load from server Two bugs in low-code app editor (raw apps use a separate code path): 1. Every /edit visit looked like an autosave because loadApp() called UserDraft.discard('app', path, undefined). The comment claimed "this load doesn't POST" but discard always POSTs value: null server-side — that surfaced as a DELETE-my-draft on every page load AND a flash in the AutosaveIndicator. The discard was originally intended to wipe the in-memory cell so AppEditor remounts "fresh". But the path-change $effect upstream already sets app = undefined before each loadApp, which unmounts AppEditor and releases the handle's entry — so a remount via app = backendApp naturally starts with an empty handle. Drop the discard. 2. The conflict modal's "Load from server" called loadApp() but didn't remount AppEditor. Since AppEditor's stateApp is captured once at mount and doesn't react to prop changes, the editor kept showing the conflicting local edits even after a successful reload. Wrap the onLoadFromServer to await loadApp() then bump redraw to force a fresh mount. * feat(drafts): home-page Draft badge — show user-initial circles, drop the '+' The home-page Draft badge previously showed '+Draft' as a flat label. Add per-user awareness: up to 3 user-initial circles render to the left of the label, ordered alphabetically; with 4+ users we collapse to the first 2 + a '+N' overflow circle so rows stay compact. Backend: * New `DraftUserRef { username: Option<String> }` in windmill-types::user_drafts, re-exported from windmill-common so the list endpoints in scripts/flows/apps crates share one import path (windmill-types/windmill-common can't be reordered without a cycle). * ListableScript / ListableFlow / ListableApp gain a `draft_users: Option<sqlx::types::Json<Vec<DraftUserRef>>>` field. The list SQL adds a per-row subquery `SELECT json_agg(...) FROM draft d LEFT JOIN usr u ...` that aggregates the workspace users with a per-user draft at this path. NULL (no drafts) decodes to None; LEFT JOIN against `usr` lets orphaned drafts (user removed from workspace) still surface with username = None. * Synthesized draft-only rows set draft_users to a single-element vector with the authed user (those rows come from `email = $2`). OpenAPI: `draft_users` added to listScripts / listFlows / ListableApp response shapes as an array of `{ username }` with nullable username. Frontend DraftBadge: * Accepts `draft_users: { username?: string | null }[]`. Renders up to MAX_CIRCLES (3) initial circles; at 4+ users renders first 2 + a gray '+N' overflow circle. * Initials: 'john.doe'/'john_doe' → 'JD', 'alice' → 'AL', the legacy NULL-email row → '?'. * Color picked deterministically from a 6-entry palette so the same user gets the same circle color across rows. * Label is now just 'Draft' (dropped the '+'). 'Draft only' is unchanged. * Tooltip lists every user in full. ScriptRow / FlowRow / AppRow thread `draft_users` through their prop types and pass it to DraftBadge. * fix(drafts): suppress 'You have unsaved changes' banner when deployed baseline is null A brand-new variable/resource/trigger (no deployed row yet) has `getDeployed() == null`, but the caller's `show` prop is computed off `current != deployed` which is trivially true while the user types. Result: the banner appeared with 'Show diff' (no-op — the drawer early-returns on null deployed) and a 'Discard' that's semantically backwards (there's nothing to revert to). Gate `show` internally on `getDeployed() != null`. The check sits in the banner rather than each caller because every caller would otherwise need the same boilerplate guard. * fix(drafts): hide LocalDraftBanner when deployed and current match the DiffDrawer's compare Earlier I gated the banner on `getDeployed() != null`, but the user still saw it fire on entries where 'Show diff' opens to 'No changes detected'. That means `show` (the caller's coarse dirty check) flagged a difference the DiffDrawer treats as a no-op — typically toggle defaults (`false ↔ undefined`), removed empty arrays, or key-ordering noise that `cleanValueProperties + orderedYamlStringify` collapses. Replicate the drawer's comparison inside the banner: stringify both sides through the same pipeline and only render when the keys differ. A single `diffKey()` helper keeps the logic local; the catch-and-empty fallback survives a non-serializable side rather than throwing. * ui(drafts): nest user-initial circles inside the Draft badge Previously the circles sat alongside the Badge in a parent flex container; the result read as two separate UI elements. The Badge component already exposes its children as a snippet rendered inside its own flex row, so moving the circles into it makes them feel like part of the same chip. Knock-on tweaks: shrunk the circles from h-4/w-4 to h-3.5/w-3.5 so the badge stays compact, and tinted each circle's ring with the badge's indigo palette (instead of plain white) so the overlap reads as a deliberate stack rather than dots floating on top of the chip. * feat(drafts): drop the authed user's circle, mark own drafts with a '*' suffix Three tweaks to the home-page Draft badge: 1. Filter the authed user out of `draft_users` before rendering circles. The row already signals 'this user has a draft' via the asterisk (below), so a circle for them would be redundant noise. New `currentUsername` prop on DraftBadge — pass `$userStore?.username` from each row. The tooltip still lists every user (with `(you)` next to the authed one) so the full picture is one hover away. 2. The badge already showed whenever `is_draft || draft_users.length > 0` (per-user OR any-user). Spelled the rationale out in a comment — no logic change. 3. Append '*' to the displayed summary when `is_draft` is true. Falls back to `draft_path`/`path` when summary is empty so the marker never decorates an empty string. Threaded the same expression into ScriptRow / FlowRow / AppRow. Slice/overflow math now keys on the post-filter `otherUsers` list, so dropping the authed user doesn't silently shrink the visible count (e.g. 3 users incl. self → 2 circles, not 1 circle + a '+1' bubble). * feat(drafts): clone per-user drafts when forking a workspace `clone_workspace_data` clones every other workspace-scoped table on fork creation (resources, variables, scripts, flows, apps, raw apps, triggers, schedules) but quietly dropped the `draft` table. With per-user drafts that meant any open editor in the parent lost its pending edits the moment a fork was created — surprising and inconsistent with how forks treat the deployed surface. New `clone_drafts` mirrors the existing clone helpers: a single INSERT...SELECT into the target workspace, preserving `path`, `typ`, `value`, `created_at`, and `email`. The `email` FK targets `password.email` which is instance-scoped so it carries across workspaces without remap. `created_at` is preserved on purpose so the per-tab `last_sync` baseline lines up with the parent's timeline — otherwise the fork's next autosave would race a stale `last_sync` and trip the conflict modal on every cloned draft. Plain INSERT (not UPSERT) is safe because the fork target is empty at create time; no conflict against the partial unique indexes (`draft_pkey_with_user` / `draft_pkey_legacy`). The synthetic BIGSERIAL `id` PK is regenerated by the default so it stays out of the column list. * ui(drafts): pin the authed user to the first circle instead of hiding them Previously the authed user was filtered out of the circle row entirely on the theory that the row's '*' suffix already signalled 'this user has a draft'. New requirement: they should always lead the circle row when they have a draft so the visual half of the signal lines up across rows (consistent leading-slot identity, easy scan). Switch from a filter to a sort: `orderedUsers` finds the authed user in `draft_users` and splices them to index 0; everyone else keeps the backend's alphabetical order behind. Slice/overflow math now keys on `orderedUsers`, which guarantees the authed user never falls into the '+N' bubble — they're at position 0 and the slice keeps the head. The popover's '(you)' annotation moves to the circle's title attr too, so hovering the leading circle confirms the identity. * feat(drafts): drop draft_only column from script/flow/app Drafts now live in the `draft` table exclusively — `draft_only` stubs in script/flow/app are redundant. Migration `INSERT INTO draft ... ON CONFLICT (workspace_id, path, typ) WHERE email IS NULL DO NOTHING` so real per-user drafts already at the same path are preserved; only rare stubs that lost their draft get a synthesised workspace-level row. Stubs are then deleted (FKs cascade to *_version) and the column is dropped. List endpoints keep a synthesised `draft_only: true` on rows sourced from the draft table itself (sqlx default on the struct field). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ui(drafts): surface draft state in AutosaveIndicator instead of toast+auto-modal The "Loaded your saved draft" toast and the auto-opening OtherUsersDraftsModal both surprised users on every editor mount. Move both signals into the AutosaveIndicator label: "Loaded from draft" or "Others are working on this {kind}" (priority) sits where Saving/Saved do, with a one-shot light-green flash behind the indicator that fades to transparent. Saving/Saved still win when they fire. The popover gains a "See others' drafts" button that flips the modal open on demand; the modal itself is now externally controlled via a bindable \`isOpen\` threaded through DraftEditorModals. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ui(drafts): per-user View JSON / Fork actions in DraftBadge popover Hover popover used to be a plain text list of usernames. Now each row gets a colored circle icon + name + "(you)" for the authed user, and every OTHER user's row carries View JSON / Fork buttons mirroring the OtherUsersDraftsModal. For draft-only entries owned solely by the authed user, the popover ends with "Only you can see this {kind}" so the row's privacy is obvious. ScriptRow / FlowRow / AppRow thread workspace + itemKind + path + editPathFor through; AppRow switches between app / raw_app on app.raw_app. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * nit * fix(drafts): clone only the forker's per-user drafts on workspace fork clone_drafts copied every user's drafts, but only the forker gets added to the fork's usr table. Drafts owned by absent users LEFT-JOIN to NULL in the home page's draft_users aggregate, surfacing as multiple legacy-style rows at one path and crashing the popover with each_key_duplicate. Filter the clone to email = forker OR email IS NULL, and key the popover's #each by index defensively so future legacy collisions can't crash the page either. Also re-adds `draft_only: None` to NewScript/CreateFlowBody literals in tests — the auto-generated windmill-api-client still carries the field and the previous commit dropped them too aggressively. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): always populate other_drafts_users in maybe_overlay_draft Reset-to-deployed reloads the deployed payload with get_draft=false, which made the backend return other_drafts_users=[]. The route then reassigned otherDraftsUsers to the empty list, dropping the count to 0 and hiding "See others' drafts" in the AutosaveIndicator popover — but the other users' drafts hadn't actually gone anywhere. Fetch the list independently of get_draft so the popover stays accurate across reset reloads. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(drafts): alert user when their draft is older than the latest deploy Open a modal on editor mount when the per-user draft was saved before the latest deploy at the same path — i.e. a teammate deployed a new version while this user's draft was sitting. Two choices: discard the stale draft and pick up the deploy, or keep editing the older draft. DraftEditorModals computes the staleness from the timestamps each route threads in (script.created_at, flow.edited_at, app_version.created_at) and the "Load latest deploy" callback reuses the route's existing reset-to-deployed logic. Wired for script / flow / app / raw_app editors; trigger / resource / variable drawer editors follow a different pattern and aren't covered here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): deploy only wipes the deployer's draft, not everyone else's Script / flow / app deploys ran an unconditional DELETE on every draft at the path, so a teammate's deploy silently destroyed any other user's pending draft. After the wipe, the other user's tab kept auto-saving — re-creating the row at a NOW timestamp newer than the deploy — and StaleDraftModal never fired because draft_saved_at had been bumped past the deploy. Filter the DELETE to email = deployer (plus the legacy NULL row), so other users' drafts persist and the stale-draft prompt actually fires on their next reload. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): surface save failures in AutosaveIndicator instead of pretending Saved postSave caught network errors with `console.error` and let the runner finish normally. The indicator read the saving → none transition as a successful save and flashed "Saved" even when the request had thrown. Track failed keys in a SvelteMap, expose `'failed'` as a new UserDraftSyncState, render "Save failed" in red with a CloudOff icon. Failure clears on the next successful save for the same key, or when recordRemoteSync seeds a fresh authoritative timestamp. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): surface 'Save failed' inside the AutosaveIndicator popover too The popover used to repeat the cheerful "All changes are saved as a draft on the server..." copy even when the inline label said "Save failed", which read as contradictory. Add a red, text-xs warning at the top of the popover body when the sync state is `failed`, explaining that the latest edits didn't reach the server and that editing again retries the save. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): surface the actual error message in the AutosaveIndicator popover Replace the generic "your latest changes did not reach the server" copy with the real failure detail. The syncer now stores the extracted message in the failures map (formatSaveError walks body / message / statusText) and exposes it via the state handle's `failureMessage` getter. Popover renders it in red, monospaced, scrollable so a long server traceback doesn't blow out the popover. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard A `value: null` POST is a discard, not a save, but it ran through the same runner the indicator watched — so resetting to deployed flashed "Saving..." → "Saved", reading as "your draft just landed" while we were actually wiping it. Track in-flight discards in a SvelteSet, expose a distinct `'discarding'` UserDraftSyncState, and the indicator stays quiet for it: no spinner, no label change, and the `discarding → none` transition deliberately skips the "Saved" flash. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Revert "fix(drafts): suppress Saving/Saved indicator during a reset-to-deployed discard" This reverts commit625a47c5d2. * fix(drafts): flush pending autosaves when the editor hook unmounts SPA navigation doesn't fire `pagehide`, so a debounced edit (up to maxDebounceMs old) silently disappeared when the editor was unmounted mid-typing. `UserDraft.useMany`'s onDestroy now walks every acquired entry and fires `UserDraftDbSyncer.flush(query)` before releasing, re-submitting the pending opts with `immediate: true`. The POST rides the runner's own lifetime and survives the component teardown. `use` / `useReactive` are thin wrappers around `useMany` so they inherit the flush automatically. Editors that don't go through the hook (sessions' `ScriptEditorView`, `AppJsonEditor`, copilot adapter, DraftBadge fork action) only call `UserDraftDbSyncer.save` for one-shot operations and don't need lifecycle flush. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * nit * feat(ui): Modal2 fixedHeight='adaptive' sizes the modal to its content The fixed-height steps force either wasted whitespace or clipped content for small dialogs. `adaptive` emits no height rule (still capped by max-h-screen-80) so the modal hugs its content. Use it in StaleDraftModal, which only has two lines of copy and a button row. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(drafts): 'Create test drafts' button on the home page Dev/QA helper that seeds one per-user draft for every supported kind (script, flow, app, raw_app, trigger_schedule, resource, variable) at fixed u/{me}/draft_<kind> paths, so the draft surfaces (home badges, editors, stale-draft modal, others' drafts modal) can be exercised without hand-creating items. Re-clicking overwrites the same paths. Value shapes mirror what each editor's autosave writes, matching the backend list synthesizers that parse them back. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): dedupe app list rows when a path holds both app and raw_app drafts The apps list LEFT JOINed draft with typ IN ('app', 'raw_app') for the is_draft flag — a path holding BOTH kinds for the same user (easy to hit: open a raw-app draft path in the regular app editor and its autosave writes the second kind) fanned the row out into two identical entries and crashed the home list with each_key_duplicate. Join a DISTINCT (path, workspace_id) subquery instead. Same dedup for the draft-only synthesis block via DISTINCT ON (path) keeping the most recently saved kind. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(drafts): asterisk on resource/variable/schedule/trigger rows with own draft Add an is_draft flag to ListableVariable / ListableResource / ScheduleLight / BaseTrigger list rows — a scalar EXISTS subquery on the draft table for the authed email (no join, so no row fan-out), plus is_draft: true on the synthesized draft-only rows. The list pages (variables, resources, schedules, all trigger kinds) append `*` to the displayed name when set, mirroring the home page's convention. Also fixes draft-only resources never appearing on the resources page: the page always lists with resource_type_exclude=cache,state,app_theme (its tab split) and the synthesis gate bailed on any type filter. The gate now keeps synthesizing and applies resource_type / resource_type_exclude per-row against the draft JSON instead. list_triggers (trait default) takes an authed_email: Option<&str> — Some from the list endpoint, None from workspace export. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Revert "feat(drafts): 'Create test drafts' button on the home page" This reverts commit1f244a2a8b. * fix(drafts): P1 hardening — save authz, secret scrubbing, hot-path index 1. save_draft had no authorization check (a regression from the old create_draft's require_writer_of_path): any workspace member could plant drafts in another user's u/ namespace or unwritable folders, and those drafts get surfaced to every reader of the path (home circles, others'-drafts modal, View JSON / Fork). New require_can_write_path: admins; own u/ namespace; g/ namespace when in the group; f/ folders with the write/owner bit (with the same folder-claim refresh deploy endpoints use). Operators are rejected outright — they're excluded from every other draft surface. 2. Secret variable values were persisted in the draft table in plaintext. save_draft now blanks variable.value for is_secret drafts at write time (the editor never round-trips secret values anyway — it fetches with decrypt_secret=false), and a migration scrubs rows persisted before the guard. 3. fetch_other_drafts_users runs on every get-by-path request with (workspace_id, path, typ) and no email predicate — neither partial unique index covers it, so it seq-scanned a table that accumulates per-user autosaves across all workspaces. Add a plain btree index; it also serves get_draft_for_user's IS NOT DISTINCT FROM lookup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): Ctrl/Cmd+S flush narrates via the indicator, not a toast The "Draft saved" toast fired even with the network down — flush never rejects (postSave catches errors internally and routes them to the failures map), so the success branch always ran. Drop the toasts from the script / flow / raw-app Ctrl+S handlers; the AutosaveIndicator already narrates the flush truthfully (Saving... → Saved / Save failed in red). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): Ctrl/Cmd+S always flashes Saved in the indicator After dropping the toast, an explicit Ctrl/Cmd+S with nothing pending (the common case — autosave already landed everything) gave zero feedback: flush() no-ops when pendingSaveOpts is empty and no state transition fires. flush() now bumps a reactive per-key counter on completion (no-op path included), exposed as flushCount on the state handle; the AutosaveIndicator flashes "Saved" on the bump when the pipeline is idle. Real flushes keep narrating through Saving... → Saved / Save failed as before. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ui(drafts): Ctrl/Cmd+S replays the green backdrop flash on the indicator Decouple the one-shot light-green → transparent backdrop from the load hint label: triggerFlash() owns the keyed span (mounted only while the animation runs), and both the on-mount hints and the Ctrl/Cmd+S confirmation route through it. The flush bump fires after the POST lands, so a real flush flashes too — not just the no-op path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(drafts): 'Create test drafts' button on the home page Dev/QA helper that seeds one per-user draft for every supported kind (script, flow, app, raw_app, trigger_schedule, resource, variable) at fixed u/{me}/draft_<kind> paths, so the draft surfaces (home badges, editors, stale-draft modal, others' drafts modal) can be exercised without hand-creating items. Re-clicking overwrites the same paths. Value shapes mirror what each editor's autosave writes, matching the backend list synthesizers that parse them back. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): Ctrl/Cmd+S reaches the raw-app flush from every editor surface The raw-app window keydown handler never fired in practice: the file editor is a VS Code workbench in a same-origin iframe (keydowns don't cross documents) and the inline-script / YAML Monacos swallow Ctrl+S via addCommand. Two hooks: - attach a capture-phase keydown listener inside the iframe document on each load (no preventDefault — VS Code's own save still runs, we flush the pending autosave alongside it); - Editor.svelte / SimpleEditor.svelte re-broadcast their swallowed Ctrl+S as a `wm-monaco-save-shortcut` window event, which RawAppEditor listens for. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): editing a draft-only item opens create mode prefilled from the draft Variable / resource / schedule / trigger editors treated every loaded path as deployed and routed saves through the update endpoints, which 404 for draft-only items ("Resource not found at name ..."). The get-by-path responses already mark the case (`no_deployed` from fetch_draft_only) — editors now flip to create mode when it's set: - VariableEditor / ResourceEditor: existedInitially = !no_deployed - ScheduleEditorInner + all 10 trigger editor inners: loadTrigger / loadSchedule return { overlay, noDeployed } and openEdit sets edit = !noDeployed The form opens prefilled from the draft and deploys via create, whose endpoints already delete the creator's draft on success. (The "Could not load schedule: Not Found" half of the report was a stale dev backend — getSchedule?get_draft=true verified working on the current build.) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): leading-edge draft saves for raw apps (no double debounce) Raw-app file changes reach the parent already coalesced — the UI Builder iframe holds a ~1s trailing debounce on its rebuild and only posts setFiles when it fires. The syncer then stacked its own 1.5s trailing window on top, so the draft landed ~2.5s after the user stopped typing. The debouncer now supports a leading edge (run immediately when the key is idle and cooled down; later schedules in the window coalesce trailing with the max-wait ceiling, mirroring the classic editor's first-keystroke-materializes-immediately logic), and raw_app saves opt into it. The app build keeps its own trailing debounce inside the iframe — only draft persistence is affected. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ui(drafts): blue flash for load hints, green for save confirmations The backdrop flash now carries meaning: green = "your save landed" (Ctrl/Cmd+S), blue = informational on-mount hints ("Loaded from draft", "Others are working on this ..."). Color is passed as an inline CSS custom property the keyframe reads, so the single keyframe serves both variants. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Revert "fix(drafts): leading-edge draft saves for raw apps (no double debounce)" This reverts commit1b996fd73a. * feat(drafts): 'Enable auto-save' toggle in the AutosaveIndicator popover Browser-wide preference (default on, persisted in localStorage). While off, the reactive keystroke mirror never POSTs — saves marked `auto: true` park their latest opts in pendingSaveOpts instead of scheduling, and the unload keepalive flush is skipped, so nothing leaves the tab except explicit actions: Ctrl/Cmd+S flush (sends the parked latest content), discard / reset-to-deployed, fork, conflict overwrite. The indicator shows a muted cloud-off while disabled (the idle check-mark would otherwise read as "everything saved") and the popover copy explains the Ctrl/Cmd+S-only behavior. Re-enabling re-schedules every parked unsaved draft so edits made while off catch up immediately. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Revert "feat(drafts): 'Create test drafts' button on the home page" This reverts commitfd7013b399. * feat(drafts): Review & Deploy covers variables/resources/schedules/triggers The drafts review page only assembled scripts/flows/apps from three paginated list endpoints, so drafts of every other kind were invisible. New GET /w/{ws}/drafts/list returns every draft of the authed user in one query over the draft table, with a per-kind draft_only flag (deployed-table EXISTS per kind); getDraftItems switches to it, which also drops the 3×N-page fan-out. CompareDrafts renders the new kinds (icon via a UserDraftItemKind → layout-Kind mapping, gray kind badge, list-page edit links for drawer-based editors), diffs them through a generic overlay GET, and deploys them by replaying the editor save: create/update for variables and resources, saveScheduleFromCfg for schedules, the per-kind save*TriggerFromCfg helpers for the ten standalone trigger kinds. Also fixes two paths stale since the draft_only column removal: draft-only flows/apps now deploy via create (update 404s — there is no row anymore), and discard always deletes the draft row (the old delete-the-item branch 404'd for the same reason). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(drafts): optimistic asterisk while editing in list-page drawers The `*` suffix on variable/resource/schedule/trigger rows came from the server's is_draft flag, which only updates on a refetch — editing an item in the drawer didn't mark its row until much later. New localDraftHints module (SvelteSet-backed): editors publish their dirty state (the same condition that shows the "You have unsaved changes" banner) and the 13 list pages OR the hint into the asterisk condition, so the suffix appears the moment the form diverges and clears on discard/teardown. Wired once in useTriggerDraftSync (covers the schedule editor and all ten trigger editors) plus VariableEditor and ResourceEditor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ee repo * fix(drafts): draft hints persist past editor teardown, re-sync on reopen Clearing the optimistic asterisk on drawer close was wrong: the divergence the editor observed is autosaved server-side, so the draft outlives the drawer and the asterisk should too. Hints are now corrected rather than expired — while an editor is settled on an item it publishes the observed truth in both directions (divergence sets, sitting at the deployed baseline clears), so a draft discarded from another tab loses its stale asterisk the next time the item is opened. No teardown cleanup anywhere. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(drafts): list-page asterisk mirrors the editor's banner, not stale is_draft The asterisk was `is_draft || hint` — an OR can turn the asterisk on optimistically but can never turn it OFF, so after discarding a draft (or editing back to the deployed value) the stale server flag kept the asterisk until the next list refetch. Make the local hint a tri-state override instead: the editor publishes the live banner state (true/false) into a SvelteMap, and the list pages read `getLocalDraftHint(...) ?? is_draft` — the editor's observed truth wins over the stale server flag in both directions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): autosaves equal to the deployed value delete the draft instead When the user edits back to exactly the deployed value, the reactive autosave mirror used to persist a baseline-equal copy — a useless draft row that kept `is_draft` (and the list asterisk) on after refetch. Add a `discardIfEqualTo` baseline getter to `UserDraft.useMany` specs: when the cell's value deep-equals the deployed baseline, the mirror POSTs `value: null` (delete) instead of the value. The variable and resource editors pass their `initialStates` baseline, guarded on `existedInitially` — draft-only/new items have no deployed copy, so equality must never delete their only data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Draft encryption for secret variables * fix(drafts): discardIf predicate + deploys clear the asterisk and draft row Two follow-ups on the baseline-equal-autosave-deletes change: 1. `discardIfEqualTo` (baseline getter + raw deepEqual) becomes `discardIf` (predicate). Raw deepEqual reported spurious diffs after a refresh: drafts round-trip through JSON, which strips undefined-valued keys, so a restored draft (`{}`) never compared equal to the freshly built baseline (`{ labels: undefined }`) and the delete never fired. The editors now pass the SAME comparison that drives their "unsaved changes" banner — a new exported `draftValuesEqual` (JSON-normalized deep equality) used by both — so the banner and the synced draft can never disagree. 2. Truly saving (deploying) clears the asterisk and the draft row: - variable/resource editors: replace post-deploy `UserDraft.remove` (blanks the cell to `undefined`, which reads as dirty and keeps the banner + asterisk on) with `discard` to the just-saved state, and refresh `initialStates`/`existedInitially` so the editor settles clean. - trigger editors: `useTriggerDraftSync.discard` publishes the hint off explicitly — after a deploy the editor's `deployed()` baseline is stale, so the hint effect alone would keep the asterisk on. - Review & Deploy page: `deployDraft`/`discardDraft` clear the hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * revert encryption just for the resources part * fix(drafts): required const DRAFT_KIND on TriggerCrud; deploy/delete cover raw_app The TriggerCrud::user_draft_item_kind() default matched on TRIGGER_TYPE and panic!'d on any unmapped string — a runtime crash on the first draft save for a trigger that forgot to map. Replace it with a required associated const DRAFT_KIND, so a missing mapping is a compile error. user_draft_item_kind() now just returns Self::DRAFT_KIND; every impl (OSS + EE) declares the const. Also fix the app deploy/delete draft cleanup to cover raw_app: raw apps deploy and delete through the same internal path, but the cleanup filtered typ = 'app' only, leaving raw_app drafts dangling (create_app_internal apps.rs:1465, update path apps.rs:2077) or un-archived on delete (apps.rs:1687). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): deleting an item wipes every user's draft, not just the caller's Scripts/flows/apps already wiped all users' drafts on delete, but resources/variables/schedules/triggers called delete_user_draft (caller-scoped), so a teammate's draft on the just-deleted item lived on forever — surfacing through fetch_other_drafts_users with no item left to deploy onto. Add delete_all_drafts_for_path (all emails + the legacy NULL row) and use it in every delete handler; keep delete_user_draft for the discard-my-own-draft flow where the item lives on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(drafts): skip other-drafts query on non-editor reads (get_draft=false) maybe_overlay_draft ran fetch_other_drafts_users (a usr join) on every get-by-path, including worker/CLI reads of MB-scale flows & apps that pass get_draft=false and never render the draft overlay or "others editing" surfaces. Gate the query behind get_draft — only editor reads pay for it. Reset-to-deployed editor reloads still get it (they pass get_draft=true). (Eliminating the serde_json::to_value materialization of the deployed payload needs WithDraftOverlay to become generic over T, which is folded into the get-by-path choreography refactor.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(drafts): single-source the kind→table mapping via deployed_table() The kind→table dispatch lived in three places that could drift: the TriggerCrud string-match (already replaced by const DRAFT_KIND), the table_for_kind access-check map, and a hand-written draft_only CASE in list_drafts. Add UserDraftItemKind::deployed_table() as the single source (plus an ALL enumerator). table_for_kind now delegates to it, and the list_drafts draft_only CASE is generated from it at runtime (table names come from the closed enum, never user input — no injection). Drift between the access check and the existence check is now impossible by construction. Webhook and the native triggers (poll/cli/nextcloud/google/github) map to None: they have no path-keyed backing table and aren't draftable, so they report draft_only=true and use a path-only access check. This also fixes a latent bug where table_for_kind mapped native kinds to native_trigger, which has no `path` column — the access query `SELECT 1 FROM native_trigger WHERE path = $1` would have errored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ee repo * fix(drafts): close variable draft-secret laundering oracle (sentinel + rehydrate) save_draft encrypts secret variable values with the workspace key, but the ciphertext was round-tripped to the client and the deploy endpoints decrypted whatever $encrypted: ciphertext the client submitted (variables.rs create/update). Any workspace member who can write a variable path could take an arbitrary workspace-key ciphertext (another user's secret draft via GET /drafts/get with only path-read, or a deployed secret's stored value) and submit it as their own secret variable's value — the server decrypted it and, since they own the path, they read the plaintext back. That bypasses the audited decrypt_secret permission. Fix: the ciphertext never leaves the server. get_variable swaps a draft secret's $encrypted: value for an opaque $draft_secret sentinel (both the draft overlay and the draft-only inner stand-in). On deploy the client sends the sentinel back and the server rehydrates the plaintext from the caller's OWN draft row — the only ciphertext it ever decrypts is one it encrypted for this exact (workspace, path, email). A raw $encrypted: submitted by a client is now rejected outright. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): don't clobber a secret draft when autosaving the $draft_secret sentinel After reload the client holds the $draft_secret sentinel for a secret variable (never the ciphertext). Editing some OTHER field (description, labels) triggers an autosave carrying value="$draft_secret" — and save_draft's encrypt_secret_variable_value, seeing a non-empty, non-$encrypted: string, encrypted the literal sentinel, overwriting the real ciphertext in the draft row and losing the secret. Treat the sentinel as "secret unchanged": restore the $encrypted: ciphertext already stored in this user's draft row instead of encrypting the placeholder (falling back to empty only if there's no prior ciphertext). The new lookup reuses the same query shape as the deploy- time rehydrate, so no new offline cache entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Revert "$draft_secret" sentinel approach for variable draft secrets Reverts339c259fceandb2c38ef407. Instead of round-tripping a sentinel and rehydrating server-side, we close the laundering vector more simply by disabling cross-user draft visibility for triggers/resources/variables (next commit) — an attacker can no longer read another user's secret draft ciphertext to launder it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(drafts): keep drafts private to their owner for resource/variable/trigger kinds Replaces the reverted $draft_secret sentinel: instead of laundering-proofing the ciphertext round-trip, simply don't expose other users' drafts for the drawer kinds (resource/variable/triggers). A viewer can no longer obtain another user's secret-variable draft ciphertext, so it can't be laundered into plaintext via deploy. UserDraftItemKind::shares_drafts_across_users() — true only for script/flow/app/raw_app. maybe_overlay_draft skips other_drafts_users for non-sharing kinds, and get_draft_for_user (View JSON / Fork) returns 404 for them. Own-draft load/save is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(drafts): make the list-page asterisk hint a shadow of UserDraftDbSyncer The optimistic `*` hint was written by three open-editor publishers, so draft deletions that didn't go through an editor (banner discard, autosave-back-to-baseline, Review & Deploy) left a stale asterisk that a server refetch couldn't clear (the hint overrides is_draft). Move ownership to the syncer — the one choke point where a draft's existence actually changes: - postSave sets the hint on a saved write (value !== null) and clears it on a delete (null), so every syncer-routed delete clears it for free. - save() lights it optimistically when a real save is scheduled, so the asterisk still tracks the editor's banner without the debounce lag. The editors no longer SET the hint; they only CLEAR it when settled at the deployed baseline (so a draft discarded from another tab disappears on reopen). discardDraft drops its explicit clear (postSave covers it); deployDraft keeps one (it deletes server-side, bypassing the syncer). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(migrations): fold draft index + secret scrub into the base sync migration Merge 20260610095349_draft_workspace_path_typ_index and 20260610100018_scrub_secret_variable_drafts into the base 20260528143710_draft_user_sync_schema migration (the index creation + secret-draft scrub in .up, the index drop in .down; the scrub stays irreversible). 20260609165313_remove_draft_only remains standalone. Verified the full chain applies and reverts cleanly on a fresh DB. (Rewrites an already-applied migration — existing dev DBs need a reset.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(drafts): promote the get-by-path draft choreography to one helper The "Some(deployed) → overlay / None+get_draft → draft-only / None → 404" dance was copy-pasted across the get-by-path handlers and had drifted (different 404 text, the trigger one missing the draft-only fallback at first). Promote it to windmill_common::overlay_or_draft_only<T>, which takes the deployed entity as Option<T> and a per-route not_found closure. Converts scripts, flows, apps, schedules, and triggers onto it. Resources keeps its own (it runs an async explain_resource_perm_error on the 404 path) and variables keeps its own (secret-decrypt logic interleaved with the draft fetch) — both genuinely diverge from the common shape. (The serde_json::to_value elimination via a generic WithDraftOverlay<T>, and the list-only draft synthesis dedup, remain as follow-ups.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * perf(drafts): serialize the deployed overlay payload in one pass maybe_overlay_draft materialized the deployed entity into a serde_json::Value tree (serde_json::to_value) and then serialized that tree again into the response — two passes plus a full Value allocation over what can be an MB-scale flow or app, on every get-by-path (including get_draft=false worker/CLI reads). Hold WithDraftOverlay.inner as a boxed erased_serde::Serialize trait object instead, so the deployed payload flattens straight into the response in one pass. The struct stays non-generic, so the helper and all seven handler return types are unchanged; only the deployed type now needs Send + 'static (already true — they're owned rows; added 'static to TriggerCrud::Trigger to say so). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(drafts): one helper for the draft-only list synthesis query The "draft rows at paths with no deployed counterpart" query was copy-pasted into the variable / resource / schedule / trigger list handlers, each hardcoding its own typ literal and NOT EXISTS table — a drift hazard. Promote it to windmill_common::fetch_draft_only_list_rows, which derives the absence-check table from kind.deployed_table() (the same single source as the access check and draft_only flag). Each handler keeps its own include_draft_only gating and per-type row mapping (genuinely entity-specific); only the shared SQL is deduped. The trigger handler's prior generated-SQL version is folded in too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): route raw-app draft deploys through the raw-app endpoint [P1] deployDraft's raw-app guard was `kind === 'app' && rawApp`, but Review & Deploy passes `kind === 'raw_app'` (raw apps are their own DRAFT_KIND), so the guard never fired and the row fell into the visual-app branch. There `d.value` is undefined (a RawAppDraft has files/runnables/data, no `value`), so AppService.updateApp did a partial update — resetting policy to the publisher default, never bundling/deploying the files — the backend then deleted the user's raw_app draft rows, and the UI reported "deployed". The work-in-progress was destroyed without ever deploying. Route `kind === 'raw_app'` (or the editor's `app` + rawApp) through deployRawAppDraft. The now-unreachable `raw_app` arm of the visual-app branch is dropped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): allow draft saves for item-level extra_perms writers [P1] require_can_write_path only accepted namespace rules (own u/, member g/, writable f/), dropping the item-level extra_perms check the old create_draft had. A user granted write on e.g. u/alice/script via the Share dialog could still deploy it (the update endpoints go through RLS) but could no longer save a draft — and because the editors autosave continuously with no permission gate, editing a shared item produced a persistent "Save failed: you don't have write permission" and Ctrl/Cmd+S failures. Add the item-level fallback: when a deployed row exists at the path, check its extra_perms for a write grant (every deployed table has extra_perms; the table comes from the closed deployed_table() mapping). Draft-only items have no row and stay governed by the namespace rules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): pass rawApp on get-app for never-deployed raw-app drafts [P2] A raw app that has only ever been drafted has no `app` row, so get_app resolves the draft kind from the `rawApp` query param. getDraftDiffValues ("Show diff") and deployRawAppDraft both fetched with getDraft=true but without rawApp, so the backend looked up the visual-app draft kind, found nothing, and 404'd. Pass rawApp so the raw_app draft is found. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(drafts): surface the localStorage→DB migration with toasts migrateUserDraftsToDb already uploaded legacy "userdraft/..." entries and cleared them on success (and runs after the v1→userdraft normalizer). Add the user-facing surface: when real legacy entries are detected, show an info toast "Migrating local storage drafts ..."; on a per-draft failure show an error toast "Could not migrate draft <path> in workspace <X>" with a "Delete draft" action that drops the stuck localStorage entry (otherwise it retries every mount). Unparseable junk is still cleared silently up front, so the toast only fires for genuine drafts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(drafts): cover the autosave pipeline's pure-logic utilities [P2] The deleted draft tests left the new debouncer + coalescing runner — the core of the autosave pipeline — with zero coverage. Add vitest suites (16 cases) for debouncerByKey (debounce window, latest-task-wins, maxDebounceMs ceiling under a trickle, fresh-chain-after-fire, cancel, key independence) and coalescingRunner (immediate run when idle, coalesce burst to in-flight + latest, displaced-task drop, submitAndWait resolve/reject/displaced, cancel semantics, key independence). Broader replacement (save_draft conflict semantics + the require_can_* checks as backend integration tests) still outstanding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(drafts): add UserDraft.seed — a one-shot baseline load that never POSTs The page editors bracket their new-draft / deployed-baseline loads with stopSync + restartSync so the programmatic write isn't synced as the user's edit. Forgetting restartSync silently disables autosave for the session — the footgun behind the three divergent resume strategies the review flagged. `UserDraft.seed(kind, path, value)` is the scoped alternative: it sets the cell (all reactive readers update) and arms a single-shot `seedNextWrite` flag the sync effect consumes — adopting the value as the new baseline and skipping exactly that one POST, with no suspension to resume. Additive: stopSync/restartSync are untouched and still used for the writes that fan out across editor components (initContent cascades). Foundation for converting the editor bootstraps off the bracket. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(drafts): extract usePageDraftSync; convert the scripts editor onto it First step of unifying the four page editors' hand-rolled draft orchestration (three divergent handle-ownership models + an easy-to-forget recordRemoteSync). usePageDraftSync is the single model — the page analogue of useTriggerDraftSync — owning the re-keyed autosave handle, the live-editor-draft registry entry, recordRemoteSync (now a method, not a per-page ritual), seedBaseline (via UserDraft.seed), and draft removal. The scripts editor is converted as the reference adoption: its inline useReactive handle, live-editor-draft effect, recordRemoteSync, and the two UserDraft.remove calls now go through draftSync. The new-draft stopSync bracket stays (it spans ScriptBuilder's initContent cascade). Verified in a real browser against the dev stack: load fires no spurious save, a code edit triggers exactly one save_draft POST + a draft row, and the draft persists across reload. Flows / apps_raw / apps conversions follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(drafts): convert the flows editor onto usePageDraftSync Replace the inline useReactive handle + UserDraftDbSyncer.recordRemoteSync + UserDraft.remove with draftSync. effectivePath is omitted — flows register their live-editor-draft entry through FlowBuilder (liveEditorDraftStoragePath), so the composable doesn't double-register. The new-draft stopSync + armRestartOnFirstInteraction bracket stays (it spans FlowBuilder's seed cascade). flowStore reads/writes draftSync.draft. Verified in a real browser: load fires no spurious save, a summary edit triggers exactly one save_draft POST + a draft row, and the edit persists across reload. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(drafts): convert the apps_raw editor onto usePageDraftSync Replace the UserDraft.use handle + mirror, UserDraftDbSyncer.recordRemoteSync, and UserDraft.remove with draftSync. `path` is a mount-scoped plain `let` (the editor remounts per path), so the composable's useReactive re-keys only on workspace change — equivalent to the prior capture-once use(). effectivePath omitted (RawAppEditor owns the live-editor-draft entry); the new-draft stopSync + armRestartOnFirstInteraction bracket stays. Type-checked and behavior-equivalent (handle mechanism unchanged; the centralized recordRemoteSync/remove read the same `path`). Not browser-exercised here — no existing raw app in the dev workspace and the new-draft template-picker flow isn't scriptable quickly; scripts and flows (same composable) were verified live. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): remove app autosave at its canonical key after deploy/rename AppEditor keys the app autosave on the URL draft path and passes it down as userDraftPath, but AppEditorHeader's post-deploy cleanup re-derived the key from the just-typed deploy path (createApp) / the live $appPath (updateApp) instead. For a new app the autosave lives at u/{user}/draft_{uuid} while the typed path is the user's chosen name, and a rename leaves the autosave at the original key — so removing at path/$appPath missed the real draft row and orphaned it. Use the canonical userDraftPath AppEditor already provides. This is the "children re-derive the UserDraft key" fragility from the review, addressed without giving apps a page-level handle — apps deliberately lets AppEditor own the handle so the entry is destroyed on unmount (a page handle would keep it alive and reintroduce spurious autosaves on every /edit visit). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(drafts): integration tests for save_draft conflict semantics + authz [P2] Replaces the deleted drafts.rs (which targeted the removed /drafts/create API) with tests for the new surface: - save_draft upsert → stale-last_sync conflict (rejected, value unchanged) → force overwrite → delete, the optimistic-concurrency contract. - require_can_write_path: own namespace allowed, another user's namespace rejected, operators rejected. - the item-level extra_perms fallback — a user granted write on a deployed item can save a draft on it (regression test for the authz drop). - cross-user draft privacy: GET /drafts/get is 404 for the drawer kinds (variable/resource/triggers), not blocked for script/flow/app. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(sqlx): refresh offline cache after the main merge The merge auto-combined both branches' additions inside the resource get-by-path query_as! (our draft_only/is_draft columns + main's folder_labels(...) inherited_labels), producing query text neither branch had cached — so the offline build failed for it. Regenerate the entry (rename to the new content hash) and refresh a re-described workspace query. Feature-gated/EE entries the local prepare can't compile are left as committed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ee repo ref * chore(system_prompts): regenerate for draft_only/is_draft trigger schema fields The openapi.yaml trigger/schedule schemas gained draft_only + is_draft, but system_prompts/generate.py wasn't rerun, failing the freshness check. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(drafts): defer save_draft write authz to RLS via a FOR UPDATE probe require_can_write_path re-implemented the item-level extra_perms write rule in Rust (SELECT extra_perms + get_perm_in_extra_perms_for_authed) — a third copy of rules whose canonical home is the RLS policies, and the exact lane that regressed once already. Replace it with an RLS write-probe: `SELECT 1 FROM {deployed_table} WHERE path/workspace ... FOR UPDATE` through UserDB. Postgres applies UPDATE policies to rows locked via FOR UPDATE, so a returned row means the canonical policies (see_own / see_member / folder-write / see_extra_perms_*_update / admin_policy) would let this user UPDATE the row — no write rule re-implemented, no drift possible. The probe's row lock is released by the immediate commit. The claim-based namespace checks stay, evaluated FIRST: they read the same JWT claims RLS does (so outcomes are identical), they spare the autosave hot path a DB round-trip for the common own-namespace case, and they are the entire check for draft-only paths — where no deployed row exists, so there is structurally nothing for RLS to evaluate. The u/own + folder-owner part now goes through the shared windmill_api_auth::require_owner_of_path instead of bespoke code. Adds a read-only-grant test case (extra_perms value false): the row is visible under the SELECT policy but FOR UPDATE filters it under the UPDATE policy — pinning the semantics the probe relies on. All 4 draft integration tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: point ee-repo-ref at the EE branch merge (has DRAFT_KIND consts) ee-repo-ref was set to main's EE commit (d45b9a6) while the EE branch was unpushed; building OSS (which requires const DRAFT_KIND on TriggerCrud) against that EE ref fails with E0046 on every EE trigger impl. The EE branch head e936e9a — the merge of d45b9a6 into the EE remove-workspace-drafts branch, carrying the DRAFT_KIND consts — is now pushed; point at it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): ignore permissioned_as fields in the unsaved-changes comparison The schedule cfg carries permissioned_as / preserve_permissioned_as — run-as deploy directives, not user-edited draft content — and the editor round-trips them asymmetrically (preserve_… is rebuilt as !!cfg.permissioned_as on load but `|| undefined` on build), so the banner comparison could report a phantom diff. Extract the normalization into a shared normalizeDraftForCompare (JSON round-trip + a DRAFT_COMPARE_IGNORED_FIELDS list with the two fields) and use it from BOTH comparators: draftValuesEqual (variable/resource banner + discardIf) and useTriggerDraftSync's cfgDiffers (schedule and trigger banners, the persist-effect's at-baseline discard, restore) — one ignore-list, no way for the two to disagree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * nit * fix(drafts): at-baseline discard is auto-gated and only fires with a draft Two related fixes to useTriggerDraftSync's persist-effect: 1. The reactive at-baseline discard bypassed the "Enable auto-save" toggle: with autosave off, value saves were parked (correct) but the discard's value:null still POSTed — so the editor never wrote drafts yet kept reactively DELETING them, and the only network traffic was discards. Thread `auto` through UserDraft.discard to the syncer; the persist-effect passes auto:true (parked for Ctrl/Cmd+S when the toggle is off), explicit discards (banner button, post-deploy cleanup, reset-to-deployed) stay ungated. 2. The discard fired unconditionally whenever the form sat at the deployed baseline — including a spurious value:null POST on every drawer open. Guard on cfgDiffers(h.draft, deployed): undefined on a fresh open (nothing to discard) and equal to deployed right after a discard (no repeat per cfg recompute). Verified live as a non-admin user on a schedule: toggle on → no POST on open, edit → one value save, revert → one discard; toggle off → zero POSTs (everything parked), banner still functional. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(drafts): scope the "Enable auto-save" toggle to the page editors Add a canBeDisabled opt (default false) to UserDraft.use / useReactive / useMany specs, threaded through acquireEntry into the reactive mirror's save opts. The syncer's auto-save gate (and the pagehide-flush skip) now only applies to saves whose handle opted in: the four full-page editors — script / flow / raw app via usePageDraftSync, app via AppEditor's use() — which are exactly the surfaces whose AutosaveIndicator carries the toggle. Drawer editors (variables / resources / schedules / triggers) keep the default and always sync regardless of the toggle — previously a toggle flipped off in some browser silently disabled their autosave and the optimistic asterisk (both sit behind the same gate) with no toggle UI anywhere on those surfaces to explain it. Verified live: schedule edit with the toggle off now POSTs the value save (and the discard on revert); script editor with the toggle off still parks everything for Ctrl/Cmd+S. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): consume the import handoff stores in the new-draft bootstrap The /add pages used to read importStore / importFlowStore / importScriptStore / sessionStorage rawAppImport to seed the editor from "Import from YAML/JSON", "Build app" (from a script/flow), and the workflows-as-code import. Since /add became a pure redirect to /{kind}/edit/u/{user}/draft_{uuid}?new_draft=true, the writers kept firing but nothing consumed the payload — every import landed in an empty editor. Consume them (one-shot read + clear) in the four edit pages' new_draft branches, layering the imported content over the empty template with path kept '' so the friendly-name generation still runs: - scripts: $importScriptStore spread over the empty script (non-empty content also keeps ScriptBuilder's template bootstrap from overwriting it — that cascade is gated on content == ''). - flows: $importFlowStore spread over the empty flow. - apps: $importStore — wrapped exports ({summary, value, policy}) and bare App values, mirroring main's /add. - raw apps: $importStore then sessionStorage rawAppImport (the full page reload for cross-origin isolation drops in-memory stores); honored only when the payload carries files (rendering gates on them), skipping the framework picker; otherwise the template seed. Verified live: "Build app" from a script lands on /apps/edit with the canvas seeded from the script instead of an empty editor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(drafts): remove dead delete_user_draft + its stale doc [C4] The doc claimed item delete handlers call it, but those all moved to delete_all_drafts_for_path (an item delete is for everyone); the caller-scoped discard goes through the save_draft route with value:null. That left delete_user_draft with zero callers (OSS and EE) — remove it and its orphaned sqlx cache entry, and reword the contrast note on delete_all_drafts_for_path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(migrations): retire the sync_drafts-era index comment + right-size it [C6] The draft_user_sync_idx comment described the deleted sync_drafts polling endpoint (editors polling created_at ranges every 2-10s) — that design was replaced by recordRemoteSync + save_draft last_sync, and nothing range-scans draft.created_at anymore. Since this migration only exists on this branch, fix it before it ships: the index's real consumer is GET /drafts/list (workspace_id + email equality, ORDER BY path), so swap the vestigial trailing created_at for path (rows come back in output order) and rename to draft_user_listing_idx. Chain re-verified on a fresh DB. (Byte-for-byte migration edit — dev DBs that already applied it need a reset, as with the earlier consolidation.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): discardDraft awaits the delete POST before refetching [I5] UserDraftDbSyncer.save resolves at enqueue time for debounced saves, so discardDraft's await finished ~1.5s before the value:null POST and the invalidateWorkspaceDrafts refetch re-listed the just-discarded draft. Use immediate: true (resolves after the POST lands), matching every sibling delete-then-refetch path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): replace stale draft_only gates in the builders [I6] draft_only was dropped from the get-by-path wire shape (the column is gone; overlays carry no_deployed instead), so these four reads were always undefined: - ScriptBuilder "Exit & See details" gate and TriggersEditor's isDeployed treated every draft-only script as deployed → now keyed on savedScript.no_deployed like the sibling reads right next to them. - FlowBuilder's deploy path never took the direct-save branch for draft-only flows (no deployed version exists to compare against), and "Exit & see details" was offered for draft-only flows (404 details page) → both now keyed on the newFlow prop (driven by no_deployed), which the rest of the file already uses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): Ctrl/Cmd+S flushes the draft in the low-code app editor [I7] The app editor's keydown handler swallowed the shortcut with a bare preventDefault() — every other page editor flushes the pending autosave (UserDraftDbSyncer.flush) so the AutosaveIndicator narrates Saving... → Saved and parked edits (autosave toggle off) actually persist. Wire the same flush, skipped in the AI session pane where no UserDraft handle exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(drafts): AI tool strings no longer describe drafts as localStorage [C2] The copilot tool results/messages still told the model drafts were "saved to local storage" / "a browser-only local draft" — drafts are per-user rows in the server-side draft table now. Misleading the model about the storage medium produces wrong explanations to users (e.g. "your draft will be lost if you clear your browser data"). Reword all occurrences to "draft" / "per-user draft (saved server-side)". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(openapi): drop stale draft_only request props, fix OtherDraftUser, regen deref [D4][C5] - The create-script (NewScript), createFlow, createApp and createAppRaw request bodies still documented draft_only — the backend request structs no longer read it, so an older CLI sending draft_only: true is silently ignored and fully deploys. Remove the property from the spec so generated clients can't offer it. (Response-side draft_only on the Listable* rows stays — the list synthesis populates it.) - UserDraftOverlay.other_drafts_users item schema declared email and a required draft_saved_at; OtherDraftUser serializes only username (nullable for the legacy row — emails never leave the server). Align the schema. [C5] - Regenerate openapi-deref.yaml/.json (served at runtime via include_str!) — they still advertised getScriptByPathWithDraft and the deleted draft surface, and now carry the drafts/save_draft routes. Frontend gen client regenerated; check:fast clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(sessions): stop session pane from clobbering server-side raw-app drafts [P1] loadRawApp seeded the session runtime from result.value (the deployed payload), ignoring the .draft pocket returned by the get-by-path overlay. The subsequent UserDraft.save then POSTed deployed content with no last_sync recorded, silently overwriting the user's server draft. Now the no-draft branch consumes result.draft when present (matching the flow/script branches) and records draft_saved_at via recordRemoteSync so later session saves are conflict-checked instead of treated as fresh. Also corrects the header and aiDraft-branch comments that claimed the overlay merges drafts into top-level fields — it never does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(rust-client): pass new get_draft arg to variable_api::get_variable getVariable gained a GetDraft query parameter (per-user draft overlay), so the generated client fn takes a sixth argument. Verified with the same generate+check pipeline CI runs (rust-client/dev.nu --check). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * nit: Workspace fork mention * fix(drafts): don't leak other_drafts_users on draft-only private kinds [P2] fetch_draft_only built the other_drafts_users list unconditionally, while the deployed-overlay path gates it on shares_drafts_across_users. For the drawer kinds (resource/variable/triggers) drafts are private to their owner, so a draft-only GET was the one route that still told a viewer who else has a draft at the path. Apply the same kind gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * perf(drafts): probe a single row in the RLS write-probe [P2] The script table keeps one row per version at the same path, so the FOR UPDATE probe locked the entire version history and serialized against concurrent deploys. LIMIT 1 locks one row — any UPDATE-policy visible row proves writability (same pattern as scripts.rs's latest-version lock). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): consume the /add?param= seeding intents in new_draft branches [D2] The /add routes' redirect preserves query params, but the edit pages' new_draft branches only consumed the YAML/JSON import stores — every other intent the old /add pages handled landed in a blank editor: - scripts: ?hub= and ?template= forks (with locked language and a `<source>_fork` path suggestion), ?wac=python|typescript (WAC editor template + language), ?lang=, ?initial_args= (URL form), and the base64-JSON #hash payload (run page "Fork", workspace_settings handler-template buttons; WAC detection restored for imports too) - flows: ?hub= (preprocessor placeholder replacement + env-variables panel), ?template=/?template_id=, ?fork=true (fork_flow localStorage / window.opener handoff), #state, ?tutorial= - apps: ?hub= (fromHub inputs panel), ?template=/?template_id=, ?tutorial= The redirect itself also dropped the URL hash — SvelteKit forbids url.hash in load, so it forwards window.location.hash (correct for all hash producers: they arrive as full page loads via window.open / target=_blank). Seeding priority and toasts mirror main's /add pages. Verified live: hub/template/wac/hash/fork intents for scripts and flows, hub for apps (dev hub returns empty payloads, code path confirmed via toast + inputs panel); no autosave POSTs fire during seeding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): LS→DB migration no longer clobbers fresher server drafts [P2] The one-off localStorage migration POSTed every entry with force: true, unconditionally overwriting whatever the user had since saved server-side from another browser. It now passes the LS copy's lastWrittenAt as last_sync (epoch 0 when absent), so the server's conflict rule arbitrates: empty slot → insert; server draft fresher → conflict, LS copy dropped; LS copy fresher → upload wins. Verified all three outcomes against the live save_draft endpoint. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(raw_apps): drop banned $bindable(default) on template picker open [P2] `open = $bindable(false)` on an optional prop is the AGENTS.md-banned pattern (the default masks the undefined state). The only caller always binds a boolean, so `open` is now a required prop with a plain `$bindable()`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): fork others' drafts via the import handoff, not an eager save The Fork actions (OtherUsersDraftsModal + DraftBadge popover) saved the fetched draft server-side immediately and navigated to the fork path, which surfaced three problems: a server draft existed before the user edited anything, the Path widget treated the slot as an existing item ("Only the owner can change the path"), and the value's draft_path kept the source path while the URL said X_owner_fork. Forking now routes through the same one-shot import handoff as the "Import from YAML/JSON" actions (new shared forkDraftToImport helper): stash the value in the kind's import store, navigate to /add, and let the new_draft branch seed a brand-new own item — nothing saved until the first real edit, fresh renamable path, no source identity riding along. The editPathFor/currentUserUsername plumbing that only served the old flow is removed from both fork surfaces and their callers. The new_draft branches also clear the previous path's draft-presence state (otherDraftsUsers, loadedFromDraft, stale-draft timestamps) — the page component is reused across same-route navigation, so forking from an editor with collaborators used to carry the "Others are working on this" hint onto the fresh draft. Verified live: fork of a legacy draft seeds content+summary on a fresh u/{user}/draft_{uuid} slot with zero save_draft requests and no leftover collaborator hints. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(drafts): replace deprecated Popover with meltComponents Popover - Migrate from old Popover.svelte to meltComponents/Popover.svelte - Convert to new trigger/content snippet pattern with openOnHover=true - Maintain hover behavior with debounceDelay=100 - Add key to visibleUsers each block for Svelte 5 compliance * feat(drafts): seed forked drafts with the source path in the forker's namespace Forking u/admin/myflow as guest now seeds the Path widget with u/guest/myflow instead of a random friendly name — everything after the source path's first two segments is kept, so f/folder/my/flow becomes u/guest/my/flow. The re-homed path travels from forkDraftToImport to the new_draft branches as a ?seed_path= param (the redirect preserves query params; plain ?path= would be eaten in transit by ScriptBuilder's legacy collab-param cleanup, which deletes path/collab from the live searchParams object). The script editor also passes initialPathChosen for any seeded path — MetadataGen fires onChange for a non-empty summary at mount, and the summary→path auto-slug would otherwise overwrite the explicit seed (hub/template forks and URL-hash payloads included). Verified live: forking a draft on u/admin/hard_working_script seeds path u/admin/hard_working_script (with the "path already used" warning), keeps the drafted summary/content, and still fires no save_draft until the first edit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): DiffDrawer "Restore deployed" actually discards the draft [P1] All four restoreDeployed implementations POSTed the delete through the debounced pipeline and reloaded with getDraft defaulting to true: the reload's draft write re-entered the autosave mirror (the one-shot seed guard was consumed on first load), and debouncerByKey displaced the queued value:null with the new save — the delete never reached the server and the editor re-rendered the draft it was told to discard. They now funnel through runResetToDeployed (the stopSync-bracketed delete the AutosaveIndicator reset already uses) with each page's proven reset body (getDraft: false reload), so the suspension mutes the mirror while the delete flushes and sync re-arms on first interaction. Also fixes the raw-app drawer navigating to the visual app editor (/apps/edit) instead of /apps_raw/edit [P2]. Verified live on the script editor: Restore deployed issues exactly one save_draft ({value:null} answered status=saved), the server row is gone, and the editor re-renders the deployed content. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): deploying a draft-only item reliably deletes its draft Two bugs left the slot draft (u/{user}/draft_{uuid}) alive after a successful deploy: - RawAppEditorHeader.createApp removed the draft at the just-typed deploy path instead of the URL slot key (the visual header documents exactly this trap), orphaning the real row for every draft-only raw-app deploy. - Everywhere else the delete went through bare UserDraft.remove, which only QUEUES the value:null in the per-key debouncer. Editors that stay mounted through the post-deploy navigation (AppEditor, RawAppEditor — and timing-dependently the script/flow builders' post-deploy draft_triggers mirror) keep mirroring their working value, and one such write displaces the queued delete with a fresh save — observed live: deploying a new visual app re-saved the full grid value at the slot right after deploy. New discardDraftAfterDeploy helper (userDraftToast.ts) applies the same bracket runResetToDeployed uses: stopSync to mute the mirror, remove + immediate flush so the displacement window closes, re-arm on first interaction. Wired into the script/flow pages' onDeploy and both app headers' create/update paths (session-pane guards preserved). Verified live for all three kinds: draft-only deploy issues the value:null (status saved), the slot row is gone, and no post-deploy save re-creates it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): forks-compare deploy clears drawer-kind drafts too The script/flow/app deploy endpoints delete the deployer's draft server-side, but the drawer kinds' (variable / resource / schedule / triggers) create/update endpoints never touch the draft table — their editors discard client-side after a save. deployDraft replayed the save but not the discard, so "Deploy n drafts" on /forks/compare deployed those kinds correctly and left the drafts listed forever. deployDraft now issues the canonical value:null delete (immediate) for the drawer kinds after a successful save. Verified live: deploying a draft-only variable from /forks/compare creates the variable and the draft row is gone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): StaleDraftModal "Load latest deploy" actually discards the stale draft [P2] The modal invoked onLoadLatestDeploy directly — the draft = undefined write queued the delete and the reload's deployed-payload write displaced it, overwriting the stale draft with a deployed-identical copy (is_draft stuck on, asterisk persists, modal can't re-fire since draft_saved_at moved past the deploy). All four pages now run the callback through runResetToDeployed, same as the DiffDrawer restore. Verified live: stale-draft scenario → Load latest deploy → exactly one value:null POST, draft row gone, editor renders the newer deploy. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): don't acquire a sync entry for empty-path specs [P2] The read-only historical-hash view (/scripts/edit/x?hash=...) computes draftPath '' but useMany still acquired a live entry at ws/script/ — every edit mirror-POSTed to /drafts/save_draft/script/ (unroutable), populating the failures map and pinning the AutosaveIndicator on "Save failed" with a retry per debounce window. Empty-path specs now get a detached local-only handle: bind: works, nothing syncs — which is what usePageDraftSync's doc always claimed. Verified live: editing in the hash view fires zero save_draft requests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): no spurious conflict after bfcache restore of a flushed page [P2] flushOnPageHide advances the server rows with unreadable keepalive POSTs and leaves lastSyncMap stale — correct when the document dies, wrong when bfcache resurrects it: the next autosave carried the pre-flush last_sync and the server rejected the user's own write as a conflict, opening DraftSyncConflictModal. The flushed keys are now remembered and dropped from lastSyncMap on pageshow with event.persisted, so the first post-restore save takes first-push semantics against this document's own flush. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ui(drafts): draft asterisk sits on the trigger row's main title The draft hint rendered at the end of the secondary path line (u/admin/item*) on the http/websocket/nats/kafka/email trigger lists — easy to miss. It now renders at the end of the row's bold title, and on the azure/gcp lists it moves from mid-title (after the path, before the topic suffix) to the end of the line. mqtt/postgres/sqs/schedules already had it on the title. Verified visually on the HTTP routes list. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): trigger editors save the FIRST edit, not the second Three interlocking fixes in the trigger autosave path: - The entry's one-shot first-write seed guard (skipNextWrite) was never consumed for trigger entries: the drawers don't write the cell on open (the form holds the state, unlike variables/resources which pass a defaultValue), so the guard stayed armed and silently swallowed the user's FIRST edit — banner on, no asterisk, no save until a second change. maybeRestore now seeds the cell with the post-load baseline (server draft overlay if any, deployed otherwise) via UserDraft.seed, consuming the guard without POSTing. - Guard hygiene in the cell's sync effect: a programmatic write consumes BOTH one-shot guards, and a no-op write (same serialization — e.g. the trigger pages fire openEdit twice per row click, re-seeding the same value) defuses a lingering seedNextWrite instead of leaving it armed to eat the next real edit. - The at-baseline auto-discard is now deferred + revalidated (600ms): with the cell seeded, the double-openEdit churn transiently shows form-at-deployed + cell-holds-draft and an immediate discard deleted the server draft on open; the recheck skips the transient state while a genuine user revert still discards. Verified live on the HTTP route editor: open-with-draft restores the draft with zero POSTs, the very first field edit saves, and reverting the form to the deployed value deletes the server draft. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * ui(drafts): underscore-separated uuids in draft slot paths u/{user}/draft_{uuid} now uses underscores instead of dashes in the uuid — path segments elsewhere in Windmill are [a-zA-Z0-9_] words and downstream consumers treat '-' as a foreign character. Nothing parses the uuid back, so existing dashed slots stay valid. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): cascade draft cleanup on bulk-delete and rename Drafts have no SQL FK to their underlying items (only to password.email), so deletion and rename must cascade programmatically. Two gaps remained: - Bulk delete of variables/resources did not wipe per-user drafts at the deleted paths (single delete already did via delete_all_drafts_for_path). Cascade them — including the linked resource/variable rows the bulk delete fans into — so no orphaned draft-only rows survive. - Renaming a variable/resource/trigger left the per-user draft stranded at the old path. Add delete_own_draft_for_path and clear the deployer's own (+ legacy NULL) draft at the old path on rename, mirroring the script/flow/app rename path; teammates keep theirs (StaleDraftModal). Variable/resource renames also move the linked counterpart, so both kinds' drafts at the old path are cleared. Schedules have no rename path. Note: sqlx offline cache not yet regenerated for the new/changed queries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): surface legacy NULL-email drafts and migrate pathless /add keys Legacy workspace-scoped drafts (pre-per-user rows + the remove_draft_only migration, all email IS NULL) stopped showing up because every per-user lookup matched only email = self. Match (email = self OR email IS NULL) everywhere a draft is surfaced or opened, with the owned row taking precedence (DISTINCT ON / ORDER BY email NULLS LAST): the home drafts list, the script/flow/app/drawer draft-only list syntheses, and the get-by-path overlay/fallback. The localStorage->DB migration also dropped pathless legacy keys (userdraft/w/{ws}/{kind}/ with no path) — the new-item /add autosave — because parseKey rejected an empty path, leaving them stranded in LS. Mint a fresh u/{user}/draft_{uuid} slot for those (same convention as the editors' /add redirects) so they migrate as regular draft-only items. Note: sqlx offline cache not yet regenerated for the changed macros. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(sqlx): regenerate offline cache for draft cascade + legacy-draft queries Adds the offline entries for the queries changed in the two preceding draft fixes (delete_own_draft_for_path, the maybe_overlay_draft/fetch_draft_only NULL-email fallback, and the script/flow/app draft-only syntheses). Also forwards the `http_trigger` feature from windmill-api-openapi to windmill-store: that crate imports `try_get_resource_from_db_as` unconditionally, but the fn is cfg-gated behind a trigger feature, so the openapi targets failed to compile in isolation (e.g. `--all-targets` under resolver 2) — which blocked `cargo sqlx prepare`. The feature was already present transitively in whole-workspace builds; this just makes it explicit where the symbol is used. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): resolve own draft owner in the admins workspace The draft-owner surfaces (home-page badge, "others' drafts", View JSON / Fork) resolve a draft's email to a username via the `usr` table. The `admins` workspace has no `usr` rows — there a user's "username" IS their email — so the join missed every owner and returned NULL, which the badge renders as "Legacy workspace draft". A user editing a deployed item in `admins` thus saw their OWN draft plus the genuine legacy NULL-email row both labelled "Legacy workspace draft" (the reported duplicate). Add the identity fallback `COALESCE(u.username, CASE WHEN workspace_id = 'admins' THEN email END)` to the script/flow/app draft_users aggregations and fetch_other_drafts_users, and accept username==email in get_draft_for_user. The genuine legacy row keeps username NULL (its email is NULL, so the CASE yields NULL too), so it alone reads "Legacy workspace draft" while the user's own draft now reads "<email> (you)". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(drafts): keep "See others' drafts" after reset-to-deployed other_drafts_users is only computed by the backend when getDraft is true (the cross-user lookup is skipped otherwise). Reset-to-deployed reloads with getDraft:false, so the editors were overwriting the known list with the empty response — hiding the "See others' drafts" button until a full page reload recomputed it. Discarding one's own draft is independent of other users' drafts, which are untouched on the backend. Only assign otherDraftsUsers on a getDraft:true load. Applied to the script, flow, app and raw-app editors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * disable fork for operators * Path reactivity issue * docs(drafts): tighten draft-feature comments and drop dead code The draft feature accumulated many multi-paragraph comments that risked code-comment drift. Compact them to the AGENTS.md bar (constraints not narration, state-once, no drafting-history), de-duplicating the repeated draft_users / cascade / draft_only-synthesis rationale to one canonical version per theme with terse cross-references elsewhere (~1300 fewer lines). Also fixes three stale/contradictory comments surfaced while trimming: - the operator authz note claimed operators are "excluded from every draft surface", contradicting require_can_read_path (they can read some drafts, never write) — reworded to match the code; - a migration comment named a non-existent index (draft_user_sync_idx); - a syncer comment documented the wrong map-key separator. Removes notifyDraftLoaded (orphaned exported helper, no callers). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(drafts): rename save_draft route to /update for CRUD consistency The draft write route was POST /drafts/save_draft/{kind}/{path}, which stutters with the /drafts prefix and uses a non-house verb. Rename it to POST /drafts/update/{kind}/{path} (operationId saveDraft -> updateDraft) to match the codebase's CRUD convention (/list, /get/{path}, /update/{path}). /list and /get/{kind}/{path} already matched and are unchanged. Updates the handler, openapi spec + dereferenced bundles, the two DraftService callers, the hand-built keepalive page-unload URL (it bypasses the generated client, so it wouldn't be caught by regeneration), and the integration tests. Response status values ("saved"/"conflict") are unchanged, so there is no behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
298 lines
10 KiB
Rust
298 lines
10 KiB
Rust
use serde_json::json;
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_test_utils::*;
|
|
|
|
const WS: &str = "test-workspace";
|
|
|
|
/// A reqwest client that sends `Authorization: Bearer <token>`. The base
|
|
/// fixture seeds: SECRET_TOKEN (test-user, admin), SECRET_TOKEN_2
|
|
/// (test-user-2, non-admin), SECRET_TOKEN_3 (test-user-3, non-admin).
|
|
fn client_for(token: &str) -> reqwest::Client {
|
|
let mut headers = reqwest::header::HeaderMap::new();
|
|
headers.insert(
|
|
reqwest::header::AUTHORIZATION,
|
|
reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
|
|
);
|
|
reqwest::ClientBuilder::new()
|
|
.default_headers(headers)
|
|
.build()
|
|
.unwrap()
|
|
}
|
|
|
|
fn save_url(port: u16, kind: &str, path: &str) -> String {
|
|
format!("http://localhost:{port}/api/w/{WS}/drafts/update/{kind}/{path}")
|
|
}
|
|
|
|
async fn draft_count(db: &Pool<Postgres>, path: &str, kind: &str, email: &str) -> i64 {
|
|
sqlx::query_scalar::<_, i64>(
|
|
"SELECT count(*) FROM draft WHERE workspace_id = $1 AND path = $2 \
|
|
AND typ = $3::text::DRAFT_KIND AND email = $4",
|
|
)
|
|
.bind(WS)
|
|
.bind(path)
|
|
.bind(kind)
|
|
.bind(email)
|
|
.fetch_one(db)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// Upsert → conflict (stale last_sync) → force-overwrite → delete, the
|
|
/// optimistic-concurrency contract `update_draft` exists to enforce.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_update_draft_conflict_lifecycle(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let c = client_for("SECRET_TOKEN");
|
|
let path = "u/test-user/draft_x";
|
|
let url = save_url(port, "script", path);
|
|
|
|
// First save: no last_sync ("treat as fresh") → saved.
|
|
let r = c
|
|
.post(&url)
|
|
.json(&json!({ "value": { "n": 1 } }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(r.status(), 200, "first save");
|
|
let body: serde_json::Value = r.json().await?;
|
|
assert_eq!(body["status"], "saved");
|
|
let ts1 = body["current_timestamp"].as_str().unwrap().to_string();
|
|
assert_eq!(
|
|
draft_count(&db, path, "script", "test@windmill.dev").await,
|
|
1
|
|
);
|
|
|
|
// A tiny gap so the next now() is strictly greater than ts1.
|
|
tokio::time::sleep(std::time::Duration::from_millis(15)).await;
|
|
|
|
// Save with the matching last_sync → not stale → saved, newer ts.
|
|
let r = c
|
|
.post(&url)
|
|
.json(&json!({ "value": { "n": 2 }, "last_sync": ts1 }))
|
|
.send()
|
|
.await?;
|
|
let body: serde_json::Value = r.json().await?;
|
|
assert_eq!(body["status"], "saved", "in-order save");
|
|
let ts2 = body["current_timestamp"].as_str().unwrap().to_string();
|
|
assert_ne!(ts1, ts2, "timestamp should advance");
|
|
|
|
// Save with the now-stale ts1 → conflict, server reports its current ts.
|
|
let r = c
|
|
.post(&url)
|
|
.json(&json!({ "value": { "n": 3 }, "last_sync": ts1 }))
|
|
.send()
|
|
.await?;
|
|
let body: serde_json::Value = r.json().await?;
|
|
assert_eq!(body["status"], "conflict", "stale save must conflict");
|
|
assert_eq!(body["current_timestamp"].as_str().unwrap(), ts2);
|
|
|
|
// The conflicting write must NOT have landed — value is still {n:2}.
|
|
let stored: serde_json::Value = sqlx::query_scalar::<_, sqlx::types::Json<serde_json::Value>>(
|
|
"SELECT value FROM draft WHERE workspace_id = $1 AND path = $2 \
|
|
AND typ = 'script' AND email = 'test@windmill.dev'",
|
|
)
|
|
.bind(WS)
|
|
.bind(path)
|
|
.fetch_one(&db)
|
|
.await?
|
|
.0;
|
|
assert_eq!(stored["n"], 2, "conflicting write must be rejected");
|
|
|
|
// force = true overrides the conflict check.
|
|
let r = c
|
|
.post(&url)
|
|
.json(&json!({ "value": { "n": 3 }, "last_sync": ts1, "force": true }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
r.json::<serde_json::Value>().await?["status"],
|
|
"saved",
|
|
"force"
|
|
);
|
|
|
|
// Delete (value: null) → saved, row gone.
|
|
let r = c.post(&url).json(&json!({ "value": null })).send().await?;
|
|
assert_eq!(
|
|
r.json::<serde_json::Value>().await?["status"],
|
|
"saved",
|
|
"delete"
|
|
);
|
|
assert_eq!(
|
|
draft_count(&db, path, "script", "test@windmill.dev").await,
|
|
0,
|
|
"row removed after delete"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// require_can_write_path: own namespace allowed, another user's namespace
|
|
/// rejected, operators rejected outright.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_update_draft_write_authorization(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let user2 = client_for("SECRET_TOKEN_2"); // test-user-2, non-admin
|
|
|
|
// Own namespace → allowed.
|
|
let r = user2
|
|
.post(save_url(port, "script", "u/test-user-2/own"))
|
|
.json(&json!({ "value": { "a": 1 } }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(r.status(), 200, "own namespace allowed");
|
|
|
|
// Another user's namespace, no grant → rejected.
|
|
let r = user2
|
|
.post(save_url(port, "script", "u/test-user/theirs"))
|
|
.json(&json!({ "value": { "a": 1 } }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(r.status(), 401, "other user's namespace rejected");
|
|
|
|
// Operators can't save drafts at all.
|
|
sqlx::query(
|
|
"UPDATE usr SET operator = true WHERE workspace_id = $1 AND username = 'test-user-3'",
|
|
)
|
|
.bind(WS)
|
|
.execute(&db)
|
|
.await?;
|
|
let op = client_for("SECRET_TOKEN_3");
|
|
let r = op
|
|
.post(save_url(port, "script", "u/test-user-3/own"))
|
|
.json(&json!({ "value": { "a": 1 } }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(r.status(), 401, "operator rejected");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The item-level extra_perms fallback: a user granted write on a deployed
|
|
/// item (via the Share dialog) can save a draft on it even though it's
|
|
/// outside their namespace. Regression test for the authz drop.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_update_draft_extra_perms_writer(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
let path = "u/test-user/shared";
|
|
|
|
// A deployed script owned by test-user, shared with test-user-2 (write).
|
|
sqlx::query(
|
|
"INSERT INTO script (workspace_id, hash, path, summary, description, content, \
|
|
language, schema, extra_perms, created_by) \
|
|
VALUES ($1, 1, $2, '', '', 'x', 'deno', '{}'::jsonb, \
|
|
'{\"u/test-user-2\": true}'::jsonb, 'test-user')",
|
|
)
|
|
.bind(WS)
|
|
.bind(path)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let user2 = client_for("SECRET_TOKEN_2");
|
|
let r = user2
|
|
.post(save_url(port, "script", path))
|
|
.json(&json!({ "value": { "a": 1 } }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
r.status(),
|
|
200,
|
|
"extra_perms writer can save a draft: {}",
|
|
r.text().await?
|
|
);
|
|
|
|
// Without a grant on a different shared item → still rejected.
|
|
sqlx::query(
|
|
"INSERT INTO script (workspace_id, hash, path, summary, description, content, \
|
|
language, schema, extra_perms, created_by) \
|
|
VALUES ($1, 2, 'u/test-user/private', '', '', 'x', 'deno', '{}'::jsonb, \
|
|
'{}'::jsonb, 'test-user')",
|
|
)
|
|
.bind(WS)
|
|
.execute(&db)
|
|
.await?;
|
|
let r = user2
|
|
.post(save_url(port, "script", "u/test-user/private"))
|
|
.json(&json!({ "value": { "a": 1 } }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(r.status(), 401, "no grant → rejected");
|
|
|
|
// A READ-ONLY grant (`extra_perms` value false) must not allow draft
|
|
// saves: the write check defers to RLS via `SELECT ... FOR UPDATE`,
|
|
// and locking applies the UPDATE policies — visibility under the
|
|
// SELECT policy alone isn't enough. Pins the FOR UPDATE semantics the
|
|
// probe relies on.
|
|
sqlx::query(
|
|
"INSERT INTO script (workspace_id, hash, path, summary, description, content, \
|
|
language, schema, extra_perms, created_by) \
|
|
VALUES ($1, 3, 'u/test-user/readonly', '', '', 'x', 'deno', '{}'::jsonb, \
|
|
'{\"u/test-user-2\": false}'::jsonb, 'test-user')",
|
|
)
|
|
.bind(WS)
|
|
.execute(&db)
|
|
.await?;
|
|
let r = user2
|
|
.post(save_url(port, "script", "u/test-user/readonly"))
|
|
.json(&json!({ "value": { "a": 1 } }))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(r.status(), 401, "read-only grant → rejected");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Cross-user draft viewing (`GET /drafts/get/{kind}/{path}`) is disabled
|
|
/// for the drawer kinds (resource/variable/triggers) so a viewer can't
|
|
/// read another user's draft; it stays available for script/flow/app.
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_cross_user_draft_privacy(db: Pool<Postgres>) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
let server = ApiServer::start(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
// test-user saves a variable draft and a script draft in a shared folder.
|
|
let admin = client_for("SECRET_TOKEN");
|
|
admin
|
|
.post(save_url(port, "variable", "f/shared/v"))
|
|
.json(&json!({ "value": { "variable": { "value": "x", "is_secret": false } } }))
|
|
.send()
|
|
.await?;
|
|
|
|
let user2 = client_for("SECRET_TOKEN_2");
|
|
// Variable is a drawer kind → cross-user view is forbidden regardless of
|
|
// path access (the kind gate fires first).
|
|
let r = user2
|
|
.get(format!(
|
|
"http://localhost:{port}/api/w/{WS}/drafts/get/variable/f/shared/v?username=test-user"
|
|
))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
r.status(),
|
|
404,
|
|
"variable drafts are private to their owner"
|
|
);
|
|
|
|
// Sharing kinds (script) are NOT gated by the kind check — a missing
|
|
// draft / no access yields 404 too, but the "private to their owner"
|
|
// wording is specific to the drawer kinds, so assert it's absent here.
|
|
let r = user2
|
|
.get(format!(
|
|
"http://localhost:{port}/api/w/{WS}/drafts/get/script/f/shared/s?username=test-user"
|
|
))
|
|
.send()
|
|
.await?;
|
|
let body = r.text().await?;
|
|
assert!(
|
|
!body.contains("private to their owner"),
|
|
"script kind must not be blocked by the cross-user privacy gate: {body}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|