From 46345e9ee7c76a2b3b4056ef654759690c226870 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:37:30 +0200 Subject: [PATCH 01/69] backfill legacy draft emails from usr table (#9616) Co-authored-by: Claude Opus 4.8 (1M context) --- ...0048_backfill_legacy_draft_emails.down.sql | 3 ++ ...120048_backfill_legacy_draft_emails.up.sql | 28 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 backend/migrations/20260616120048_backfill_legacy_draft_emails.down.sql create mode 100644 backend/migrations/20260616120048_backfill_legacy_draft_emails.up.sql diff --git a/backend/migrations/20260616120048_backfill_legacy_draft_emails.down.sql b/backend/migrations/20260616120048_backfill_legacy_draft_emails.down.sql new file mode 100644 index 0000000000..a0ef6d0cfe --- /dev/null +++ b/backend/migrations/20260616120048_backfill_legacy_draft_emails.down.sql @@ -0,0 +1,3 @@ +-- Irreversible data backfill: once an email is attached, the row is +-- indistinguishable from a draft that was always per-user owned, so the +-- original NULL state cannot be reconstructed. No-op on revert. diff --git a/backend/migrations/20260616120048_backfill_legacy_draft_emails.up.sql b/backend/migrations/20260616120048_backfill_legacy_draft_emails.up.sql new file mode 100644 index 0000000000..cdf4b19c79 --- /dev/null +++ b/backend/migrations/20260616120048_backfill_legacy_draft_emails.up.sql @@ -0,0 +1,28 @@ +-- Backfill the owner `email` on legacy drafts (rows persisted before per-user +-- sync, hence `email IS NULL`). A user-owned draft path is `u//...`, +-- so resolve `` against `usr` for the same workspace and adopt that +-- user's email. +-- +-- Guards: +-- - the resolved email must exist in `password` (the `draft_password_fkey` +-- target), or the UPDATE would violate the FK; +-- - skip rows that would collide with an existing per-user draft at the same +-- (workspace_id, path, typ, email) under the `draft_pkey_with_user` partial +-- unique index — the per-user row is authoritative, so the legacy row is +-- left untouched. +UPDATE draft d +SET email = u.email +FROM usr u +WHERE d.email IS NULL + AND split_part(d.path, '/', 1) = 'u' + AND split_part(d.path, '/', 2) <> '' + AND u.workspace_id = d.workspace_id + AND u.username = split_part(d.path, '/', 2) + AND EXISTS (SELECT 1 FROM password p WHERE p.email = u.email) + AND NOT EXISTS ( + SELECT 1 FROM draft d2 + WHERE d2.workspace_id = d.workspace_id + AND d2.path = d.path + AND d2.typ = d.typ + AND d2.email = u.email + ); From 7cb5c6e749b2020dee5ee1499f0dc69c5109a6d8 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:07:13 +0200 Subject: [PATCH 02/69] fix(frontend): reset deleteWorkspaceForkModal on confirm in SidebarContent (#9619) The on:confirmed handler for the delete-fork ConfirmationModal never reset deleteWorkspaceForkModal to false. Since SidebarContent persists across workspace switches, the stale true state caused the delete-fork modal to immediately reappear when a new fork workspace was created. Reset the state before calling deleteFork(), matching the on:canceled handler and the pattern in forks/compare/+page.svelte and SessionWrapper.svelte. Fixes WIN-2057 Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/sidebar/SidebarContent.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index dc90a32f7e..2e6c8d2448 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -881,6 +881,7 @@ deleteWorkspaceForkModal = false }} on:confirmed={() => { + deleteWorkspaceForkModal = false deleteFork() }} > From 46288b6143efae4dfdf6fe068b97a1e8831fce6a Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 16 Jun 2026 17:11:01 +0200 Subject: [PATCH 03/69] fix(frontend): session Drafts drawer uses raw_app kind for the raw-app diff (#9617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): session Drafts drawer uses raw_app kind for the raw-app diff Follow-up to #9601. DraftDiffDrawer mapped a raw_app row back to `app` before calling getDraftDiffValues(), but that helper sends `rawApp:true` only for the exact kind `raw_app` (which a never-deployed raw app needs). With `app` it hit the normal app endpoint and 404'd instead of rendering the added diff. `raw_app` isn't in the deploy-kind maps anyway, so just pass the row kind through. Caught by the Codex auto-review on #9601, which posted after that PR had already merged (locked conversation), so the fix lands separately here. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): show friendly draft path + summary for all kinds in session Drafts drawer A never-deployed app/raw_app is parked at a synthetic `u/.../draft_` storage path with the user's typed name in the draft JSON's `draft_path`; the Drafts drawer rendered that UUID path. The list endpoint already returns `draft_path` and `summary` for every kind, but `fetchDrafts` dropped them and the drawer only had the lazily-derived summary. Thread both through the shared row: `WorkspaceDiffDrawer` gains optional `displayPath` (shown in tree/header/search, while `path` stays the storage key for value-loading, item keys and edit links) and `summary` (preferred over the value-derived one, shown before the diff loads). `DraftDiffDrawer` populates them from the draft list (`draft_path ?? path`, `summary`). Both fields are opt-in via `?? path` / lazy fallback, so ForkDiffDrawer — the other consumer of the component — is unchanged. The symptom only surfaced for apps/raw apps because their storage path diverges from the friendly name; scripts already kept a readable path. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): use friendly display path for single-segment draft tree nodes buildTree splits displayPathOf(d), but the `< 2 parts` branch still named the file node from the storage `path` — a draft whose friendly path is a bare name (no `/`) would show `…/draft_` in the sidebar tree. Name it from displayPathOf(d) too, consistent with the rest of the tree/header/search. Addresses Codex and claude review nits on #9617. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../sessions/DraftDiffDrawer.svelte | 22 +++++++++--- .../sessions/WorkspaceDiffDrawer.svelte | 34 +++++++++++++------ 2 files changed, 41 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte index b855c4591a..0716398131 100644 --- a/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/DraftDiffDrawer.svelte @@ -61,7 +61,18 @@ const baseKind = it.raw_app ? 'raw_app' : it.kind const kind = DEPLOY_KIND_BY_DRAFT_KIND[baseKind] ?? baseKind donly[`${kind}/${it.path}`] = it.draft_only - return { kind, path: it.path, status: it.draft_only ? 'added' : 'modified' } + // A never-deployed app/raw_app is parked at a synthetic `…/draft_` + // storage path with the user's typed name in `draft_path`; show that + // (matches the home list) while `path` stays the storage key for loading. + // `summary` comes straight from the draft row, so it shows for every kind + // up front instead of only after the diff value loads. + return { + kind, + path: it.path, + displayPath: it.draft_path ?? it.path, + summary: it.summary, + status: it.draft_only ? 'added' : 'modified' + } }) draftOnlyByKey = donly } catch (e) { @@ -75,10 +86,11 @@ async function loadValues(d: DiffRow): Promise<{ before: unknown; after: unknown }> { const draftOnly = draftOnlyByKey[`${d.kind}/${d.path}`] ?? false - // getDraftDiffValues works on the draft itemKind ('app' for raw apps too); - // map the deploy-style display kind back to it. - const kind: DraftKind = - d.kind === 'raw_app' ? 'app' : ((DRAFT_KIND_BY_DEPLOY_KIND[d.kind] ?? d.kind) as DraftKind) + // getDraftDiffValues keys on the draft itemKind: `raw_app` must stay + // `raw_app` (the helper sends rawApp:true only for that exact kind, which a + // never-deployed raw app needs, else it hits the normal app endpoint and + // 404s). Only the trigger display kinds map back from their deploy-style names. + const kind = (DRAFT_KIND_BY_DEPLOY_KIND[d.kind] ?? d.kind) as DraftKind const { deployed, draft } = await getDraftDiffValues(kind, d.path, workspaceId, draftOnly) // draft_only items have never been deployed → render as "added" (empty // before), matching how the fork drawer renders added items. diff --git a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte index 3eae6f9205..472ea1515b 100644 --- a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte @@ -8,6 +8,13 @@ status: DiffStatus ahead?: number behind?: number + /** Human-facing path; defaults to `path`. Lets a draft parked at a + * synthetic storage path (`…/draft_`) show its friendly typed path + * while keys, value-loading and edit links stay keyed on `path`. */ + displayPath?: string + /** Summary supplied by the data source. Preferred over the one derived + * from the loaded diff value, and shown before that value loads. */ + summary?: string } @@ -88,6 +95,12 @@ return `${d.kind}/${d.path}` } + // Friendly path for display only; `path` stays the storage key everywhere + // keys/loads happen, so a never-deployed draft still loads from `…/draft_`. + function displayPathOf(d: DiffRow): string { + return d.displayPath ?? d.path + } + const KIND_LABELS: Record = { script: 'Script', flow: 'Flow', @@ -183,9 +196,9 @@ } const folderCache = new Map() for (const d of rows) { - const parts = d.path.split('/') + const parts = displayPathOf(d).split('/') if (parts.length < 2) { - root.children.push({ type: 'file', name: d.path, diff: d }) + root.children.push({ type: 'file', name: displayPathOf(d), diff: d }) continue } const scopeKey = parts.slice(0, 2).join('/') @@ -226,8 +239,8 @@ } function searchableText(d: DiffRow): string { - const parts = [d.path, KIND_LABELS[d.kind] ?? d.kind] - const s = summaries[itemKey(d)] + const parts = [displayPathOf(d), KIND_LABELS[d.kind] ?? d.kind] + const s = summaries[itemKey(d)] ?? d.summary if (s) parts.push(s) return parts.join(' ') } @@ -433,12 +446,12 @@ { highlightedKey = key scrollToDiff(node.diff) @@ -556,6 +569,7 @@ {@const StatusIcon = statusIcons[status]} {@const loaded = loadedDiffs[key]} {@const editUrl = editUrlFor?.(d)} + {@const dpath = displayPathOf(d)}
- {d.path} + {dpath} {:else} -
- {d.path} +
+ {dpath}
{/if}
From 651fa13ee80ff76e5a53ef1ed545b03ce6792294 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:16:49 +0200 Subject: [PATCH 04/69] fix: show folder labels in the folder list table (#9620) Surface folder labels in the /folders table via a new "Labels" column between Name and Scripts, rendered as blue badges with a +N overflow indicator (first 3 shown), matching the script row pattern. Previously labels were only visible inside the folder editor drawer. Fixes WIN-2056 Co-authored-by: Claude Opus 4.8 (1M context) --- .../(root)/(logged)/folders/+page.svelte | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/(root)/(logged)/folders/+page.svelte b/frontend/src/routes/(root)/(logged)/folders/+page.svelte index 1e488d3d13..0a55acfe69 100644 --- a/frontend/src/routes/(root)/(logged)/folders/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/folders/+page.svelte @@ -17,6 +17,7 @@ import { Pen, Trash, Plus } from 'lucide-svelte' import Head from '$lib/components/table/Head.svelte' import Row from '$lib/components/table/Row.svelte' + import Badge from '$lib/components/common/badge/Badge.svelte' import { untrack } from 'svelte' type FolderW = Folder & { canWrite: boolean } @@ -135,6 +136,7 @@ Name + Labels Scripts Flows Apps @@ -149,7 +151,7 @@ {#if folders === undefined} {#each new Array(4) as _} - + @@ -157,7 +159,7 @@ {:else} {#if folders.length === 0} - +
No folders yet, create one
@@ -165,7 +167,7 @@ {/if} - {#each folders as { name, extra_perms, owners, canWrite, summary } (name)} + {#each folders as { name, extra_perms, owners, canWrite, summary, labels } (name)} { @@ -180,6 +182,27 @@ {summary} {/if}
+ + {#if labels?.length} +
+ {#each labels.slice(0, 3) as label} + {label} + {/each} + {#if labels.length > 3} + 'Label: ' + l) + .join('\n')}>+{labels.length - 3} + {/if} +
+ {/if} +
From a2ce44645fdbfa98bf250fac2d15d2b5b26c4b47 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:49:45 +0200 Subject: [PATCH 05/69] feat(frontend): dedup user drafts against the deployed baseline (#9618) --- .../components/apps/editor/AppEditor.svelte | 22 ++- .../src/lib/components/apps/migrateApp.ts | 32 +++ frontend/src/lib/components/apps/types.ts | 4 + frontend/src/lib/components/apps/utils.ts | 42 ++-- .../lib/components/usePageDraftSync.svelte.ts | 15 +- frontend/src/lib/userDraft.svelte.ts | 27 ++- frontend/src/lib/userDraftDbMigration.test.ts | 185 ++++++++++++++++++ frontend/src/lib/userDraftDbMigration.ts | 66 ++++++- .../(logged)/apps/edit/[...path]/+page.svelte | 13 ++ .../apps_raw/edit/[...path]/+page.svelte | 68 +++++-- .../flows/edit/[...path]/+page.svelte | 18 +- .../scripts/edit/[...path]/+page.svelte | 28 ++- 12 files changed, 463 insertions(+), 57 deletions(-) create mode 100644 frontend/src/lib/components/apps/migrateApp.ts create mode 100644 frontend/src/lib/userDraftDbMigration.test.ts diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 53bbcdb10c..0cffdc2400 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -34,7 +34,7 @@ sendUserToast, urlParamsToObject } from '$lib/utils' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import AppPreview from './AppPreview.svelte' import ComponentList from './componentsPanel/ComponentList.svelte' import ContextPanel from './contextPanel/ContextPanel.svelte' @@ -69,6 +69,7 @@ path, policy, summary, + deployedBaseline = undefined, fromHub = false, diffDrawer = undefined, savedApp = $bindable(undefined), @@ -87,6 +88,17 @@ migrateApp(untrack(() => app)) + // Migrated clone of the deployed baseline for the autosave `discardIf`. The + // live `stateApp` is `migrateApp`'d on mount, so the baseline must be too or + // an unedited draft would never compare equal. Captured once per mount (the + // route remounts AppEditor on path change), `undefined` for draft-only paths. + const migratedDeployedBaseline = untrack(() => { + if (!deployedBaseline) return undefined + const clone = structuredClone($state.snapshot(deployedBaseline)) as App + migrateApp(clone) + return clone + }) + // Inside a session pane the AIChatManager is injected via context. Sessions // have their own state machinery (sessionRuntime + per-fork backend), and // the user-facing $workspaceStore stays on the main workspace even when @@ -101,7 +113,13 @@ const appDraftHandle = inSessionPane ? undefined : // `canBeDisabled`: page editor's AutosaveIndicator carries the toggle. - UserDraft.use('app', appDraftPath, { canBeDisabled: true }) + // `discardIf`: an autosave reverting to the deployed app deletes the + // draft instead of persisting a no-op copy. + UserDraft.use('app', appDraftPath, { + canBeDisabled: true, + discardIf: (val) => + migratedDeployedBaseline !== undefined && draftValuesEqual(val, migratedDeployedBaseline) + }) // Suspend autosave around mount so the `firstMirror` seed write isn't POSTed // as the user's first edit; `onMount`-then-`tick` resumes once effects settle. if (appDraftHandle) UserDraft.stopSync('app', appDraftPath) diff --git a/frontend/src/lib/components/apps/migrateApp.ts b/frontend/src/lib/components/apps/migrateApp.ts new file mode 100644 index 0000000000..7e6d4f12cb --- /dev/null +++ b/frontend/src/lib/components/apps/migrateApp.ts @@ -0,0 +1,32 @@ +import type { App } from './types' +import { gridColumns } from './gridUtils' +import { allItems } from './editor/appUtilsCore' + +/** + * Normalize an `App` in place to the current schema: default `hiddenInlineScripts` + * type, migrate the legacy `doNotRecomputeOnInputChanged` flag, and default + * `fullHeight` on every grid item. Lives in its own light module (no app-editor + * component imports) so non-editor callers — e.g. the localStorage→DB draft + * migration — can reuse it without pulling the whole `apps/utils` graph. + */ +export function migrateApp(app: App) { + ;(app?.hiddenInlineScripts ?? []).forEach((x) => { + if (x.type == undefined) { + //@ts-ignore + x.type = 'inline' + } + //TODO: remove after migration is done + if (x.doNotRecomputeOnInputChanged != undefined) { + x.recomputeOnInputChanged = !x.doNotRecomputeOnInputChanged + x.doNotRecomputeOnInputChanged = undefined + } + }) + + allItems(app.grid, app.subgrids).forEach((x) => { + gridColumns.forEach((column: number) => { + if (x?.[column]?.fullHeight === undefined) { + x[column].fullHeight = false + } + }) + }) +} diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 8eef469472..90c7ba82b9 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -144,6 +144,10 @@ export interface AppEditorProps { path: string policy: Policy summary: string + /** Deployed app value the autosave `discardIf` compares against, so an + * edit reverting to deployed clears the draft instead of leaving a no-op. + * `undefined` for draft-only paths (no deployed baseline). */ + deployedBaseline?: App | undefined fromHub?: boolean diffDrawer?: DiffDrawerI | undefined savedApp?: diff --git a/frontend/src/lib/components/apps/utils.ts b/frontend/src/lib/components/apps/utils.ts index cc197762b3..85bdd774eb 100644 --- a/frontend/src/lib/components/apps/utils.ts +++ b/frontend/src/lib/components/apps/utils.ts @@ -2,7 +2,14 @@ import type { Schema } from '$lib/common' import { twMerge } from 'tailwind-merge' import { type AppComponent } from './editor/component' -import { isRunnableByName, isRunnableByPath, type AppInput, type InputType, type ResultAppInput, type StaticAppInput } from './inputType' +import { + isRunnableByName, + isRunnableByPath, + type AppInput, + type InputType, + type ResultAppInput, + type StaticAppInput +} from './inputType' import type { Output } from './rx' import type { App, @@ -11,30 +18,12 @@ import type { HorizontalAlignment, VerticalAlignment } from './types' -import { gridColumns } from './gridUtils' import { allItems, BG_PREFIX } from './editor/appUtilsCore' -export function migrateApp(app: App) { - ;(app?.hiddenInlineScripts ?? []).forEach((x) => { - if (x.type == undefined) { - //@ts-ignore - x.type = 'inline' - } - //TODO: remove after migration is done - if (x.doNotRecomputeOnInputChanged != undefined) { - x.recomputeOnInputChanged = !x.doNotRecomputeOnInputChanged - x.doNotRecomputeOnInputChanged = undefined - } - }) - - allItems(app.grid, app.subgrids).forEach((x) => { - gridColumns.forEach((column: number) => { - if (x?.[column]?.fullHeight === undefined) { - x[column].fullHeight = false - } - }) - }) -} +// `migrateApp` moved to its own light module so non-editor callers can reuse it +// without pulling the whole `apps/utils` graph; re-exported here for existing +// `from '../utils'` importers. +export { migrateApp } from './migrateApp' export function processSubcomponents(data: AppComponent, fn: (data: AppComponent) => void) { if (data.type == 'tablecomponent' && Array.isArray(data.actionButtons)) { @@ -133,7 +122,7 @@ export function isScriptByNameDefined(appInput: AppInput | undefined): boolean { return false } - if (appInput.type === 'runnable' && isRunnableByName(appInput.runnable)) { + if (appInput.type === 'runnable' && isRunnableByName(appInput.runnable)) { return appInput.runnable?.name != undefined } @@ -402,10 +391,7 @@ export function getAllScriptNames(app: App): string[] { const names = (allItems(app.grid, app?.subgrids) ?? []).reduce((acc, gridItem: GridItem) => { const { componentInput } = gridItem.data - if ( - componentInput?.type === 'runnable' && - isRunnableByName(componentInput.runnable) - ) { + if (componentInput?.type === 'runnable' && isRunnableByName(componentInput.runnable)) { acc.push(componentInput.runnable.name) } diff --git a/frontend/src/lib/components/usePageDraftSync.svelte.ts b/frontend/src/lib/components/usePageDraftSync.svelte.ts index 6edbe98070..f0cdf2b780 100644 --- a/frontend/src/lib/components/usePageDraftSync.svelte.ts +++ b/frontend/src/lib/components/usePageDraftSync.svelte.ts @@ -11,7 +11,7 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' * overwrite-as-fresh), the deployed-baseline `seed`, and the draft `remove`. * The entity-specific backend load and new-draft template stay in the page. */ -export interface PageDraftSyncOptions { +export interface PageDraftSyncOptions { itemKind: UserDraftItemKind /** Reactive draft storage path. `''` (e.g. viewing a historical hash) * releases the handle and skips registry/sync work. */ @@ -22,6 +22,14 @@ export interface PageDraftSyncOptions { * draft's own `path`, used by home-page deep links). Omit to skip * registry registration entirely (e.g. read-only hash views). */ effectivePath?: () => string | undefined + /** Predicate: is the value about to autosave back at the deployed + * baseline? When true the syncer POSTs a delete instead of a + * baseline-equal draft, so editing back to deployed clears the draft + * instead of leaving a no-op behind. MUST read the deployed baseline + * reactively (it's captured once per re-keyed acquire) and use + * `draftValuesEqual` so it can't disagree with the "unsaved changes" + * banner. Return false for draft-only items (no deployed baseline). */ + discardIf?: (val: V) => boolean } export interface PageDraftSync { @@ -41,14 +49,15 @@ export interface PageDraftSync { remove(): void } -export function usePageDraftSync(opts: PageDraftSyncOptions): PageDraftSync { +export function usePageDraftSync(opts: PageDraftSyncOptions): PageDraftSync { // One handle, re-keyed on (workspace, path); `''` path releases it. // `canBeDisabled` because these editors carry the "Enable auto-save" toggle. const handle = UserDraft.useReactive(() => ({ itemKind: opts.itemKind, path: opts.path(), workspace: opts.workspace(), - canBeDisabled: true + canBeDisabled: true, + discardIf: opts.discardIf })) // Live-editor-draft registry: lets the home-page "edit draft" link resolve diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 75bef6901b..ec80865b2b 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -186,11 +186,24 @@ function snapshotDraftValue(value: V | undefined): V | undefined { * run-as directives, not draft content, and the editor round-trips them * asymmetrically (`preserve_…` rebuilt as `!!cfg.permissioned_as` on load * but `|| undefined` on build) — keeping them produces a phantom banner. + * + * The rest are server-managed read-time metadata that ride along on the + * loaded deployed payload but never appear in the editor's draft content, so + * comparing them would mask a true baseline match: + * `draft_saved_at` (the draft's own save time), `edited_at` (deploy time), + * `edited_by` (deploy author), `workspace_id`, `version_id` (deployed version), + * and `is_draft` (backend presence flag). */ const DRAFT_COMPARE_IGNORED_FIELDS = [ 'permissioned_as', 'preserve_permissioned_as', - 'extra_perms' + 'extra_perms', + 'draft_saved_at', + 'edited_at', + 'edited_by', + 'workspace_id', + 'version_id', + 'is_draft' ] as const /** @@ -453,6 +466,8 @@ export const UserDraft = { opts?: UserDraftOptions & { /** See the `useMany` spec field. Default `false`. */ canBeDisabled?: boolean + /** See the `useMany` spec field. Captured once on first acquire. */ + discardIf?: (val: V) => boolean } ): UserDraftHandle { // Single-spec wrapper around `useMany`. `untrack` captures reactive @@ -460,7 +475,13 @@ export const UserDraft = { // workspace until unmount. For reactive `(kind, path)` use `useReactive`. const handles = UserDraft.useMany(() => untrack(() => [ - { itemKind, path, workspace: opts?.workspace, canBeDisabled: opts?.canBeDisabled } + { + itemKind, + path, + workspace: opts?.workspace, + canBeDisabled: opts?.canBeDisabled, + discardIf: opts?.discardIf + } ]) ) return handles[0] @@ -479,6 +500,8 @@ export const UserDraft = { path: string workspace?: string canBeDisabled?: boolean + /** See the `useMany` spec field. Captured per re-keyed acquire. */ + discardIf?: (val: V) => boolean } ): UserDraftHandle { const handles = UserDraft.useMany(() => [getSpec()]) diff --git a/frontend/src/lib/userDraftDbMigration.test.ts b/frontend/src/lib/userDraftDbMigration.test.ts new file mode 100644 index 0000000000..622401c2b5 --- /dev/null +++ b/frontend/src/lib/userDraftDbMigration.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +// Service layer is mocked: the migration must dedup against the deployed value +// without making real network calls. +const updateDraft = vi.fn(async (..._args: any[]) => ({ status: 'created' as const })) +const getScriptByPath = vi.fn() +const getFlowByPath = vi.fn() +const getAppByPath = vi.fn() + +vi.mock('./gen', () => ({ + DraftService: { updateDraft: (...a: unknown[]) => updateDraft(...(a as [])) }, + ScriptService: { getScriptByPath: (...a: unknown[]) => getScriptByPath(...(a as [])) }, + FlowService: { getFlowByPath: (...a: unknown[]) => getFlowByPath(...(a as [])) }, + AppService: { getAppByPath: (...a: unknown[]) => getAppByPath(...(a as [])) } +})) + +// `migrateApp` mutates an App in place; the deployed fixtures below are already +// in migrated shape, so a no-op keeps the dedup comparison exact. +vi.mock('./components/apps/migrateApp', () => ({ migrateApp: vi.fn() })) +vi.mock('./toast', () => ({ sendUserToast: vi.fn() })) +vi.mock('./userNamespace', () => ({ getUsernameForNamespace: () => 'me' })) +vi.mock('./utils/uuid', () => ({ randomUUID: () => 'fixed-uuid' })) + +import { migrateUserDraftsToDb } from './userDraftDbMigration' + +function lsKey(kind: string, path: string): string { + return `userdraft/w/main/${kind}/${path}` +} + +function setDraft(kind: string, path: string, value: unknown): string { + const key = lsKey(kind, path) + localStorage.setItem(key, JSON.stringify({ value, lastWrittenAt: 123 })) + return key +} + +beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + updateDraft.mockResolvedValue({ status: 'created' }) +}) + +describe('migrateUserDraftsToDb dedup', () => { + it('drops a draft deep-equal to the deployed script without uploading it', async () => { + const deployed = { path: 'u/me/s', summary: 'hi', content: 'x', language: 'bun' } + getScriptByPath.mockResolvedValue(deployed) + const key = setDraft('script', 'u/me/s', { ...deployed }) + + await migrateUserDraftsToDb() + + expect(getScriptByPath).toHaveBeenCalledWith({ + workspace: 'main', + path: 'u/me/s', + getDraft: false + }) + expect(updateDraft).not.toHaveBeenCalled() + expect(localStorage.getItem(key)).toBeNull() + }) + + it('uploads a draft that differs from the deployed script', async () => { + getScriptByPath.mockResolvedValue({ + path: 'u/me/s', + summary: 'hi', + content: 'x', + language: 'bun' + }) + const key = setDraft('script', 'u/me/s', { + path: 'u/me/s', + summary: 'hi', + content: 'EDITED', + language: 'bun' + }) + + await migrateUserDraftsToDb() + + expect(updateDraft).toHaveBeenCalledTimes(1) + expect(localStorage.getItem(key)).toBeNull() + }) + + it('treats `{ field: undefined }` and an absent field as equal (json normalization)', async () => { + // The draft table stores JSON, which strips `undefined` keys — the + // comparison must too, or a draft that only differs by an undefined key + // would never dedup. + getFlowByPath.mockResolvedValue({ summary: 'f', value: { modules: [] } }) + const key = setDraft('flow', 'u/me/f', { + summary: 'f', + value: { modules: [] }, + labels: undefined + }) + + await migrateUserDraftsToDb() + + expect(updateDraft).not.toHaveBeenCalled() + expect(localStorage.getItem(key)).toBeNull() + }) + + it('ignores server-managed metadata fields on the deployed flow payload', async () => { + // The deployed flow carries read-time metadata (workspace_id, edited_by, + // version_id, is_draft, timestamps) that the editor's draft content never + // holds — they must not block the dedup. + getFlowByPath.mockResolvedValue({ + workspace_id: 'admins', + path: 'u/me/f', + summary: 'f', + value: { modules: [] }, + edited_by: 'admin@windmill.dev', + edited_at: '2026-01-01T00:00:00Z', + archived: false, + schema: {}, + extra_perms: {}, + version_id: 2, + is_draft: false, + draft_saved_at: '2026-01-01T00:00:01Z' + }) + const key = setDraft('flow', 'u/me/f', { + path: 'u/me/f', + summary: 'f', + value: { modules: [] }, + archived: false, + schema: {} + }) + + await migrateUserDraftsToDb() + + expect(updateDraft).not.toHaveBeenCalled() + expect(localStorage.getItem(key)).toBeNull() + }) + + it('compares app drafts against the deployed `.value`', async () => { + const appValue = { + grid: [], + fullscreen: false, + unusedInlineScripts: [], + hiddenInlineScripts: [] + } + getAppByPath.mockResolvedValue({ value: { ...appValue } }) + const key = setDraft('app', 'u/me/a', { ...appValue }) + + await migrateUserDraftsToDb() + + expect(getAppByPath).toHaveBeenCalledWith({ + workspace: 'main', + path: 'u/me/a', + getDraft: false + }) + expect(updateDraft).not.toHaveBeenCalled() + expect(localStorage.getItem(key)).toBeNull() + }) + + it('uploads when there is no deployed item (fetch rejects)', async () => { + getScriptByPath.mockRejectedValue(new Error('404')) + const key = setDraft('script', 'u/me/new', { path: 'u/me/new', content: 'x' }) + + await migrateUserDraftsToDb() + + expect(updateDraft).toHaveBeenCalledTimes(1) + expect(localStorage.getItem(key)).toBeNull() + }) + + it('skips the deployed fetch for a pathless /add draft and uploads at a minted path', async () => { + // A legacy `/add` autosave has an empty path; there is no deployed item to + // dedup against, so it uploads to a freshly minted `u/{user}/draft_{uuid}`. + setDraft('script', '', { path: '', content: 'x' }) + + await migrateUserDraftsToDb() + + expect(getScriptByPath).not.toHaveBeenCalled() + expect(updateDraft).toHaveBeenCalledTimes(1) + expect(updateDraft.mock.calls[0][0]).toMatchObject({ + kind: 'script', + // `mintDraftAddPath` dashes→underscores (path segments are word chars). + path: 'u/me/draft_fixed_uuid' + }) + }) + + it('does not dedup unsupported kinds (e.g. variable) — uploads as before', async () => { + const key = setDraft('variable', 'u/me/v', { value: 'secret' }) + + await migrateUserDraftsToDb() + + expect(getScriptByPath).not.toHaveBeenCalled() + expect(getAppByPath).not.toHaveBeenCalled() + expect(updateDraft).toHaveBeenCalledTimes(1) + expect(localStorage.getItem(key)).toBeNull() + }) +}) diff --git a/frontend/src/lib/userDraftDbMigration.ts b/frontend/src/lib/userDraftDbMigration.ts index e6bca477a7..8859f588e0 100644 --- a/frontend/src/lib/userDraftDbMigration.ts +++ b/frontend/src/lib/userDraftDbMigration.ts @@ -6,13 +6,18 @@ * success — so it's idempotent without a sentinel; failed entries retry next * mount. Not workspace-gated: keys embed their own workspace and the token * covers all of them, so gating would orphan other-workspace entries. - * Deliberately self-contained (no `userDraft.svelte.ts` import) so the - * runtime module stays free of legacy decoders. + * + * Before uploading, each draft is compared against its deployed version + * (script / flow / app); a draft that's deep-equal to what's deployed carries + * no changes, so it's dropped instead of migrated (no error). */ -import { DraftService } from './gen' +import { AppService, DraftService, FlowService, ScriptService } from './gen' import type { UserDraftItemKind } from './gen' +import type { App } from './components/apps/types' +import { migrateApp } from './components/apps/migrateApp' import { sendUserToast } from './toast' +import { draftValuesEqual } from './userDraft.svelte' import { openDraftMigrationErrorModal, reportDraftMigrationError @@ -121,6 +126,48 @@ function readPayload(key: string): { value: unknown; lastWrittenAt?: number } | } } +/** + * Fetch the deployed value for a draft so the migration can drop a draft that + * carries no changes (deep-equal to what's already deployed) instead of + * uploading a no-op that would light up the "unsaved" badge. Returns the + * comparable deployed payload, or `undefined` when there's nothing to compare + * against: an unsupported kind, a pathless (minted `/add`) draft, or a fetch + * miss (404 — the path is draft-only, so the draft is genuinely new). `getDraft` + * is forced off so we compare against the deployed baseline, not our own draft. + */ +async function fetchDeployedValue( + workspace: string, + kind: UserDraftItemKind, + path: string +): Promise { + if (!path) return undefined + try { + switch (kind) { + case 'script': + return await ScriptService.getScriptByPath({ workspace, path, getDraft: false }) + case 'flow': + return await FlowService.getFlowByPath({ workspace, path, getDraft: false }) + case 'app': { + // The app autosave stores the inner `App`, not the `AppWithLastVersion` + // wrapper getAppByPath returns — compare against `.value`. Run + // `migrateApp` so the deployed value matches the editor-migrated draft + // (AppEditor `migrateApp`s `stateApp` on mount); without this an app + // whose deployed row predates those field migrations never dedups. + const app = await AppService.getAppByPath({ workspace, path, getDraft: false }) + const value = (app as { value?: App }).value + if (value) migrateApp(value) + return value + } + default: + return undefined + } + } catch { + // No deployed item at this path (or the fetch failed) — nothing to dedup + // against, so the caller proceeds to upload the draft. + return undefined + } +} + function collectKeys(): string[] { const keys: string[] = [] for (let i = 0; i < localStorage.length; i++) { @@ -188,6 +235,19 @@ export async function migrateUserDraftsToDb(): Promise { for (const { key, parsed, path, value, lastWrittenAt } of toMigrate) { try { + // Dedup: if the draft is deep-equal to the deployed version it carries + // no changes — drop it (no error) instead of uploading a no-op draft. + // Fetches against `parsed.path` (the real item path); minted `/add` + // drafts have `parsed.path === ''` and so are never deduped. + const deployed = await fetchDeployedValue(parsed.workspace, parsed.itemKind, parsed.path) + if (deployed !== undefined && draftValuesEqual(value, deployed)) { + try { + localStorage.removeItem(key) + } catch { + // Best-effort; a stale LS entry is harmless — it re-dedups next mount. + } + continue + } const res = await DraftService.updateDraft({ workspace: parsed.workspace, kind: parsed.itemKind, diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index cf81fe26e1..760b0cd65d 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -36,6 +36,10 @@ /** No deployed app at the URL path. Drives the editor's deploy: * `createApp` vs `updateApp`. Flips false once a deploy lands here. */ let isNewApp = $state(false) + /** Deployed app value this load, the baseline AppEditor's autosave + * `discardIf` compares against. `undefined` for draft-only paths so they + * never self-destruct by matching a non-existent baseline. */ + let deployedBaseline = $state(undefined) let otherDraftsUsers = $state([]) let loadedFromDraft = $state(false) let othersModalOpen = $state(false) @@ -65,6 +69,8 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + // Brand-new app: no deployed baseline, so never discard-on-equal. + deployedBaseline = undefined // Capture every seeding param BEFORE stripping the URL flag. const templatePath = page.url.searchParams.get('template') const templateId = page.url.searchParams.get('template_id') @@ -185,6 +191,12 @@ getDraft }) if (tok !== loadAppToken) return + // Deployed App value for AppEditor's autosave `discardIf`, captured BEFORE + // the draft swap below replaces `backendApp.value`. `undefined` when + // there's no deployed row (draft-only path). + deployedBaseline = backendApp.no_deployed + ? undefined + : (structuredClone(stateSnapshot(backendApp.value)) as App) // `other_drafts_users` only computed when `getDraft`; don't clobber the // known list on a `getDraft:false` reload. See /scripts/edit's loader. if (getDraft) { @@ -343,6 +355,7 @@ on:restore={onRestore} summary={app.summary} app={app.value} + {deployedBaseline} newPath={app.value?.draft_path ?? app.path} path={page.params.path ?? ''} policy={app.policy} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index a1b789b0df..2fe1434d96 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -13,7 +13,7 @@ import { page } from '$app/state' import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { importStore } from '$lib/components/apps/store' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte' import { armRestartOnFirstInteraction, runResetToDeployed } from '$lib/userDraftToast' import DraftEditorModals from '$lib/components/common/confirmationModal/DraftEditorModals.svelte' @@ -72,13 +72,21 @@ * React/Svelte + data config + optional AI prompt before the editor goes live. */ let templatePicker = $state(false) + /** Deployed raw-app bundle this load, the baseline the autosave `discardIf` + * compares against. `undefined` for draft-only paths so they never + * self-destruct by matching a non-existent baseline. */ + let deployedBaseline = $state(undefined) + // Page-level draft orchestration. `path` is a mount-scoped plain `let` (the // editor remounts per path), so this re-keys only on workspace change. // effectivePath omitted: the live-editor-draft entry is owned by RawAppEditor. const draftSync = usePageDraftSync({ itemKind: 'raw_app', path: () => path, - workspace: () => $workspaceStore + workspace: () => $workspaceStore, + // Autosaves landing back on the deployed raw app become deletes, so + // reverting edits clears the draft instead of leaving a no-op behind. + discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline) }) // Persist the bundle whenever any of the four pieces of state changes. @@ -98,30 +106,42 @@ summary, policy, custom_path: savedApp?.custom_path, - // Only persist when set, so the field disappears from the saved JSON - // once the typed path matches the baseline again (or on deploy). - ...(pendingDraftPath ? { draft_path: pendingDraftPath } : {}) + // Persist the typed path as `draft_path` only when it actually differs + // from the current path — a `draft_path` equal to the baseline is a + // no-op that would block the draft from deduping against the deployed + // app (which carries none). Drops back out on a revert or deploy. + ...(pendingDraftPath && pendingDraftPath !== (savedApp?.path ?? '') + ? { draft_path: pendingDraftPath } + : {}) } as RawAppDraft }) - function extractRawApp(app: any) { - runnables = app.value.runnables - // Support old formats and new format - if (app.value.data) { - const d = app.value.data + /** Normalize a raw-app `value` into the editor's `data` config, supporting + * the old nested `creation` / `datatables` shapes. `undefined` when the + * value carries no data config (caller keeps the current/default `data`). */ + function extractDataConfig(value: any): RawAppData | undefined { + if (value?.data) { + const d = value.data // Handle old nested creation format if (d.creation) { - data = { + return { tables: d.tables ?? [], datatable: d.creation.datatable, schema: d.creation.schema } - } else { - data = d } - } else if (app.value.datatables) { - data = { ...DEFAULT_DATA, tables: app.value.datatables } + return d + } else if (value?.datatables) { + return { ...DEFAULT_DATA, tables: value.datatables } } + return undefined + } + + function extractRawApp(app: any) { + runnables = app.value.runnables + // Support old formats and new format + const extractedData = extractDataConfig(app.value) + if (extractedData) data = extractedData files = app.value.files summary = app.summary // lastVersion = app.version @@ -158,6 +178,8 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + // Brand-new raw app: no deployed baseline, so never discard-on-equal. + deployedBaseline = undefined // Suspend autosave across the bootstrap: the seed template and the // picker's `onStart` are programmatic writes that must not POST as the // first edit. Resume on first interaction (the template-card click or @@ -259,6 +281,22 @@ // See /apps/edit's loader. draftSavedAt = backendApp.draft_saved_at as string | undefined deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined) + // Deployed baseline for the autosave `discardIf`, captured BEFORE the swap + // below mutates `backendApp`. Mirrors the bundle `$effect`'s shape (minus + // the edit-only `draft_path`) so an unedited draft compares equal. + // `undefined` when there's no deployed row. + deployedBaseline = backendApp.no_deployed + ? undefined + : (structuredClone( + stateSnapshot({ + files: backendApp.value?.files ?? {}, + runnables: backendApp.value?.runnables ?? {}, + data: extractDataConfig(backendApp.value) ?? { ...DEFAULT_DATA }, + summary: backendApp.summary ?? '', + policy: backendApp.policy, + custom_path: backendApp.custom_path + }) + ) as RawAppDraft) // The raw-app autosave stores a flat `RawAppDraft`, but this loader (and // `extractRawApp`) needs the deployed shape with `files`/`runnables`/`data` // under `.value` and the rest top-level. Re-wrap the saved draft (`.draft`): diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 1e1fcdccbc..2391e5c723 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -19,7 +19,7 @@ import { tick, untrack } from 'svelte' import type { stepState } from '$lib/components/stepHistoryLoader.svelte' import { page } from '$app/state' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { armRestartOnFirstInteraction, discardDraftAfterDeploy, @@ -43,6 +43,10 @@ } let savedFlow: Flow | undefined = $state(undefined) + /** Deployed flow this load, the baseline the autosave `discardIf` compares + * against. `undefined` for draft-only paths (no deployed) so a draft-only + * item never self-destructs by "matching" a non-existent baseline. */ + let deployedBaseline = $state(undefined) let otherDraftsUsers = $state([]) let loadedFromDraft = $state(false) let othersModalOpen = $state(false) @@ -65,7 +69,10 @@ const draftSync = usePageDraftSync({ itemKind: 'flow', path: () => flowDraftPath, - workspace: () => $workspaceStore + workspace: () => $workspaceStore, + // Autosaves landing back on the deployed flow become deletes, so reverting + // edits clears the draft instead of leaving a no-op behind. + discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline) }) function emptyFlow(): Flow { @@ -136,6 +143,8 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + // Brand-new flow: no deployed baseline, so never discard-on-equal. + deployedBaseline = undefined // Suspend autosave around the bootstrap cascade: the Path widget's // `initPath → reset → bind:path` chain seeds a friendly auto-name that // FlowBuilder mirrors into `flow.draft_path` — a programmatic write that @@ -336,6 +345,11 @@ ? ({ ...deployedFlow, ...draftFromBackend } as Flow) : (deployedFlow as Flow) savedFlow = structuredClone($state.snapshot(effectiveFlow)) as Flow + // Baseline for the autosave `discardIf`: the deployed flow WITHOUT the + // draft overlay (matches the unedited seed when no draft exists). + deployedBaseline = backendFlow.no_deployed + ? undefined + : (structuredClone($state.snapshot(deployedFlow)) as Flow) // Surface the saved `draft_path` to the Path widget so the topbar shows the // pending name, not the `draft_{uuid}` URL. Else the widget seeds from the // URL, the first edit clobbers `draft_path`, and the friendly name is lost. diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index f04ef93188..5ed2c4f428 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -17,7 +17,7 @@ import { get } from 'svelte/store' import { untrack } from 'svelte' import { page } from '$app/state' - import { UserDraft } from '$lib/userDraft.svelte' + import { UserDraft, draftValuesEqual } from '$lib/userDraft.svelte' import { discardDraftAfterDeploy, runResetToDeployed } from '$lib/userDraftToast' import { usePageDraftSync } from '$lib/components/usePageDraftSync.svelte' import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' @@ -65,11 +65,19 @@ // Page-level draft orchestration: autosave handle (re-keyed on nav via // `draftPath`), live-editor-draft registry, `recordRemoteSync`, removal. // `draftSync.draft` stays a stable lvalue for `bind:script`. + /** Deployed script this load (with `parent_hash` grafted to match the + * unedited draft seed), the baseline the autosave `discardIf` compares + * against. `undefined` for draft-only paths so they never self-destruct. */ + let deployedBaseline = $state(undefined) + const draftSync = usePageDraftSync({ itemKind: 'script', path: () => draftPath, workspace: () => $workspaceStore, - effectivePath: () => draftSync.draft?.path ?? draftPath + effectivePath: () => draftSync.draft?.path ?? draftPath, + // Autosaves landing back on the deployed script become deletes, so + // reverting edits clears the draft instead of leaving a no-op behind. + discardIf: (val) => deployedBaseline !== undefined && draftValuesEqual(val, deployedBaseline) }) // Seed from the URL so ScriptBuilder mounts with a populated `initialPath` @@ -129,6 +137,8 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + // Brand-new script: no deployed baseline, so never discard-on-equal. + deployedBaseline = undefined // Capture every seeding param BEFORE stripping the URL flag. const templatePath = page.url.searchParams.get('template') const hubPath = page.url.searchParams.get('hub') @@ -259,6 +269,9 @@ }) if (tok !== loadScriptToken) return savedScript = structuredClone($state.snapshot(scriptByHash)) + // Historical-hash view is read-only relative to drafts (`draftPath` is + // '' → detached handle), so no baseline is needed. + deployedBaseline = undefined draftSync.draft = { ...scriptByHash, parent_hash: hash, lock: undefined } } else { const backendScript = await ScriptService.getScriptByPath({ @@ -292,6 +305,17 @@ ? { ...deployedScript, ...draftFromBackend } : (deployedScript as EditableScript) savedScript = structuredClone($state.snapshot(effectiveScript)) + // Baseline for the autosave `discardIf`: the deployed script with the + // same `parent_hash` graft the seed below applies, so the unedited + // draft compares equal. `undefined` when there's no deployed row. + deployedBaseline = backendScript.no_deployed + ? undefined + : structuredClone( + $state.snapshot({ + ...deployedScript, + parent_hash: topHash ?? backendScript.hash + }) + ) // `parent_hash` is grafted on so the editor's compile reuses the // deployed lock. The first cell write after `acquireEntry` is swallowed // by the syncer's seed guard, so this load doesn't POST. From f9cfeb0dbaac53fe92dbc21d99570e69ef8e40bf Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 16 Jun 2026 17:55:39 +0200 Subject: [PATCH 06/69] chore(main): release 1.728.0 (#9613) * chore(main): release 1.728.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 19 +++ backend/Cargo.lock | 156 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 137 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8441043de0..959bc7e739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.728.0](https://github.com/windmill-labs/windmill/compare/v1.727.0...v1.728.0) (2026-06-16) + + +### Features + +* **frontend:** adapt AI-chat/sessions drafts to DB-backed model ([#9601](https://github.com/windmill-labs/windmill/issues/9601)) ([611c70a](https://github.com/windmill-labs/windmill/commit/611c70acd211cf4b8f8308da4a264c670a2f5f43)) +* **frontend:** consolidate draft-migration errors into a single toast + modal ([#9612](https://github.com/windmill-labs/windmill/issues/9612)) ([bc0d5bf](https://github.com/windmill-labs/windmill/commit/bc0d5bf241df3633921bd9d43d171e91034fbfcf)) +* **frontend:** dedup user drafts against the deployed baseline ([#9618](https://github.com/windmill-labs/windmill/issues/9618)) ([a2ce446](https://github.com/windmill-labs/windmill/commit/a2ce44645fdbfa98bf250fac2d15d2b5b26c4b47)) + + +### Bug Fixes + +* **frontend:** reset deleteWorkspaceForkModal on confirm in SidebarContent ([#9619](https://github.com/windmill-labs/windmill/issues/9619)) ([7cb5c6e](https://github.com/windmill-labs/windmill/commit/7cb5c6e749b2020dee5ee1499f0dc69c5109a6d8)) +* **frontend:** session Drafts drawer uses raw_app kind for the raw-app diff ([#9617](https://github.com/windmill-labs/windmill/issues/9617)) ([46288b6](https://github.com/windmill-labs/windmill/commit/46288b6143efae4dfdf6fe068b97a1e8831fce6a)) +* **nativets:** respect custom CA certs in in-process fetch runtime ([#9615](https://github.com/windmill-labs/windmill/issues/9615)) ([41562c7](https://github.com/windmill-labs/windmill/commit/41562c7d7c708d7d056d9b3d0c39b994a6f4a016)) +* **ResourceForm:** initialize JSON editor when resource type schema is unavailable ([#9611](https://github.com/windmill-labs/windmill/issues/9611)) ([5a24057](https://github.com/windmill-labs/windmill/commit/5a2405743b4622fc1021109114d007057abd5dfd)) +* show folder labels in the folder list table ([#9620](https://github.com/windmill-labs/windmill/issues/9620)) ([651fa13](https://github.com/windmill-labs/windmill/commit/651fa13ee80ff76e5a53ef1ed545b03ce6792294)) +* show last updated date per user in other-users-drafts modal ([#9614](https://github.com/windmill-labs/windmill/issues/9614)) ([f6104ce](https://github.com/windmill-labs/windmill/commit/f6104ce05c4005ffb9fe8112782d1ef6d3065300)) + ## [1.727.0](https://github.com/windmill-labs/windmill/compare/v1.726.1...v1.727.0) (2026-06-16) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index cdd9f6089e..7edfa22113 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13792,7 +13792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-nats", @@ -13874,7 +13874,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.727.0" +version = "1.728.0" dependencies = [ "async-stream", "async-trait", @@ -13907,7 +13907,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13920,7 +13920,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "argon2", @@ -14058,7 +14058,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14081,7 +14081,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14094,7 +14094,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14120,7 +14120,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.727.0" +version = "1.728.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14130,7 +14130,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14147,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14169,7 +14169,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14192,7 +14192,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14208,7 +14208,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14229,7 +14229,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14250,7 +14250,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14264,7 +14264,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-nats", @@ -14299,7 +14299,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14324,7 +14324,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14342,7 +14342,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14364,7 +14364,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14384,7 +14384,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14415,7 +14415,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14443,7 +14443,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.727.0" +version = "1.728.0" dependencies = [ "lazy_static", "serde", @@ -14455,7 +14455,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.727.0" +version = "1.728.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14480,7 +14480,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14494,7 +14494,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.727.0" +version = "1.728.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14527,7 +14527,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.727.0" +version = "1.728.0" dependencies = [ "chrono", "lazy_static", @@ -14541,7 +14541,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14560,7 +14560,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.727.0" +version = "1.728.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14662,7 +14662,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.727.0" +version = "1.728.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14681,7 +14681,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.727.0" +version = "1.728.0" dependencies = [ "regex", "serde", @@ -14696,7 +14696,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14720,7 +14720,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "futures", @@ -14737,7 +14737,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.727.0" +version = "1.728.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14753,7 +14753,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -14774,7 +14774,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -14805,7 +14805,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "arc-swap", @@ -14830,7 +14830,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-stream", @@ -14864,7 +14864,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "futures", @@ -14882,7 +14882,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.727.0" +version = "1.728.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14891,7 +14891,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -14903,7 +14903,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde_json", @@ -14915,7 +14915,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "gosyn", @@ -14927,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -14939,7 +14939,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde_json", @@ -14951,7 +14951,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "nu-parser", @@ -14962,7 +14962,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14973,7 +14973,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14985,7 +14985,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14996,7 +14996,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-recursion", @@ -15018,7 +15018,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde_json", @@ -15030,7 +15030,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -15044,7 +15044,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15061,7 +15061,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -15074,7 +15074,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde", @@ -15086,7 +15086,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -15104,7 +15104,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15120,7 +15120,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15136,7 +15136,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde", @@ -15147,7 +15147,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-recursion", @@ -15185,7 +15185,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "const_format", @@ -15224,7 +15224,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.727.0" +version = "1.728.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15235,7 +15235,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-recursion", @@ -15267,7 +15267,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15291,7 +15291,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15324,7 +15324,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15357,7 +15357,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15377,7 +15377,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15411,7 +15411,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15447,7 +15447,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15470,7 +15470,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15494,7 +15494,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-nats", @@ -15518,7 +15518,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15553,7 +15553,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15581,7 +15581,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-trait", @@ -15606,7 +15606,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15625,7 +15625,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-once-cell", @@ -15735,7 +15735,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.727.0" +version = "1.728.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index de302ce748..a87bf4a918 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.727.0" +version = "1.728.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.727.0" +version = "1.728.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 8f646c0503..2db1a183a2 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.727.0" +version = "1.728.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.727.0" +version = "1.728.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.727.0" +version = "1.728.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.727.0" +version = "1.728.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 3039b52af4..2d14bb32fb 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.727.0" +version = "1.728.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 38bacfb7e7..96c5c98f17 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.727.0 + version: 1.728.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 3610ad0602..c8288d83a9 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.727.0"; +export const VERSION = "v1.728.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index b2c6688154..23ffeef62a 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.727.0"; +export const VERSION = "1.728.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 54b326cff7..e6788ac60c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.727.0", + "version": "1.728.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.727.0", + "version": "1.728.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index f750d6a2ad..40487151e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.727.0", + "version": "1.728.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 1f1f43f7b9..096424c4a4 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.727.0" +wmill = ">=1.728.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 51613e5e73..172b601fbe 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.727.0 + version: 1.728.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 0efc3d6930..c05ef22da6 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.727.0' + ModuleVersion = '1.728.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 94a6aae2b0..c0a4eaea0c 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.727.0" +version = "1.728.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 2ef900d8dd..58edd0705c 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.727.0", + "version": "1.728.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index f9595d1a7e..615ae734b6 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.727.0", + "version": "1.728.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 06327a1ec7..4525c0372d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.727.0 +1.728.0 From e4bfeb29bc4e89669863b5f6396904a331167658 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 16 Jun 2026 23:19:12 +0200 Subject: [PATCH 07/69] fix(frontend): persist session-editor draft path/summary edits + per-line diff tooltips (#9622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): persist raw-app draft path edits in the session editor Renaming a raw app's path in the session preview editor never triggered a draft save: the header surfaced the typed path as `pendingDraftPath`, but RawAppEditorView ignored it (it never reached runtime.rawApp.val), and the RawAppDraft codec didn't serialize a path field — so the autosave signature (JSON.stringify(draft)) was unchanged and nothing was written. The rename was lost and the home/review/Drafts lists kept the original `draft_path`. - appDraftCodec: make `draft_path` a real draft + runtime field, serialized by runtimeRawAppToDraft and round-tripped by applyDraftToRuntimeRawApp, so a path change moves the sig and fires a save. - sessionRuntime.loadRawApp (+ inline rawApp.val type): seed `draft_path` from the loaded draft so it survives reloads. - RawAppEditorView: bind the header's `pendingDraftPath`, mirror it into runtime.rawApp.val.draft_path (guarded so the initial undefined can't clobber the seed or fire a spurious save), and seed the path widget from `draft_path ?? path`. Mirrors the full-page /apps_raw/edit route. - appDraftCodec.test: add a draft_path round-trip test. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): per-line tooltips in workspace item diff rows The summary line now shows the full summary on hover and the path line the full path, instead of one row-level title surfacing the path everywhere. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): persist flow/script draft path edits in the session editor The session sync dedups on a per-kind signature that omitted the path, so a rename never moved the signature and never autosaved. Add path/draft_path to the flow signature, and derive draft_path in the script codec (scripts bind the Path widget to script.path directly) so the rename both autosaves and shows the typed name in the home/Drafts lists. Mirrors the raw-app fix. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): commit editor summary edits live instead of on blur EditableInput only fired onSave on Enter/blur, so the summary in the shared editor header only updated when the field lost focus. Add an opt-in commitOnInput that fires onSave per keystroke and enable it for the header summary, so flow/script/raw-app summaries autosave as you type. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): preserve renamed never-deployed script path on session re-seed loadScript seeded a draft-only script's baseline path from the storage key, so re-running it with the draft still in memory (e.g. a script→script switch) reset the path to draft_ and the next autosave dropped draft_path, clobbering the rename. Seed from the draft's own draft_path/path instead. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): clear raw-app draft_path when the path rename is reverted The mirror effect only ever set draft_path, so reverting/clearing the path field left a stale friendly name in the draft (persisted by the codec and shown in the home/Drafts lists). Track whether a real typed path was surfaced so a revert clears draft_path while the initial pre-bind undefined still can't clobber the loadRawApp-seeded value. Mirrors the script codec's drop-on-revert. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/lib/components/EditorHeader.svelte | 1 + .../lib/components/WorkspaceItemRow.svelte | 32 +++++++++---- .../components/common/EditableInput.svelte | 28 +++++++++-- .../sessions/RawAppEditorView.svelte | 30 +++++++++++- .../sessions/SessionEditorTarget.svelte | 2 +- .../components/sessions/appDraftCodec.test.ts | 19 ++++++++ .../lib/components/sessions/appDraftCodec.ts | 12 ++++- .../components/sessions/flowDraftSig.test.ts | 11 +++++ .../lib/components/sessions/flowDraftSig.ts | 15 ++++-- .../sessions/sessionDraftCodecs.test.ts | 48 +++++++++++++++++++ .../components/sessions/sessionDraftCodecs.ts | 32 +++++++++++-- .../sessions/sessionRuntime.svelte.ts | 14 +++++- 12 files changed, 218 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/components/sessions/sessionDraftCodecs.test.ts diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index 0e107593ea..87a0c6e1da 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -237,6 +237,7 @@ value={summary ?? ''} placeholder="Add a summary..." editable={summaryEditable} + commitOnInput size="sm" onSave={handleSummarySave} textClass="text-xs font-semibold text-emphasis leading-tight" diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte index bb5fc9b74f..fe923b12ca 100644 --- a/frontend/src/lib/components/WorkspaceItemRow.svelte +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -50,7 +50,9 @@ doesn't steal focus from a sibling search input (matches the picker). /** Extra left padding (px) for tree-view indentation. Adds to the * default `px-3` horizontal padding. */ indent?: number - /** Title tooltip shown on hover; defaults to the secondary text. */ + /** Full path tooltip for the secondary line; defaults to the secondary + * text. The summary line gets its own tooltip (the full summary) so each + * truncated line reveals its own content on hover. */ title?: string /** When set, the row renders as an `` link * instead of a ` @@ -503,23 +628,18 @@ + (discardTarget = undefined)} > - {#if discardTarget?.draft_only} -

- {discardTarget?.path} exists only as a - draft. Discarding it will permanently delete the item. This cannot be undone. -

- {:else} -

- Discard the draft of - {discardTarget?.path}? The deployed - version is unaffected. -

- {/if} +

+ {discardTarget?.draft_path ?? discardTarget?.path} exists only as a draft. Discarding it will permanently delete the item. This cannot be undone. +

diff --git a/frontend/src/lib/components/DraftBadge.svelte b/frontend/src/lib/components/DraftBadge.svelte index 46c24578a4..a3e6d5d048 100644 --- a/frontend/src/lib/components/DraftBadge.svelte +++ b/frontend/src/lib/components/DraftBadge.svelte @@ -29,6 +29,9 @@ workspace?: string itemKind?: UserDraftItemKind path?: string + /** Offer "Fork" alongside "View JSON" on other users' rows. The deploy + * page sets this false: forking a new item is meaningless there. */ + allowFork?: boolean } let { @@ -38,7 +41,8 @@ currentUsername = undefined, workspace = undefined, itemKind = undefined, - path = undefined + path = undefined, + allowFork = true }: Props = $props() // Authed user lands first; everyone else keeps the backend's ordering. @@ -163,7 +167,14 @@ {#if showBadge} - + + {#snippet trigger()} {#if orderedUsers.length > 0} @@ -244,7 +255,7 @@ View JSON - {#if !$userStore?.operator} + {#if allowFork && !$userStore?.operator} + + {#if draftItem.mine} + + {/if}
@@ -749,16 +951,10 @@ @@ -867,6 +1063,14 @@ {/if} + {#if registryCcCapable()} + + {/if} {#key resourceTypeInfo} Create a resource backed by an OAuth connection, whose token is fetched from the external services and refreshed automatically if needed before expiration. - + {#if ccBringYourOwn} + + {/if} {#if resourceTypeInfo?.description} @@ -909,26 +1118,40 @@ {#if supportsClientCredentials} -
-

Authentication Method

-
- - - - Server-to-server authentication without user interaction. -

- Provide your own OAuth client credentials for this resource. -
-
+
+

Authentication

+ {#if ccOnly || ccBringYourOwn} +
+ {#if useSharedInstanceCreds} + {resourceType} connects server-to-server using the credentials configured for this + instance. The token is acquired and refreshed automatically. + {:else} + {resourceType} connects server-to-server. Enter a client ID and secret; the token is + acquired and refreshed automatically. + {/if} +
+ {:else} +
+ + enableClientCredentials()} + /> +
+ {/if} - {#if useClientCredentials} + {#if useClientCredentials && !useSharedInstanceCreds}
- + {#if ccInstanceMeta} + + {/if}
{/if}
diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index 335b64277c..2ad6ab917a 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -18,6 +18,8 @@ import { capitalize, type Item } from '$lib/utils' import ClipboardPanel from './details/ClipboardPanel.svelte' import Toggle from './Toggle.svelte' + import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import DropdownV2 from './DropdownV2.svelte' import { APP_TO_ICON_COMPONENT } from './icons' import { ExternalLink, Plus, Circle, X } from 'lucide-svelte' @@ -100,6 +102,10 @@ // carry a `connect_config_template`. Derived from the registry so adding a // new one needs only a JSON entry — they get a builtin tile + the generic // instance-name input below, with no frontend change. + // Every per-instance templated provider gets a settings tile + instance input: + // authorization-code ones (ServiceNow) provide an `auth_url`, client-credentials-only + // ones (Coupa) provide only a `token_url`. The admin enters their instance host so + // the shared credentials point at the right endpoint. const connectConfigTemplates: Record = Object.fromEntries( Object.entries(oauthConnectRegistry) .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg) @@ -112,6 +118,55 @@ ...windmillBuiltinsTemplated ] + /** Resolve a `_sandbox` key to its parent registry entry (sandbox + * variants inherit the parent's grant_types), matching the connect dialog. */ + function canonicalRegistryKey(name: string): string { + return name.endsWith('_sandbox') ? name.slice(0, -'_sandbox'.length) : name + } + + /** The static registry declares client credentials for this provider */ + function registryCcCapable(name: string): boolean { + return ( + (oauthConnectRegistry as Record)[ + canonicalRegistryKey(name) + ]?.grant_types?.includes('client_credentials') ?? false + ) + } + + /** The static registry supports authorization code for this provider. A + * provider with no explicit grant_types defaults to authorization code. */ + function registryAuthCodeCapable(name: string): boolean { + const reg = (oauthConnectRegistry as Record)[canonicalRegistryKey(name)] + if (!reg) return false + return reg.grant_types ? reg.grant_types.includes('authorization_code') : true + } + + /** Built-in provider that only supports client credentials (e.g. Coupa): no + * authorization-code flow to choose, so the grant is fixed. */ + function registryCcOnly(name: string): boolean { + return registryCcCapable(name) && !registryAuthCodeCapable(name) + } + + /** Map the entry's grant_types to the single-select choice (so the segmented + * control always has exactly one selected and can never be empty) */ + function grantChoice(name: string): string { + const gts = oauths?.[name]?.['grant_types'] ?? ['authorization_code'] + const cc = gts.includes('client_credentials') + const ac = gts.includes('authorization_code') + if (cc && ac) return 'both' + if (cc) return 'client_credentials' + return 'authorization_code' + } + + /** Set the grant types from the segmented choice. The instance credentials are + * then used for every selected grant — authorization-code popup and/or + * server-to-server. */ + function setGrantChoice(name: string, choice: string) { + if (!oauths || !oauths[name]) return + oauths[name]['grant_types'] = + choice === 'both' ? ['authorization_code', 'client_credentials'] : [choice] + } + let showCustomOAuthForm = $state(false) let customOAuthName = $state('') let customNameInput = $state() @@ -125,7 +180,11 @@ if (oauths && name) { // Create a new object to ensure the new item is added at the end const newOauths = { ...oauths } - newOauths[name] = { id: '', secret: '', grant_types: ['authorization_code'] } + newOauths[name] = { + id: '', + secret: '', + grant_types: registryCcOnly(name) ? ['client_credentials'] : ['authorization_code'] + } oauths = newOauths dropdownOpen = false } @@ -463,49 +522,51 @@ bind:password={oauths[k]['secret']} /> - {#if k === 'visma' || !windmillBuiltins.includes(k)} -
-
- { - const target = e.target as HTMLInputElement - if (oauths && oauths[k]) { - if (!oauths[k]['grant_types']) { - oauths[k]['grant_types'] = ['authorization_code'] - } - if (target.checked) { - if (!oauths[k]['grant_types'].includes('client_credentials')) { - oauths[k]['grant_types'] = [ - ...oauths[k]['grant_types'], - 'client_credentials' - ] - } - } else { - oauths[k]['grant_types'] = oauths[k]['grant_types'].filter( - (gt: string) => gt !== 'client_credentials' - ) - } - } - }} - /> - Support Client Credentials Flow + These credentials are for + {#if !windmillBuiltins.includes(k) || (registryCcCapable(k) && registryAuthCodeCapable(k))} + setGrantChoice(k, v)} + > + {#snippet children({ item })} + + + + {/snippet} + + {:else if registryCcCapable(k)} + + Client credentials (server-to-server) + Fill Client ID and Secret to share one service account, or leave them empty + so each user brings their own. - - Enables server-to-server authentication without user interaction. Use for - automated scripts and background jobs. -

- When enabled, users can provide their own client credentials at the resource - level. The Client ID and Secret configured above are only used for the traditional - OAuth flow (popup window). -
-
-
- {/if} + + {:else} + Authorization code (browser sign-in) + {/if} +
{#if k === 'azure_oauth'} {:else if !windmillBuiltins.includes(k) && k != 'slack'} diff --git a/frontend/src/lib/components/CustomOauth.svelte b/frontend/src/lib/components/CustomOauth.svelte index 432a02152b..942dc553d7 100644 --- a/frontend/src/lib/components/CustomOauth.svelte +++ b/frontend/src/lib/components/CustomOauth.svelte @@ -1,12 +1,12 @@