From c537d45e4982f30a44026d6644d5340e56f16ef9 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 9 Jul 2026 11:01:53 +0200 Subject: [PATCH 01/52] fix(frontend): persist forked "Copy of X" script drafts (#10021) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): persist forked "Copy of X" script drafts Forking a script ("Copy of X"), hub-forking, or seeding a new draft from a URL/YAML/JSON import opened the editor with pre-filled content, but the saved draft never appeared in the scripts list. The edit route suspends autosave for every `?new_draft=true` load (`UserDraft.stopSync`) so the seed write doesn't post as the user's first edit, expecting ScriptBuilder to lift it. ScriptBuilder's `restartSync` only ran inside `if (script.content == '')`, so a non-empty seed (fork / hub / import) skipped it and left autosave suspended for the session — both autosave and explicit Ctrl+S then silently no-op'd, so the draft was never written and never listed. Add an `else if` branch for pre-filled `new_draft` seeds that runs the same stores-gated restart cascade (restart only, no template seeding), restoring parity with the empty-new-script flow. The empty-seed block is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * docs: condense scheduleRestartSync comments to ≤4 lines Address Codex review P2: trim the helper and new-branch comments to the core invariant per AGENTS.md's comment-length rule. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/lib/components/ScriptBuilder.svelte | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index c6d48e5e89..aa68a2bfc1 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -397,6 +397,43 @@ let pathError = $state('') let loadingSave = $state(false) + // Lifts the route's `?new_draft=true` `stopSync` suspension, but only after the + // stores-gated bind:path cascade (and, for an empty seed, `initContent` via + // `markContentReady`) settles — resuming earlier posts the seed/auto-generated + // path as the user's first "edit". `restarted` keeps re-entry idempotent. + function scheduleRestartSync( + path: string, + opts?: { waitForContent?: boolean } + ): { markContentReady: () => void } { + let contentReady = !opts?.waitForContent + let storesReady = !!($userStore && $workspaceStore) + let restarted = false + async function tryRestart() { + if (restarted || !contentReady || !storesReady) return + // 500ms covers the bind:path cascade even on cold reload; two ticks + // weren't enough (bind:path fired ~100ms after restart, posting an edit). + await new Promise((r) => setTimeout(r, 500)) + if (restarted) return + restarted = true + UserDraft.restartSync('script', path) + } + if (!storesReady) { + $effect(() => { + if ($userStore && $workspaceStore) { + storesReady = true + untrack(() => void tryRestart()) + } + }) + } + void tryRestart() + return { + markContentReady() { + contentReady = true + void tryRestart() + } + } + } + if (script.content == '') { // Suspend autosave around the bootstrap mutations: seeding the template // content is a programmatic write, not the user's first edit. The handle @@ -417,36 +454,13 @@ } } } - // Sync resumes only after two cascades settle: the async `initContent`, - // and the stores-gated `initPath → reset → onMetaChange → bind:path` - // auto-naming chain. Whichever lands last calls `tryRestart`; otherwise - // the auto-generated path posts as the first "user edit". - let initContentDone = false - let storesReady = !!($userStore && $workspaceStore) - let restarted = false - async function tryRestart() { - if (restarted || !initContentDone || !storesReady) return - // 500ms covers the bind:path cascade even on cold reload; two ticks - // weren't enough (bind:path fired ~100ms after restart, posting an edit). - await new Promise((r) => setTimeout(r, 500)) - if (restarted) return - restarted = true - UserDraft.restartSync('script', userDraftPath) - } - initContent(script.language, script.kind, template).finally(() => { - initContentDone = true - void tryRestart() - }) - // Cold reload: auth stores may load after mount; the `restarted` guard - // makes the effect self-cleaning. - if (!storesReady) { - $effect(() => { - if ($userStore && $workspaceStore) { - storesReady = true - untrack(() => void tryRestart()) - } - }) - } + const restarter = scheduleRestartSync(userDraftPath, { waitForContent: true }) + initContent(script.language, script.kind, template).finally(() => restarter.markContentReady()) + } else if (userDraftPath && untrack(() => searchParams).get('new_draft') == 'true') { + // Pre-filled new-draft seed (fork "Copy of X", hub fork, URL/YAML import): no + // template to seed, but the route still suspended autosave — lift it or the + // draft never persists (autosave stays dead for the session). + scheduleRestartSync(userDraftPath) } async function isTemplateScript() { From 368fd2d9e4b3ffb66e64934d9a622eea291cde5a Mon Sep 17 00:00:00 2001 From: Guilhem Date: Thu, 9 Jul 2026 16:40:46 +0200 Subject: [PATCH 02/52] fix: resolve fork family/picker for superadmin visiting a non-member workspace (#10023) * fix: populate fork base picker for superadmin visiting a non-member workspace Co-Authored-By: Claude Opus 4.8 (1M context) * fix: resolve fork family for superadmin across sidebar picker and scope header Extract the superadmin-visited-workspace fallback into a shared useForkableWorkspaces composable and apply it to WorkspaceFamilyPicker and WorkspaceScopeHeader so the sidebar fork picker and its fork-count trigger resolve the family for a superadmin viewing a non-member workspace. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: resolve superadmin-visited workspace name in the scope trigger chip The sidebar scope trigger next to the fork picker read $userWorkspaces directly, so a superadmin viewing a non-member workspace saw its raw id instead of the resolved name/family. Thread the folded-in forkable list into WorkspaceScopeTrigger, and trim the now-duplicated per-site rationale comments to a pointer at the composable. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/WorkspaceScopeTrigger.svelte | 17 ++++--- .../sessions/WorkspaceFamilyPicker.svelte | 28 +++++++++-- .../sidebar/WorkspaceScopeHeader.svelte | 17 +++++-- .../CreateWorkspaceInner.svelte | 20 ++++++-- .../lib/utils/useForkableWorkspaces.svelte.ts | 50 +++++++++++++++++++ 5 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 frontend/src/lib/utils/useForkableWorkspaces.svelte.ts diff --git a/frontend/src/lib/components/WorkspaceScopeTrigger.svelte b/frontend/src/lib/components/WorkspaceScopeTrigger.svelte index ac132bdca2..77fe52b421 100644 --- a/frontend/src/lib/components/WorkspaceScopeTrigger.svelte +++ b/frontend/src/lib/components/WorkspaceScopeTrigger.svelte @@ -1,5 +1,5 @@ @@ -119,7 +130,7 @@ {result_stream} {requireHtmlApproval} disableExpand={resolvedConfig?.hideDetails} - appPath={$userStore ? undefined : $appPath} + appPath={isEditor ? undefined : $appPath} forceJson={resolvedConfig?.forceJson} /> diff --git a/frontend/src/lib/components/apps/components/display/AppDisplayComponentByJobId.svelte b/frontend/src/lib/components/apps/components/display/AppDisplayComponentByJobId.svelte index d2831f76a3..750aff3df7 100644 --- a/frontend/src/lib/components/apps/components/display/AppDisplayComponentByJobId.svelte +++ b/frontend/src/lib/components/apps/components/display/AppDisplayComponentByJobId.svelte @@ -16,7 +16,6 @@ import ResolveStyle from '../helpers/ResolveStyle.svelte' import InitializeComponent from '../helpers/InitializeComponent.svelte' import DisplayResult from '$lib/components/DisplayResult.svelte' - import { userStore } from '$lib/stores' interface Props { id: string @@ -34,22 +33,35 @@ render }: Props = $props() - const { app, worldStore, workspace, appPath } = getContext('AppViewerContext') + const { app, worldStore, workspace, appPath, isEditor } = + getContext('AppViewerContext') const requireHtmlApproval = getContext(IS_APP_PUBLIC_CONTEXT_KEY) let resolvedConfig = $state( - initConfig(components['jobiddisplaycomponent'].initialData.configuration, untrack(() => configuration)) + initConfig( + components['jobiddisplaycomponent'].initialData.configuration, + untrack(() => configuration) + ) ) - const outputs = initOutput($worldStore, untrack(() => id), { - result: undefined, - loading: false, - jobId: undefined as string | undefined - }) + const outputs = initOutput( + $worldStore, + untrack(() => id), + { + result: undefined, + loading: false, + jobId: undefined as string | undefined + } + ) initializing = false - let css = $state(initCss($app.css?.jobiddisplaycomponent, untrack(() => customCss))) + let css = $state( + initCss( + $app.css?.jobiddisplaycomponent, + untrack(() => customCss) + ) + ) let jobLoader: JobLoader | undefined = $state(undefined) let testIsLoading: boolean = $state(false) @@ -137,7 +149,7 @@ {result} {requireHtmlApproval} disableExpand={resolvedConfig?.hideDetails} - appPath={$userStore ? undefined : $appPath} + appPath={isEditor ? undefined : $appPath} forceJson={resolvedConfig?.forceJson} /> From 7d02d9a1e47760f287a71f6e09cd6fe45efb5635 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 11 Jul 2026 11:54:08 +0200 Subject: [PATCH 26/52] fix(frontend): keep draft autosave alive after AI-session round-trip (#10052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): keep draft autosave alive after AI-session round-trip A UserDraft entry is shared by refcount across the components editing the same draft — notably an AI-session preview and the nav editor on either side of the Workspace<->AI Sessions toggle. The entry's autosave mirror was a $effect.root created inside whichever component first acquired it; when that component (the session preview) unmounted while the returned-to nav editor still held a refcount, the mirror stopped firing even though the entry lived on — silently killing autosave in the workspace editor for scripts, flows and (raw) apps. Move the cell out of the mirror root (so handles survive) and re-home the mirror to each new acquirer, so it is always owned by a mounted component. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(frontend): record mirror-ownership invariant on releaseEntry Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): preserve sync baseline across mirror re-home Addresses a re-home edge case (Codex review): the replacement mirror rearmed the first-write skip, so a draft edit the outgoing mirror had not yet observed (e.g. a session edit still pending at the Workspace<->AI Sessions handoff) was swallowed as the new baseline instead of POSTed, dropping the final change. Persist the serialization baseline on the entry (mirrorBaseline) and, on a re-home, seed the mirror from it without re-arming the skip — so a genuine unobserved change still syncs while an unchanged inherited value still doesn't POST. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(frontend): make draft autosave mirror component-independent Replaces the re-home approach (Codex review): re-homing the mirror to the last acquirer assumed LIFO holder lifetimes, which the sessions UI breaks — it keeps multiple warm session previews mounted at once, so two warm previews of one draft share the entry and closing the newer one killed autosave in the surviving older one. Instead create the entry's mirror $effect.root in a microtask, where no component/effect is active, so it is a true top-level root owned by the ENTRY: it survives every holder unmounting and is disposed only at refcount 0. Removes the re-home/baseline bookkeeping entirely. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(frontend): condense mirror-deferral comment per review Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/userDraft.svelte.ts | 204 ++++++++++++++------------- 1 file changed, 105 insertions(+), 99 deletions(-) diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 1ffdf3a2e4..a81c223fb1 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -82,10 +82,19 @@ type DraftEntry = { */ seedNextWrite: boolean /** - * Tears down the entry's `$effect.root` scope; called at refcount 0. - * `undefined` only on the test-runtime fallback path (see `acquireEntry`). + * Tears down the entry's autosave-mirror `$effect.root`; called once at + * refcount 0. The mirror is created detached from any component (see + * `acquireEntry`), so this is the ONLY thing that disposes it. Also flips + * `mirrorDisposed` to defuse the deferred creation if the entry is released + * before the mirror is even set up. In vitest the deferred callback never + * runs, so no root is created and this stays a no-op. */ destroyRoot?: () => void + /** + * Set by `destroyRoot` so the microtask-deferred mirror creation aborts when + * the entry was released (refcount 0) before the mirror was established. + */ + mirrorDisposed?: boolean } export type UserDraftEntry = { @@ -740,115 +749,112 @@ function acquireEntry( // Seed the cell with `defaultValue` (deep-cloned). Swallowed by // `skipNextWrite` below — it never POSTs. const seed = defaultValue !== undefined ? snapshotDraftValue(defaultValue) : undefined - // `$effect.root` gives the entry its own scope, disposed only by - // `releaseEntry`. Without it the sync `$effect` would parent to - // `useMany`'s reconcile effect and die on the next reconcile. - let stateRef: DraftState | undefined - const destroyRoot = $effect.root(() => { - const cell = $state<{ val: unknown }>({ val: seed }) - stateRef = cell as DraftState - // Mirror every observable change of `cell.val` to the syncer. - // `readFieldsRecursively` walks the value so deep mutations - // (`handle.draft.content = '...'`) re-fire the effect — reading - // `cell.val` alone only subscribes to the proxy root. - // - // `lastSerialized` + `skipNextWrite` dedup no-op updates and treat - // the FIRST change after mount as the seed/restore (no POST), so - // landing on `?new_draft` doesn't sync until the user edits. - // - // `cell.val === undefined` is the delete signal (`value: null`). - // `skipNextSync` lets callers that already POSTed (`discard`, - // `remove`) suppress the duplicate fire from their own write. - let lastSerialized: string | undefined = undefined - let skipNextWrite = true - $effect(() => { - const val = cell.val - if (val !== undefined) readFieldsRecursively(val) - const next = val === undefined ? undefined : JSON.stringify(val) - if (next === lastSerialized) { - // No-op write. If a `seed` re-seeded the value already in the - // cell, its one-shot flag consumed nothing — defuse it here or - // it lingers and swallows the user's NEXT edit. (`skipNextWrite` - // stays armed: an undefined-seeded cell's initial run lands - // here, and page editors rely on it to swallow their load write.) - const e = entries.get(mk) - if (e?.seedNextWrite) e.seedNextWrite = false - return - } - lastSerialized = next - if (skipNextWrite) { - skipNextWrite = false - // One write consumes BOTH one-shot guards: a `seed` on a fresh - // entry (still armed with the first-write skip) must not leave - // `seedNextWrite` behind to swallow the user's first edit. - const fresh = entries.get(mk) - if (fresh?.seedNextWrite) fresh.seedNextWrite = false - return - } - const entry = entries.get(mk) - // `seed` write: baseline already advanced above; don't POST. - if (entry?.seedNextWrite) { - entry.seedNextWrite = false - return - } - if (entry?.skipNextSync) { - entry.skipNextSync = false - return - } - // `syncSuspended` swallows the POST but `lastSerialized` advanced - // above, so only writes made during suspension are dropped — the - // first change after resume is still detected. - if (entry?.syncSuspended) return - // At the deployed baseline → sync a delete, not a baseline-equal - // copy. `untrack` so reactive reads in the predicate (the editor's - // post-deploy baseline) don't re-fire the mirror. - const atBaseline = untrack(() => val !== undefined && (discardIf?.(val) ?? false)) - void UserDraftDbSyncer.save({ - workspace, - itemKind, - path, - value: val === undefined || atBaseline ? null : val, - // Reactive keystroke mirror — `auto`, so suppressed while the - // auto-save toggle is off (for `canBeDisabled` handles). - auto: true, - canBeDisabled - }) - }) - }) - if (stateRef) { - entries.set(mk, { - count: 1, - workspace, - itemKind, - path, - state: stateRef, - skipNextSync: false, - syncSuspended: pendingSuspensions.delete(mk), - seedNextWrite: false, - destroyRoot - }) - return - } - // Fallback for the vitest runtime where `$effect.root`'s callback isn't - // invoked (unreachable in production). No sync effect — writes in tests - // stay in-memory. - const fallback = $state<{ val: unknown }>({ val: seed }) - entries.set(mk, { + const cell = $state<{ val: unknown }>({ val: seed }) + const stateRef = cell as DraftState + const entry: DraftEntry = { count: 1, workspace, itemKind, path, - state: fallback as DraftState, + state: stateRef, skipNextSync: false, syncSuspended: pendingSuspensions.delete(mk), - seedNextWrite: false - }) + seedNextWrite: false, + mirrorDisposed: false, + // Placeholder until the deferred `createMirror` swaps in the real teardown; + // flips `mirrorDisposed` so a release before then aborts the creation. + destroyRoot: () => { + entry.mirrorDisposed = true + } + } + entries.set(mk, entry) + const createMirror = (): void => { + // Bail if this entry was released before the microtask ran, or if the key + // was released and re-acquired (a different entry now owns `mk` — its own + // `createMirror` will run). + if (entry.mirrorDisposed || entries.get(mk) !== entry) return + entry.destroyRoot = $effect.root(() => { + // Mirror every observable change of `cell.val` to the syncer. + // `readFieldsRecursively` walks the value so deep mutations + // (`handle.draft.content = '...'`) re-fire the effect — reading + // `cell.val` alone only subscribes to the proxy root. + // + // `lastSerialized` + `skipNextWrite` dedup no-op updates and treat + // the FIRST change after mount as the seed/restore (no POST), so + // landing on `?new_draft` doesn't sync until the user edits. + // + // `cell.val === undefined` is the delete signal (`value: null`). + // `skipNextSync` lets callers that already POSTed (`discard`, + // `remove`) suppress the duplicate fire from their own write. + let lastSerialized: string | undefined = undefined + let skipNextWrite = true + $effect(() => { + const val = cell.val + if (val !== undefined) readFieldsRecursively(val) + const next = val === undefined ? undefined : JSON.stringify(val) + if (next === lastSerialized) { + // No-op write. If a `seed` re-seeded the value already in the + // cell, its one-shot flag consumed nothing — defuse it here or + // it lingers and swallows the user's NEXT edit. (`skipNextWrite` + // stays armed: an undefined-seeded cell's initial run lands + // here, and page editors rely on it to swallow their load write.) + if (entry.seedNextWrite) entry.seedNextWrite = false + return + } + lastSerialized = next + if (skipNextWrite) { + skipNextWrite = false + // One write consumes BOTH one-shot guards: a `seed` on a fresh + // entry (still armed with the first-write skip) must not leave + // `seedNextWrite` behind to swallow the user's first edit. + if (entry.seedNextWrite) entry.seedNextWrite = false + return + } + // `seed` write: baseline already advanced above; don't POST. + if (entry.seedNextWrite) { + entry.seedNextWrite = false + return + } + if (entry.skipNextSync) { + entry.skipNextSync = false + return + } + // `syncSuspended` swallows the POST but `lastSerialized` advanced + // above, so only writes made during suspension are dropped — the + // first change after resume is still detected. + if (entry.syncSuspended) return + // At the deployed baseline → sync a delete, not a baseline-equal + // copy. `untrack` so reactive reads in the predicate (the editor's + // post-deploy baseline) don't re-fire the mirror. + const atBaseline = untrack(() => val !== undefined && (discardIf?.(val) ?? false)) + void UserDraftDbSyncer.save({ + workspace, + itemKind, + path, + value: val === undefined || atBaseline ? null : val, + // Reactive keystroke mirror — `auto`, so suppressed while the + // auto-save toggle is off (for `canBeDisabled` handles). + auto: true, + canBeDisabled + }) + }) + }) + } + // Defer so the root is created with no active component/effect — a top-level + // scope owned by the entry, not by the acquiring component. An inline + // `$effect.root` would die when that component unmounts while other holders + // still edit the same draft (see `destroyRoot`). + queueMicrotask(createMirror) } function releaseEntry(mk: string): void { const entry = entries.get(mk) if (!entry) return entry.count-- + // The mirror is owned by the ENTRY (created detached — see `acquireEntry`), + // not by any holder, so releasing one holder never touches it; it is disposed + // only here, once, at refcount 0. This is what lets multiple holders (warm + // session previews + the nav editor) share the entry and drop in any order. if (entry.count <= 0) { // The live entry was authoritative while mounted; once gone, drop any // cached write for this key so a later read falls back to the server From a89b896ce5638f42f334055f9ffe6971b047aa84 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 11 Jul 2026 15:50:41 +0200 Subject: [PATCH 27/52] fix(frontend): mint draft path for new SDK builder items so autosave attaches (#10056) Fixes WIN-2159 Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/lib/components/FlowWrapper.svelte | 21 +++++--- .../src/lib/components/ScriptWrapper.svelte | 16 ++++-- frontend/src/lib/components/script_builder.ts | 9 ++++ frontend/src/lib/draftAddRedirect.ts | 16 +++--- frontend/src/lib/mintDraftPath.test.ts | 50 +++++++++++++++++++ frontend/src/lib/mintDraftPath.ts | 42 ++++++++++++++++ frontend/src/lib/userDraft.svelte.ts | 27 +++++++--- 7 files changed, 155 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/mintDraftPath.test.ts create mode 100644 frontend/src/lib/mintDraftPath.ts diff --git a/frontend/src/lib/components/FlowWrapper.svelte b/frontend/src/lib/components/FlowWrapper.svelte index 6eaf199ea7..2bd24f151a 100644 --- a/frontend/src/lib/components/FlowWrapper.svelte +++ b/frontend/src/lib/components/FlowWrapper.svelte @@ -5,6 +5,7 @@ import FlowBuilder from './FlowBuilder.svelte' import { usePageDraftSync } from './usePageDraftSync.svelte' import { workspaceStore } from '$lib/stores' + import { selectDraftStoragePath } from '$lib/mintDraftPath' import type { OpenFlow } from '$lib/gen' let { @@ -28,13 +29,19 @@ // Stable per-user draft storage key. Captured once so editing the flow's path // (which lives in `draft_path`, not the storage key) can't re-key the autosave // handle and orphan the draft. Mirrors the full-page editor keying on the URL - // path; falls back through the SDK's path inputs. - const draftStoragePath = untrack( - () => - props.initialPath || - props.pathStoreInit || - (oldFlowStore.val as { path?: string } | undefined)?.path || - '' + // path; falls back through the SDK's path inputs. For a brand-new flow with no + // caller path this mints a `u//draft_` key — the SDK equivalent of + // the `/flows/add` redirect — so autosave attaches instead of the handle + // detaching (local-only, never POSTs). + const draftStoragePath = untrack(() => + selectDraftStoragePath({ + providedPaths: [ + props.initialPath, + props.pathStoreInit, + (oldFlowStore.val as { path?: string } | undefined)?.path + ], + isNewItem: !!props.newFlow + }) ) // Reuse the full-page flow editor's draft orchestration so the SDK gets diff --git a/frontend/src/lib/components/ScriptWrapper.svelte b/frontend/src/lib/components/ScriptWrapper.svelte index 7fafdecc83..ff8fc8b2f5 100644 --- a/frontend/src/lib/components/ScriptWrapper.svelte +++ b/frontend/src/lib/components/ScriptWrapper.svelte @@ -5,12 +5,22 @@ import type { ScriptBuilderProps } from './script_builder' import { usePageDraftSync } from './usePageDraftSync.svelte' import { workspaceStore } from '$lib/stores' + import { selectDraftStoragePath } from '$lib/mintDraftPath' - let { script: oldScript, disableAi, ...props }: ScriptBuilderProps = $props() + let { script: oldScript, disableAi, newScript, ...props }: ScriptBuilderProps = $props() // Stable per-user draft storage key. Mirrors the full-page editor keying on - // the URL path; falls back through the SDK's path inputs. - const draftStoragePath = untrack(() => props.initialPath || oldScript?.path || '') + // the URL path; falls back through the SDK's path inputs. For a brand-new + // script with no caller path this mints a `u//draft_` key — the + // SDK equivalent of the `/scripts/add` redirect — so autosave attaches instead + // of the handle detaching (local-only, never POSTs). Captured once (untrack) + // so editing the path field can't re-key and orphan the draft. + const draftStoragePath = untrack(() => + selectDraftStoragePath({ + providedPaths: [props.initialPath, oldScript?.path], + isNewItem: !!newScript + }) + ) // Reuse the full-page script editor's draft orchestration (same as the flow // SDK) so the SDK gets autosave + the AutosaveIndicator (gated by ScriptBuilder diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index 0fbd24d904..0609c6737d 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -15,6 +15,15 @@ export interface ScriptBuilderProps { disableAi?: boolean fullyLoaded?: boolean initialPath?: string + /** + * Wrapper-only signal (consumed by `ScriptWrapper`, not `ScriptBuilder`): + * this editor is mounting a brand-new script. When set and no caller path + * is provided, the wrapper mints a `u//draft_` storage path so + * autosave attaches — mirrors what the `/scripts/add` route does before the + * full-page editor mounts. Left unset for read-only / pathless views so + * autosave stays intentionally detached. + */ + newScript?: boolean /** * Path the route's `UserDraft.use('script', ...)` * handle is keyed by. Distinct from `initialPath` for new drafts — diff --git a/frontend/src/lib/draftAddRedirect.ts b/frontend/src/lib/draftAddRedirect.ts index 2f9e381ed1..242893f782 100644 --- a/frontend/src/lib/draftAddRedirect.ts +++ b/frontend/src/lib/draftAddRedirect.ts @@ -1,25 +1,21 @@ import { redirect } from '@sveltejs/kit' import { base } from '$app/paths' -import { getUsernameForNamespace } from '$lib/userNamespace' -import { randomUUID } from '$lib/utils/uuid' +import { mintDraftPath } from '$lib/mintDraftPath' /** * Shared `load` for every `/{scripts,flows,apps,apps_raw}/add` route. Doing the * redirect in `load` (not `onMount`) avoids painting a blank frame first. * - * Mints a fresh `u//draft_` path and 307s to + * Mints a fresh `u//draft_` path (via the shared `mintDraftPath`, + * so the SDK builder wrappers stay in lockstep) and 307s to * `{base}/{editPrefix}/?new_draft=true&`. `new_draft=true` * tells the edit route to seed an empty editor instead of 404-ing. The hash is * carried over too — fork / handler-template buttons encode a payload into it - * that the edit route's `new_draft` branch consumes. `randomUUID` not - * `crypto.randomUUID` (WebCrypto is absent on non-secure origins). + * that the edit route's `new_draft` branch consumes. */ export function makeDraftAddLoad(editPrefix: string) { return ({ url }: { url: URL }) => { - const username = getUsernameForNamespace() - // Underscores not dashes — path segments are `[a-zA-Z0-9_]` words and - // downstream consumers treat `-` as foreign. - const uuid = randomUUID().replaceAll('-', '_') + const path = mintDraftPath() const params = new URLSearchParams(url.searchParams) params.set('new_draft', 'true') // `url.hash` is unavailable in `load`; read `window.location` instead @@ -27,6 +23,6 @@ export function makeDraftAddLoad(editPrefix: string) { // points at the PREVIOUS page, but every hash-payload producer arrives // as a full page load, so the hash is correct. const hash = typeof window !== 'undefined' ? window.location.hash : '' - redirect(307, `${base}/${editPrefix}/u/${username}/draft_${uuid}?${params.toString()}${hash}`) + redirect(307, `${base}/${editPrefix}/${path}?${params.toString()}${hash}`) } } diff --git a/frontend/src/lib/mintDraftPath.test.ts b/frontend/src/lib/mintDraftPath.test.ts new file mode 100644 index 0000000000..10569f0f01 --- /dev/null +++ b/frontend/src/lib/mintDraftPath.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, vi } from 'vitest' + +vi.mock('$lib/userNamespace', () => ({ + getUsernameForNamespace: () => 'alice' +})) + +import { mintDraftPath, selectDraftStoragePath } from './mintDraftPath' + +const MINTED = /^u\/alice\/draft_[0-9a-f_]+$/ + +describe('mintDraftPath', () => { + it('mints a non-empty u//draft_ path', () => { + const path = mintDraftPath() + expect(path).not.toBe('') + expect(path).toMatch(MINTED) + }) + + it('uses underscores, never dashes (path segments are word chars)', () => { + expect(mintDraftPath()).not.toContain('-') + }) + + it('is unique per call', () => { + expect(mintDraftPath()).not.toBe(mintDraftPath()) + }) +}) + +describe('selectDraftStoragePath', () => { + it('mints a non-empty path for a new item with no caller path', () => { + // Regression: SDK create wrappers keyed autosave under '' → detached → + // silently never POSTed. A new item must get a real, mintable key. + const path = selectDraftStoragePath({ providedPaths: [undefined, ''], isNewItem: true }) + expect(path).toMatch(MINTED) + }) + + it('honors the first caller-provided path over minting (SDK stopgap wins)', () => { + expect( + selectDraftStoragePath({ providedPaths: ['u/bob/given', undefined], isNewItem: true }) + ).toBe('u/bob/given') + }) + + it('falls through empties to the first non-empty provided path', () => { + expect( + selectDraftStoragePath({ providedPaths: ['', undefined, 'u/bob/existing'], isNewItem: false }) + ).toBe('u/bob/existing') + }) + + it('stays detached ("") for a non-new item with no path (read-only view)', () => { + expect(selectDraftStoragePath({ providedPaths: [undefined, ''], isNewItem: false })).toBe('') + }) +}) diff --git a/frontend/src/lib/mintDraftPath.ts b/frontend/src/lib/mintDraftPath.ts new file mode 100644 index 0000000000..5df9c8838a --- /dev/null +++ b/frontend/src/lib/mintDraftPath.ts @@ -0,0 +1,42 @@ +import { getUsernameForNamespace } from '$lib/userNamespace' +import { randomUUID } from '$lib/utils/uuid' + +/** + * Mint a fresh `u//draft_` storage path for a brand-new + * editor item. Shared by the `/{scripts,flows,apps,apps_raw}/add` route + * redirects (`makeDraftAddLoad`) and the SDK builder wrappers + * (`ScriptWrapper` / `FlowWrapper`) so the two can't diverge on format — + * both need the autosave handle keyed under a real, unique path or the + * draft detaches (local-only) and never POSTs. + * + * Underscores not dashes — path segments are `[a-zA-Z0-9_]` words and + * downstream consumers treat `-` as foreign. `randomUUID` not + * `crypto.randomUUID` (WebCrypto is absent on non-secure origins). + */ +export function mintDraftPath(): string { + const username = getUsernameForNamespace() + const uuid = randomUUID().replaceAll('-', '_') + return `u/${username}/draft_${uuid}` +} + +/** + * Resolve the autosave storage key for an SDK builder wrapper. The first + * caller-provided path wins (a consumer that supplies its own path — including + * the temporary React-SDK stopgap — keeps it); otherwise, for a brand-new + * editable item, mint a fresh draft path so autosave attaches. Returns `''` + * (a detached, local-only handle that never POSTs) when there is no path and + * the item isn't a new editable one — the intentionally-pathless read-only case. + * + * Shared by `ScriptWrapper` / `FlowWrapper` so the two can't diverge on this + * precedence. Callers MUST capture the result once (`untrack`) so editing a + * path field later can't re-key the handle and orphan the draft. + */ +export function selectDraftStoragePath(opts: { + providedPaths: (string | undefined)[] + isNewItem: boolean +}): string { + for (const p of opts.providedPaths) { + if (p) return p + } + return opts.isNewItem ? mintDraftPath() : '' +} diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index a81c223fb1..b9392cee9a 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -746,8 +746,8 @@ function acquireEntry( existing.count++ return } - // Seed the cell with `defaultValue` (deep-cloned). Swallowed by - // `skipNextWrite` below — it never POSTs. + // Seed the cell with `defaultValue` (deep-cloned). The mirror below anchors + // its baseline to this seed so it never POSTs the template. const seed = defaultValue !== undefined ? snapshotDraftValue(defaultValue) : undefined const cell = $state<{ val: unknown }>({ val: seed }) const stateRef = cell as DraftState @@ -780,14 +780,29 @@ function acquireEntry( // `cell.val` alone only subscribes to the proxy root. // // `lastSerialized` + `skipNextWrite` dedup no-op updates and treat - // the FIRST change after mount as the seed/restore (no POST), so - // landing on `?new_draft` doesn't sync until the user edits. + // the seed/restore as no-POST, so landing on `?new_draft` doesn't + // sync until the user edits. + // + // The swallow is anchored to the seed VALUE, not "first change after + // mount": when a `defaultValue` seeded the cell, `lastSerialized` + // starts at its serialization so a first mirror run that still equals + // the seed is a genuine no-op, while a first run that already DIFFERS + // is the user's edit and must POST. That closes a create-path race — + // the mirror is created in a deferred microtask (see below) and the + // handle re-keys detached→acquired as the workspace resolves, so the + // seed and the user's first edit can land before the mirror's first + // run and coalesce into it; a blind "swallow the first change" would + // eat that edit. + // + // With no in-hand seed (page editors load & assign the value AFTER + // mount), arm the blind first-write swallow so that post-load + // assignment doesn't POST. // // `cell.val === undefined` is the delete signal (`value: null`). // `skipNextSync` lets callers that already POSTed (`discard`, // `remove`) suppress the duplicate fire from their own write. - let lastSerialized: string | undefined = undefined - let skipNextWrite = true + let lastSerialized: string | undefined = seed === undefined ? undefined : JSON.stringify(seed) + let skipNextWrite = seed === undefined $effect(() => { const val = cell.val if (val !== undefined) readFieldsRecursively(val) From 04eb7ddd3906c28bec1711e276473a87c7b9500f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 11 Jul 2026 16:00:53 +0200 Subject: [PATCH 28/52] fix: clearer errors on auto-draft save failure (WIN-2157) (#10053) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: clearer errors on auto-draft save failure (WIN-2157) When an autosave draft save fails, the cloud indicator now surfaces the backend reason on hover (native title tooltip) in addition to the existing click popover, so the cause is discoverable without a click. Backend now returns a clearer, actionable message: - `require_can_write_path` distinguishes a malformed path (unrecognized namespace prefix -> BadRequest) from a genuine permission denial, and the deny message spells out where the user *can* write. - `require_owner_of_path` no longer panics with an out-of-bounds index on a malformed single-segment path (e.g. a bare `u`/`f`); it returns a clear BadRequest instead. Covered by a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: trim narrative comment to invariant in drafts.rs (WIN-2157) Address CI review (AGENTS.md: comments record constraints, not narration, ≤4 lines): keep the malformed-path invariant, drop the motivation tail. Co-Authored-By: Claude Opus 4.8 (1M context) * fix: don't let a malformed stored draft 400 the draft listing (WIN-2157) Address CI review (P1): require_can_write_path can now return BadRequest for a malformed path, and list_drafts propagated it — so a single malformed stored draft row (the draft table has no path constraint; legacy/admin-authored rows may be malformed) would make GET /drafts/list return 400. Treat BadRequest like NotAuthorized there: the row is simply not writable. Verified e2e on EE — listing returns 200 with can_write false for the malformed rows. Also trim "unchanged"/"still" drafting-history narration from the regression test comments (P2, AGENTS.md). Co-Authored-By: Claude Opus 4.8 (1M context) * chore: compress list_drafts comment to 4 lines (WIN-2157) Address CI review P2: keep the constraint (draft table has no path constraint) and the invariant (one malformed row must not 400 the listing) within the AGENTS.md ≤4-line limit. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/windmill-api-auth/src/lib.rs | 30 +++++++++++++++++++ backend/windmill-api/src/drafts.rs | 17 +++++++++-- .../lib/components/AutosaveIndicator.svelte | 7 ++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index b1bf2d4273..25f572d8ae 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -541,6 +541,16 @@ pub fn require_owner_of_path(authed: &ApiAuthed, path: &str) -> Result<()> { } if !path.is_empty() { let splitted = path.split("/").collect::>(); + // A valid path is at least `/` (e.g. `u/alice/...`, + // `f/folder/...`). Guard the `splitted[1]` accesses below so a + // malformed single-segment path returns a clear error instead of + // panicking with an out-of-bounds index. + if splitted.len() < 2 { + return Err(Error::BadRequest(format!( + "Invalid path '{}': a valid path starts with 'u//' or 'f//'", + path + ))); + } if splitted[0] == "u" { if splitted[1] == authed.username { Ok(()) @@ -1131,6 +1141,26 @@ mod tests { ); } + // Regression for WIN-2157: a malformed single-segment path (e.g. a draft + // saved at a bare `u`) must return a clear error, not panic on the + // `splitted[1]` index. Non-admins reach this branch (admins short-circuit). + #[test] + fn require_owner_of_path_rejects_malformed_path_without_panicking() { + let alice = ApiAuthed { username: "alice".into(), ..Default::default() }; + for path in ["u", "f", "g", "nonsense"] { + let err = + require_owner_of_path(&alice, path).expect_err("malformed path must be rejected"); + assert!( + matches!(err, Error::BadRequest(_)), + "expected BadRequest for '{path}', got {err:?}" + ); + } + // A well-formed foreign path returns the owner error, not a malformed one. + assert!(require_owner_of_path(&alice, "u/bob/script").is_err()); + // The user's own namespace resolves. + assert!(require_owner_of_path(&alice, "u/alice/script").is_ok()); + } + #[test] fn predicate_no_scopes_allows_all() { let authed = authed_with_scopes(None); diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 72791de78f..ffc94d5b46 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -120,7 +120,11 @@ async fn list_drafts( .await { Ok(()) => true, - Err(Error::NotAuthorized(_)) => false, + // A stored draft can sit at an unwritable path — unauthorized, + // or malformed (`BadRequest`; the `draft` table has no path + // constraint). Either way it's not writable, and one bad row + // must not 400 the whole listing. + Err(Error::NotAuthorized(_)) | Err(Error::BadRequest(_)) => false, Err(e) => return Err(e), }; out.push(row); @@ -745,8 +749,17 @@ async fn require_can_write_path( return Ok(()); } } + // A path without a recognized namespace prefix (u/, f/, g/) can never be + // writable — no namespace rule and no deployed row can apply — so report it + // as malformed rather than as a plain permission denial. + if !(path.starts_with("u/") || path.starts_with("f/") || path.starts_with("g/")) { + return Err(Error::BadRequest(format!( + "Invalid path '{path}': a valid path starts with 'u//', 'f//' or 'g//'" + ))); + } Err(Error::NotAuthorized(format!( - "you don't have write permission on {path}" + "You don't have write permission on '{path}'. It must be in your own 'u/{}/' namespace, or in a folder ('f//') or group ('g//') you can write to.", + authed.username ))) } diff --git a/frontend/src/lib/components/AutosaveIndicator.svelte b/frontend/src/lib/components/AutosaveIndicator.svelte index bd4912a950..76dc030c58 100644 --- a/frontend/src/lib/components/AutosaveIndicator.svelte +++ b/frontend/src/lib/components/AutosaveIndicator.svelte @@ -258,7 +258,12 @@ closeOnOutsideClick > {#snippet trigger()} -
+
{#if editingOtherUserDraft} From 6f49a1f6a904442fcae9bb703f095b0a3ef61268 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 11 Jul 2026 16:35:33 +0200 Subject: [PATCH 29/52] fix(docker): pin ansible tool interpreter to a persistent path (#10054) Co-authored-by: Claude Opus 4.8 (1M context) --- docker/DockerfileFull | 7 ++++++- docker/DockerfileFullEe | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docker/DockerfileFull b/docker/DockerfileFull index 15c037b6f4..41a9091ae3 100644 --- a/docker/DockerfileFull +++ b/docker/DockerfileFull @@ -6,7 +6,12 @@ COPY --from=rust:1.97.0 /usr/local/rustup /usr/local/rustup RUN RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo /usr/local/cargo/bin/cargo install cargo-sweep --version ^0.7 # Ansible -RUN uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -s -t "$UV_TOOL_BIN_DIR/" || true +# UV_PYTHON_INSTALL_DIR defaults to /tmp/windmill/cache/py_runtime, which is an +# ephemeral runtime cache (fresh volume/tmpfs, and pruned by the worker). Installing +# ansible there leaves its venv interpreter as a dangling symlink at runtime, so every +# ansible-* executable fails with ENOENT ("ansible-galaxy not found"). Pin the tool's +# interpreter to a persistent image path so the install stays self-contained. +RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true # C# RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ diff --git a/docker/DockerfileFullEe b/docker/DockerfileFullEe index 657b4fbef2..c53642ca4c 100644 --- a/docker/DockerfileFullEe +++ b/docker/DockerfileFullEe @@ -25,7 +25,12 @@ COPY --from=rust:1.97.0 /usr/local/rustup /usr/local/rustup RUN RUSTUP_HOME=/usr/local/rustup CARGO_HOME=/usr/local/cargo /usr/local/cargo/bin/cargo install cargo-sweep --version ^0.7 # Ansible -RUN uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -s -t "$UV_TOOL_BIN_DIR/" || true +# UV_PYTHON_INSTALL_DIR defaults to /tmp/windmill/cache/py_runtime, which is an +# ephemeral runtime cache (fresh volume/tmpfs, and pruned by the worker). Installing +# ansible there leaves its venv interpreter as a dangling symlink at runtime, so every +# ansible-* executable fails with ENOENT ("ansible-galaxy not found"). Pin the tool's +# interpreter to a persistent image path so the install stays self-contained. +RUN UV_PYTHON_INSTALL_DIR=/usr/local/uv/py uv tool install ansible && [ -d "$(uv tool dir)/ansible/bin/" ] && find "$(uv tool dir)/ansible/bin/" -mindepth 1 -maxdepth 1 -type f -executable -regextype posix-extended -regex '^((.+/)?)[^.]+' -print0 | xargs -0 ln -sf -t "$UV_TOOL_BIN_DIR/" || true # dotnet SDK RUN wget https://dot.net/v1/dotnet-install.sh -O dotnet-install.sh \ && chmod +x dotnet-install.sh \ From ff774c46bff4bff1c532e512b163225aa7c41c11 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 11 Jul 2026 23:46:34 +0200 Subject: [PATCH 30/52] feat: add per-workspace job-retention override (#10050) * feat: add per-workspace job-retention override (EE) Co-Authored-By: Claude Opus 4.8 (1M context) * chore: update ee-repo-ref to 2ba6a2a75b6fc97858b306b2c98ada481e363c10 This commit updates the EE repository reference after PR #658 was merged in windmill-ee-private. Previous ee-repo-ref: e7fb36acd813cd717bcf05f5aafbf81de271d618 New ee-repo-ref: 2ba6a2a75b6fc97858b306b2c98ada481e363c10 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: windmill-internal-app[bot] --- ...7e253678631e091b9e4223842648a4475dbbf.json | 28 + ...43e1b981f7884df99f033838c4336bea2b051.json | 34 ++ ...0e991cb539320f4c8321af94416f702108e2e.json | 32 + ...4a229f3dc601b4592d052d3956d5e54d69463.json | 23 + ...612cea9f6c69d664ceb89094fd544dc63af9d.json | 23 + ...41a9e9f2d5a56e12a9d6805ca58ff40f61614.json | 30 - ...386515290c33ea7cedfe2a1092690749ca49c.json | 31 + ...fa7cfd861f1dcad88c7ea75a0a8b014ec1f75.json | 31 - ...149d3ee4cd59ae9d7b154a6d0fcab438b09e6.json | 32 + ...22d59d970aa87c0d74ca461f1dea83972ccd9.json | 31 + backend/ee-repo-ref.txt | 2 +- backend/src/main.rs | 17 +- backend/src/monitor.rs | 552 ++++++++++++++---- backend/windmill-api-settings/src/lib.rs | 35 +- .../windmill-api-settings/src/log_cleanup.rs | 300 +++++++--- backend/windmill-api/src/db_health.rs | 146 ++++- .../windmill-common/src/global_settings.rs | 6 + backend/windmill-common/src/lib.rs | 10 + .../src/lib/components/InstanceSetting.svelte | 7 + .../src/lib/components/instanceSettings.ts | 33 +- .../RetentionPeriodOverrides.svelte | 147 +++++ 21 files changed, 1260 insertions(+), 290 deletions(-) create mode 100644 backend/.sqlx/query-056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf.json create mode 100644 backend/.sqlx/query-0d373b2600273763c002b1e75f743e1b981f7884df99f033838c4336bea2b051.json create mode 100644 backend/.sqlx/query-20d62e1937c855cc6887597b3860e991cb539320f4c8321af94416f702108e2e.json create mode 100644 backend/.sqlx/query-73a3e00e61b33c8ba683ea03f314a229f3dc601b4592d052d3956d5e54d69463.json create mode 100644 backend/.sqlx/query-8c1ae47f01c3d5f66faf2f1835c612cea9f6c69d664ceb89094fd544dc63af9d.json delete mode 100644 backend/.sqlx/query-a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614.json create mode 100644 backend/.sqlx/query-b326b02adf2245369173fbb1b24386515290c33ea7cedfe2a1092690749ca49c.json delete mode 100644 backend/.sqlx/query-c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75.json create mode 100644 backend/.sqlx/query-c89a6cb3b2d5aad2d5e406b5d85149d3ee4cd59ae9d7b154a6d0fcab438b09e6.json create mode 100644 backend/.sqlx/query-e9c3e9b9dd6a54c50a7f6639b7922d59d970aa87c0d74ca461f1dea83972ccd9.json create mode 100644 frontend/src/lib/components/instanceSettings/RetentionPeriodOverrides.svelte diff --git a/backend/.sqlx/query-056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf.json b/backend/.sqlx/query-056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf.json new file mode 100644 index 0000000000..abafce2d82 --- /dev/null +++ b/backend/.sqlx/query-056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id as \"workspace_id!\", MIN(completed_at) as oldest\n FROM v2_job_completed\n WHERE workspace_id = ANY($1::text[])\n GROUP BY workspace_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "oldest", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "056231b872a080a257c034489127e253678631e091b9e4223842648a4475dbbf" +} diff --git a/backend/.sqlx/query-0d373b2600273763c002b1e75f743e1b981f7884df99f033838c4336bea2b051.json b/backend/.sqlx/query-0d373b2600273763c002b1e75f743e1b981f7884df99f033838c4336bea2b051.json new file mode 100644 index 0000000000..e022f764c8 --- /dev/null +++ b/backend/.sqlx/query-0d373b2600273763c002b1e75f743e1b981f7884df99f033838c4336bea2b051.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n (SELECT MIN(completed_at) FROM v2_job_completed) as true_oldest,\n (SELECT MIN(completed_at) FROM v2_job_completed\n WHERE workspace_id <> ALL($1::text[])) as global_oldest,\n (SELECT COUNT(*) FROM v2_job_completed) as total", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "true_oldest", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "global_oldest", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "total", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "0d373b2600273763c002b1e75f743e1b981f7884df99f033838c4336bea2b051" +} diff --git a/backend/.sqlx/query-20d62e1937c855cc6887597b3860e991cb539320f4c8321af94416f702108e2e.json b/backend/.sqlx/query-20d62e1937c855cc6887597b3860e991cb539320f4c8321af94416f702108e2e.json new file mode 100644 index 0000000000..cf55e44fe4 --- /dev/null +++ b/backend/.sqlx/query-20d62e1937c855cc6887597b3860e991cb539320f4c8321af94416f702108e2e.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.workspace_id = $5\n AND jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz)\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id, completed_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "UuidArray", + "Timestamptz", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "20d62e1937c855cc6887597b3860e991cb539320f4c8321af94416f702108e2e" +} diff --git a/backend/.sqlx/query-73a3e00e61b33c8ba683ea03f314a229f3dc601b4592d052d3956d5e54d69463.json b/backend/.sqlx/query-73a3e00e61b33c8ba683ea03f314a229f3dc601b4592d052d3956d5e54d69463.json new file mode 100644 index 0000000000..99621f180f --- /dev/null +++ b/backend/.sqlx/query-73a3e00e61b33c8ba683ea03f314a229f3dc601b4592d052d3956d5e54d69463.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($2::text[] IS NULL OR workspace_id NOT IN (\n SELECT u FROM unnest($2::text[]) AS u WHERE u IS NOT NULL\n ))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "73a3e00e61b33c8ba683ea03f314a229f3dc601b4592d052d3956d5e54d69463" +} diff --git a/backend/.sqlx/query-8c1ae47f01c3d5f66faf2f1835c612cea9f6c69d664ceb89094fd544dc63af9d.json b/backend/.sqlx/query-8c1ae47f01c3d5f66faf2f1835c612cea9f6c69d664ceb89094fd544dc63af9d.json new file mode 100644 index 0000000000..1f90b91084 --- /dev/null +++ b/backend/.sqlx/query-8c1ae47f01c3d5f66faf2f1835c612cea9f6c69d664ceb89094fd544dc63af9d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM v2_job_completed\n WHERE workspace_id = $1\n AND completed_at <= now() - ($2::bigint::text || ' s')::interval", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "8c1ae47f01c3d5f66faf2f1835c612cea9f6c69d664ceb89094fd544dc63af9d" +} diff --git a/backend/.sqlx/query-a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614.json b/backend/.sqlx/query-a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614.json deleted file mode 100644 index 24e387a783..0000000000 --- a/backend/.sqlx/query-a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($3::timestamptz IS NULL OR completed_at >= $3)\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id, completed_at", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "completed_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Timestamptz" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "a2d4a8aedb15e9faf0a2512fa4241a9e9f2d5a56e12a9d6805ca58ff40f61614" -} diff --git a/backend/.sqlx/query-b326b02adf2245369173fbb1b24386515290c33ea7cedfe2a1092690749ca49c.json b/backend/.sqlx/query-b326b02adf2245369173fbb1b24386515290c33ea7cedfe2a1092690749ca49c.json new file mode 100644 index 0000000000..5dbd6329b6 --- /dev/null +++ b/backend/.sqlx/query-b326b02adf2245369173fbb1b24386515290c33ea7cedfe2a1092690749ca49c.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE workspace_id = $4\n AND completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz)\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id, completed_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Timestamptz", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "b326b02adf2245369173fbb1b24386515290c33ea7cedfe2a1092690749ca49c" +} diff --git a/backend/.sqlx/query-c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75.json b/backend/.sqlx/query-c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75.json deleted file mode 100644 index b2e218e511..0000000000 --- a/backend/.sqlx/query-c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND ($4::timestamptz IS NULL OR jc.completed_at >= $4)\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id, completed_at", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "completed_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8", - "UuidArray", - "Timestamptz" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "c033a690fde04da79745e850b72fa7cfd861f1dcad88c7ea75a0a8b014ec1f75" -} diff --git a/backend/.sqlx/query-c89a6cb3b2d5aad2d5e406b5d85149d3ee4cd59ae9d7b154a6d0fcab438b09e6.json b/backend/.sqlx/query-c89a6cb3b2d5aad2d5e406b5d85149d3ee4cd59ae9d7b154a6d0fcab438b09e6.json new file mode 100644 index 0000000000..ffe341848c --- /dev/null +++ b/backend/.sqlx/query-c89a6cb3b2d5aad2d5e406b5d85149d3ee4cd59ae9d7b154a6d0fcab438b09e6.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT jc.id FROM v2_job_completed jc\n LEFT JOIN v2_job j ON j.id = jc.id\n WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz)\n AND ($5::text[] IS NULL OR jc.workspace_id NOT IN (\n SELECT u FROM unnest($5::text[]) AS u WHERE u IS NOT NULL\n ))\n AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN (\n SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL\n )\n ORDER BY jc.completed_at ASC\n LIMIT $2\n FOR UPDATE OF jc SKIP LOCKED\n )\n RETURNING id, completed_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "UuidArray", + "Timestamptz", + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "c89a6cb3b2d5aad2d5e406b5d85149d3ee4cd59ae9d7b154a6d0fcab438b09e6" +} diff --git a/backend/.sqlx/query-e9c3e9b9dd6a54c50a7f6639b7922d59d970aa87c0d74ca461f1dea83972ccd9.json b/backend/.sqlx/query-e9c3e9b9dd6a54c50a7f6639b7922d59d970aa87c0d74ca461f1dea83972ccd9.json new file mode 100644 index 0000000000..10bf1a6061 --- /dev/null +++ b/backend/.sqlx/query-e9c3e9b9dd6a54c50a7f6639b7922d59d970aa87c0d74ca461f1dea83972ccd9.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz)\n AND ($4::text[] IS NULL OR workspace_id NOT IN (\n SELECT u FROM unnest($4::text[]) AS u WHERE u IS NOT NULL\n ))\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id, completed_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "completed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Timestamptz", + "TextArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "e9c3e9b9dd6a54c50a7f6639b7922d59d970aa87c0d74ca461f1dea83972ccd9" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 92f2e40cbd..589a683dba 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -f292a1040da6a667ce7c22abf63ec0debfdd480f +2ba6a2a75b6fc97858b306b2c98ada481e363c10 diff --git a/backend/src/main.rs b/backend/src/main.rs index 4115e2a12b..7080ae0c13 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -56,11 +56,11 @@ use windmill_common::{ PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, - RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, - SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, - SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, SANDBOX_IMAGE_PULL_POLICY_SETTING, - SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, - STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, + RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, + SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, + SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, + SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, @@ -124,7 +124,7 @@ use windmill_worker::{ use crate::monitor::{ initial_load, load_disable_password_login, load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled, load_preview_tags_override, - load_require_preexisting_user, load_tag_per_workspace_enabled, + load_require_preexisting_user, load_retention_period_overrides, load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs, load_workspace_fairness_enabled, load_workspace_fairness_max_percent, load_workspace_fairness_min_total, monitor_db, reload_app_workspaced_route_setting, @@ -1881,6 +1881,11 @@ async fn process_notify_event( } TIMEOUT_WAIT_RESULT_SETTING => reload_timeout_wait_result_setting(conn).await, RETENTION_PERIOD_SECS_SETTING => reload_retention_period_setting(conn).await, + RETENTION_PERIOD_SECS_OVERRIDES_SETTING => { + if let Err(e) = load_retention_period_overrides(db).await { + tracing::error!("Error loading per-workspace retention overrides: {e:#}"); + } + } AUDIT_LOG_RETENTION_DAYS_SETTING => { reload_audit_log_retention_days_setting(conn).await } diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 60b4024c0a..6632e3b3a8 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -94,7 +94,8 @@ use windmill_common::{ }, KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, - DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED, + DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS, JOB_RETENTION_SECS_OVERRIDES, + JOB_RETENTION_SECS_OVERRIDES_LOADED, METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS, STORE_AUDIT_LOGS_S3, }; @@ -265,6 +266,12 @@ pub async fn initial_load( tracing::error!("Error loading preview tags override: {e:#}"); } + // Load per-workspace retention overrides before the first cleanup tick so a fresh server + // never sweeps globally without honoring configured longer-retention workspaces. + if let Err(e) = load_retention_period_overrides(db).await { + tracing::error!("Error loading per-workspace retention overrides: {e:#}"); + } + // Workspace fairness (cloud-only). Load the percentage/duration/min knobs // *before* the enabled flag so that `load_workspace_fairness_enabled` reads // current values when re-storing the pull queries. @@ -1339,68 +1346,73 @@ pub async fn delete_expired_items(db: &DB) -> () { ), } - let job_retention_secs = JOB_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed); - if job_retention_secs > 0 { - let batch_size = *JOB_CLEANUP_BATCH_SIZE; - let max_batches = *JOB_CLEANUP_MAX_BATCHES; - let cleanup_start = Instant::now(); - let mut total_deleted = 0u64; - let mut batch_num = 0i32; - // Watermark carried across batches so each one resumes after the rows the previous batch - // already processed instead of re-scanning the (potentially undeletable) oldest prefix. - let mut completed_at_floor: Option> = None; + // Per-workspace retention overrides (EE-only; the cache is always empty on CE). A workspace may + // keep jobs LONGER or SHORTER than the instance-wide window. Phase 1 sweeps globally on the + // instance window but excludes override workspaces; Phase 2 sweeps each override workspace on its + // own window (a sargable `workspace_id = $w` scan). The override count is capped small + // (`MAX_RETENTION_OVERRIDE_WORKSPACES`), so Phase 2's per-workspace fan-out stays bounded. + // + // Deliberate simplicity/scale trade-off: a LONGER or keep-forever override lets that workspace's + // old rows accumulate at the front of the completed_at index, and Phase 1's first batch each tick + // scans past that retained prefix (an index scan, thanks to the sargable floor — not a Seq Scan) + // before reaching a deletable row. This is only material at extreme scale (millions of retained + // rows on one busy keep-forever workspace); we accept it rather than carrying a cross-tick + // watermark, given overrides are a capped, targeted escape hatch. + // + // Gate the whole sweep on a confirmed-known override set: if the load never succeeded (e.g. a + // startup DB hiccup, or malformed data), the empty cache is "unknown", not "no overrides", and + // sweeping globally would delete jobs a longer-retention workspace configured. Retry the load + // once here (on CE the flag is already set at startup, so this is a no-op), and skip the whole + // job-cleanup phase this tick if still unknown — it runs again shortly. + if !JOB_RETENTION_SECS_OVERRIDES_LOADED.load(std::sync::atomic::Ordering::Relaxed) { + if let Err(e) = load_retention_period_overrides(db).await { + tracing::error!("Error (re)loading per-workspace retention overrides: {e:#}"); + } + } + if !JOB_RETENTION_SECS_OVERRIDES_LOADED.load(std::sync::atomic::Ordering::Relaxed) { + tracing::error!( + "Skipping job retention cleanup this cycle: per-workspace overrides not yet loaded" + ); + } else { + let job_retention_secs = JOB_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed); + // `load_full` (owned Arc) rather than `load` (Guard): the sweep below holds this across many + // `.await`s, and an arc_swap Guard is not meant to be held for long. + let retention_overrides = JOB_RETENTION_SECS_OVERRIDES.load_full(); + let override_workspace_ids: Vec = retention_overrides.keys().cloned().collect(); - // Process batches until no more expired jobs or max batches reached - loop { - if max_batches > 0 && batch_num >= max_batches { - tracing::debug!( - "Job cleanup: reached max batches limit ({}), will continue next iteration", - max_batches - ); - break; + // Phase 1: global sweep with the instance window, skipping override workspaces. + if job_retention_secs > 0 { + run_retention_cleanup( + db, + job_retention_secs, + RetentionScope::GlobalExcluding(&override_workspace_ids), + ) + .await; + + // Clean up concurrency keys separately (not tied to specific job IDs). Kept global on + // the instance window — concurrency keys are short-lived and not worth per-workspace + // scoping. + if let Err(e) = sqlx::query!( + "DELETE FROM concurrency_key WHERE ended_at <= now() - ($1::bigint::text || ' s')::interval", + job_retention_secs + ) + .execute(db) + .await + { + tracing::error!("Error deleting custom concurrency key: {:?}", e); } + } - // Each batch runs in its own transaction to avoid long-running locks - let batch_result = - delete_expired_jobs_batch(db, job_retention_secs, batch_size, completed_at_floor) + // Phase 2: each override workspace swept on its own window. A window of 0 means "keep + // forever" for that workspace, so it is excluded from Phase 1 above and skipped here. The + // override count is capped at MAX_RETENTION_OVERRIDE_WORKSPACES (enforced at write time), so + // this loop runs a bounded number of scoped sweeps per pass. + for (w_id, retention_secs) in retention_overrides.iter() { + if *retention_secs > 0 { + run_retention_cleanup(db, *retention_secs, RetentionScope::OnlyWorkspace(w_id)) .await; - - match batch_result { - Ok((deleted_count, max_completed_at)) => { - if deleted_count == 0 { - // No more expired jobs to delete - break; - } - completed_at_floor = max_completed_at.or(completed_at_floor); - total_deleted += deleted_count as u64; - batch_num += 1; - } - Err(e) => { - tracing::error!("Error in job cleanup batch {}: {:?}", batch_num, e); - break; - } } } - - if total_deleted > 0 { - tracing::info!( - "Job cleanup completed: deleted {} jobs in {} batches, took {:?}", - total_deleted, - batch_num, - cleanup_start.elapsed() - ); - } - - // Clean up concurrency keys separately (not tied to specific job IDs) - if let Err(e) = sqlx::query!( - "DELETE FROM concurrency_key WHERE ended_at <= now() - ($1::bigint::text || ' s')::interval", - job_retention_secs - ) - .execute(db) - .await - { - tracing::error!("Error deleting custom concurrency key: {:?}", e); - } } match windmill_common::trashbin::delete_expired_trash(db).await { @@ -1546,11 +1558,18 @@ pub async fn check_expiring_tokens(db: &DB) { /// /// Returns `(jobs deleted in this batch, max completed_at deleted)`. The caller feeds the /// returned watermark back in as `completed_at_floor` for the next batch. +/// +/// `only_workspace` and `exclude_workspaces` implement the per-workspace retention override and are +/// mutually exclusive: Phase 1 passes `exclude_workspaces` (skip override workspaces, sweep the +/// rest), Phase 2 passes `only_workspace` (sweep just that workspace on its own window). Both `None` +/// reproduces the plain global sweep exactly. See `run_retention_cleanup` / `delete_expired_items`. async fn delete_expired_jobs_batch( db: &DB, job_retention_secs: i64, batch_size: i64, completed_at_floor: Option>, + only_workspace: Option<&str>, + exclude_workspaces: Option<&[String]>, ) -> error::Result<(usize, Option>)> { let mut tx = db.begin().await?; @@ -1571,65 +1590,142 @@ async fn delete_expired_jobs_batch( // max(completed_at) deleted by the previous batch. Re-applying it as `completed_at >= floor` // lets each batch resume after the rows the previous batch already processed instead of // re-scanning them. This matters when the oldest rows are undeletable (children of a - // still-active root flow): without the floor the `ORDER BY completed_at ASC` scan walks that - // same protected prefix on every batch, turning a cleanup run quadratic in prefix size. + // still-active root flow, or override workspaces excluded from the global sweep): without the + // floor the `ORDER BY completed_at ASC` scan walks that same protected/retained prefix on every + // batch, turning a cleanup run quadratic in prefix size. // Floor only ever skips rows the current run already deleted, was protecting, or skip-locked — // all correctly deferred to the next run, identical to the unbounded scan's semantics. // + // It is applied as `completed_at >= COALESCE($floor, '-infinity')`, NOT `$floor IS NULL OR + // completed_at >= $floor`: the `OR ... IS NULL` disjunction is non-sargable, so the planner + // cannot use the floor as an index lower bound and falls back to a Seq Scan of the whole table — + // walking the entire prefix regardless of the floor. The COALESCE sentinel keeps a single cached + // query while making the bound a plain range predicate the completed_at / composite index drives. + // // Use FOR UPDATE SKIP LOCKED to avoid contention between replicas; ORDER BY completed_at // deletes oldest jobs first. - let (deleted_jobs, max_completed_at) = if active_root_job_ids.is_empty() { - // Common case: no old root flow is still running, so nothing is protected and the - // v2_job join (a PK lookup per candidate) is pure overhead — skip it entirely. - let rows = sqlx::query!( - "DELETE FROM v2_job_completed - WHERE id IN ( - SELECT id FROM v2_job_completed - WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval - AND ($3::timestamptz IS NULL OR completed_at >= $3) - ORDER BY completed_at ASC - LIMIT $2 - FOR UPDATE SKIP LOCKED - ) - RETURNING id, completed_at", - job_retention_secs, - batch_size, - completed_at_floor, - ) - .fetch_all(&mut *tx) - .await?; - let max = rows.iter().map(|r| r.completed_at).max(); - (rows.into_iter().map(|r| r.id).collect::>(), max) - } else { - // Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than `!= ALL($3)`: - // the subquery form lets the planner build a one-time hashed SubPlan and apply it as a - // filter on the ordered index scan, giving O(1) membership per candidate instead of a - // per-row linear array scan (which degrades sharply when many root jobs are active). The - // `u IS NOT NULL` guard sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids). - let rows = sqlx::query!( - "DELETE FROM v2_job_completed - WHERE id IN ( - SELECT jc.id FROM v2_job_completed jc - LEFT JOIN v2_job j ON j.id = jc.id - WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval - AND ($4::timestamptz IS NULL OR jc.completed_at >= $4) - AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN ( - SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL - ) - ORDER BY jc.completed_at ASC - LIMIT $2 - FOR UPDATE OF jc SKIP LOCKED - ) - RETURNING id, completed_at", - job_retention_secs, - batch_size, - &active_root_job_ids, - completed_at_floor, - ) - .fetch_all(&mut *tx) - .await?; - let max = rows.iter().map(|r| r.completed_at).max(); - (rows.into_iter().map(|r| r.id).collect::>(), max) + // Two orthogonal choices drive which DELETE we run: + // - `only_workspace`: Some => a single-workspace (Phase 2) sweep. We bind `workspace_id = $n` + // directly (no `OR $n IS NULL` guard) so the composite `(workspace_id, completed_at)` index + // can drive the ordered scan — a sargable equality the OR-form would defeat. `None` => a + // global (Phase 1) sweep that instead excludes override workspaces via a hashed `NOT IN + // (SELECT ... unnest($exclude))` SubPlan (same one-time-hash trick as the active-root + // exclusion below): O(1) membership per candidate, vs `<> ALL($exclude)`'s per-row linear + // array scan which degrades sharply once many workspaces have overrides. + // - `active_root_job_ids.is_empty()`: skip the `v2_job` join entirely when nothing is + // protected (a PK lookup per candidate is pure overhead in the common case). + let (deleted_jobs, max_completed_at) = match only_workspace { + Some(w_id) if active_root_job_ids.is_empty() => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT id FROM v2_job_completed + WHERE workspace_id = $4 + AND completed_at <= now() - ($1::bigint::text || ' s')::interval + AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz) + ORDER BY completed_at ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + completed_at_floor, + w_id, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } + Some(w_id) => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT jc.id FROM v2_job_completed jc + LEFT JOIN v2_job j ON j.id = jc.id + WHERE jc.workspace_id = $5 + AND jc.completed_at <= now() - ($1::bigint::text || ' s')::interval + AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz) + AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN ( + SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL + ) + ORDER BY jc.completed_at ASC + LIMIT $2 + FOR UPDATE OF jc SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + &active_root_job_ids, + completed_at_floor, + w_id, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } + None if active_root_job_ids.is_empty() => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT id FROM v2_job_completed + WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval + AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz) + AND ($4::text[] IS NULL OR workspace_id NOT IN ( + SELECT u FROM unnest($4::text[]) AS u WHERE u IS NOT NULL + )) + ORDER BY completed_at ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + completed_at_floor, + exclude_workspaces, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } + None => { + // Active-root exclusion uses `NOT IN (SELECT ... unnest($3))` rather than `!= ALL($3)`: + // the subquery form lets the planner build a one-time hashed SubPlan and apply it as a + // filter on the ordered index scan, giving O(1) membership per candidate instead of a + // per-row linear array scan (which degrades sharply when many root jobs are active). The + // `u IS NOT NULL` guard sidesteps NOT IN's null-trap semantics ($3 holds non-null PK ids). + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT jc.id FROM v2_job_completed jc + LEFT JOIN v2_job j ON j.id = jc.id + WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval + AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz) + AND ($5::text[] IS NULL OR jc.workspace_id NOT IN ( + SELECT u FROM unnest($5::text[]) AS u WHERE u IS NOT NULL + )) + AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN ( + SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL + ) + ORDER BY jc.completed_at ASC + LIMIT $2 + FOR UPDATE OF jc SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + &active_root_job_ids, + completed_at_floor, + exclude_workspaces, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } }; let deleted_count = deleted_jobs.len(); @@ -1704,6 +1800,189 @@ async fn delete_expired_jobs_batch( Ok((deleted_count, max_completed_at)) } +/// Which workspaces a retention cleanup run targets. +#[derive(Debug)] +enum RetentionScope<'a> { + /// Sweep every workspace except the listed ones (they run in their own Phase-2 pass). + GlobalExcluding(&'a [String]), + /// Sweep only this single workspace, on its own retention window. + OnlyWorkspace(&'a str), +} + +/// Drives the batched job-retention delete for a given `retention_secs` window and `scope`. +/// Preserves the per-run `completed_at_floor` watermark across batches (see +/// `delete_expired_jobs_batch`). Returns the number of jobs deleted. +/// +/// `JOB_CLEANUP_MAX_BATCHES` bounds the batches per call, i.e. per scope. A full cleanup cycle can +/// therefore run up to `(1 + n_override_workspaces) * max_batches` batches; the override count is +/// capped at `MAX_RETENTION_OVERRIDE_WORKSPACES`, and any residue is picked up on the next tick. +async fn run_retention_cleanup(db: &DB, retention_secs: i64, scope: RetentionScope<'_>) -> u64 { + let (only_workspace, exclude_workspaces): (Option<&str>, Option<&[String]>) = match &scope { + // An empty exclusion list binds as NULL so the guard short-circuits to the plain sweep. + RetentionScope::GlobalExcluding(ids) => { + (None, if ids.is_empty() { None } else { Some(*ids) }) + } + RetentionScope::OnlyWorkspace(w_id) => (Some(*w_id), None), + }; + + let batch_size = *JOB_CLEANUP_BATCH_SIZE; + let max_batches = *JOB_CLEANUP_MAX_BATCHES; + let cleanup_start = Instant::now(); + let mut total_deleted = 0u64; + let mut batch_num = 0i32; + // Watermark carried across batches so each one resumes after the rows the previous batch + // already processed instead of re-scanning the (potentially undeletable) oldest prefix. + let mut completed_at_floor: Option> = None; + + // Process batches until no more expired jobs or max batches reached + loop { + if max_batches > 0 && batch_num >= max_batches { + tracing::debug!( + "Job cleanup ({scope:?}): reached max batches limit ({max_batches}), will continue next iteration" + ); + break; + } + + // Each batch runs in its own transaction to avoid long-running locks + let batch_result = delete_expired_jobs_batch( + db, + retention_secs, + batch_size, + completed_at_floor, + only_workspace, + exclude_workspaces, + ) + .await; + + match batch_result { + Ok((deleted_count, max_completed_at)) => { + if deleted_count == 0 { + // No more expired jobs to delete + break; + } + completed_at_floor = max_completed_at.or(completed_at_floor); + total_deleted += deleted_count as u64; + batch_num += 1; + } + Err(e) => { + tracing::error!("Error in job cleanup batch {batch_num} ({scope:?}): {e:?}"); + break; + } + } + } + + if total_deleted > 0 { + tracing::info!( + "Job cleanup completed ({scope:?}): deleted {total_deleted} jobs in {batch_num} batches, took {:?}", + cleanup_start.elapsed() + ); + } + + total_deleted +} + +/// Parses the raw `{workspace_id: seconds}` global-setting object into an override map. Returns +/// `Err` (with the offending workspace) if ANY value is not a non-negative integer, so the caller +/// can keep the last-good map instead of dropping just that entry — dropping a longer-retention +/// entry would let the Phase-1 global window delete its jobs, and a negative value would silently +/// become keep-forever (Phase 2 only sweeps `> 0`). +#[cfg(feature = "enterprise")] +fn parse_retention_overrides( + map: serde_json::Map, +) -> std::result::Result, String> { + use windmill_common::global_settings::MAX_RETENTION_OVERRIDE_WORKSPACES; + if map.len() > MAX_RETENTION_OVERRIDE_WORKSPACES { + return Err(format!( + "at most {MAX_RETENTION_OVERRIDE_WORKSPACES} per-workspace retention overrides are allowed, got {}", + map.len() + )); + } + let mut overrides = std::collections::HashMap::with_capacity(map.len()); + for (w_id, v) in map { + match v.as_i64() { + Some(secs) if secs >= 0 => { + overrides.insert(w_id, secs); + } + _ => { + return Err(format!( + "override for '{w_id}' must be a non-negative integer number of seconds, got {v}" + )); + } + } + } + Ok(overrides) +} + +/// Loads the per-workspace retention overrides from the `retention_period_secs_overrides` global +/// setting (a JSON `{workspace_id: secs}` object) into the in-memory `JOB_RETENTION_SECS_OVERRIDES` +/// cache, so the cleanup sweep reads them without a per-tick DB query. Enterprise-only — CE leaves +/// the cache empty so the sweep behaves exactly as before. +/// +/// On a load error, unexpected value shape, or malformed data the previous map is kept but +/// `JOB_RETENTION_SECS_OVERRIDES_LOADED` is set to FALSE, marking the cache unknown. Clobbering the +/// map to empty would let the global sweep delete jobs a workspace asked to keep longer; leaving the +/// flag TRUE would keep the stale (possibly shorter) policy in force after a lengthened/added +/// override fails to refresh, deleting those jobs prematurely. Marking it unknown makes the sweep +/// fail closed — it skips and the monitor retries the load next tick until a confirmed-current state +/// loads. `LOADED` is set true only on a valid map, explicit unset (`Ok(None)`), or CE's no-op. +pub async fn load_retention_period_overrides(db: &DB) -> error::Result<()> { + #[cfg(not(feature = "enterprise"))] + { + let _ = db; + // Overrides are EE-only; empty is the correct, fully-known state on CE. + JOB_RETENTION_SECS_OVERRIDES_LOADED.store(true, std::sync::atomic::Ordering::Relaxed); + } + #[cfg(feature = "enterprise")] + { + use windmill_common::global_settings::RETENTION_PERIOD_SECS_OVERRIDES_SETTING; + let value = + load_value_from_global_settings(db, RETENTION_PERIOD_SECS_OVERRIDES_SETTING).await; + match value { + Ok(Some(serde_json::Value::Object(map))) => match parse_retention_overrides(map) { + Ok(overrides) => { + JOB_RETENTION_SECS_OVERRIDES.store(std::sync::Arc::new(overrides)); + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(true, std::sync::atomic::Ordering::Relaxed); + } + // Malformed persisted value: we can't confirm the current override set. Keep the + // last-good map but mark the cache unknown so the sweep fails closed (skips) and + // retries, rather than deleting with a stale — possibly shorter — policy. + Err(reason) => { + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(false, std::sync::atomic::Ordering::Relaxed); + tracing::error!( + "Malformed per-workspace retention overrides, gating cleanup until it loads: {reason}" + ); + } + }, + Ok(None) => { + // Explicit unset is a known state: no overrides. + JOB_RETENTION_SECS_OVERRIDES + .store(std::sync::Arc::new(std::collections::HashMap::new())); + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(true, std::sync::atomic::Ordering::Relaxed); + } + // Unexpected shape / read failure: mark unknown so a lengthened or added override that + // failed to refresh can't be missed by a sweep still running the previous policy. + Ok(Some(other)) => { + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(false, std::sync::atomic::Ordering::Relaxed); + tracing::error!( + "Per-workspace retention overrides setting is not a JSON object (got {other}); gating cleanup until it loads" + ); + } + Err(e) => { + JOB_RETENTION_SECS_OVERRIDES_LOADED + .store(false, std::sync::atomic::Ordering::Relaxed); + tracing::error!( + "Error loading per-workspace retention overrides, gating cleanup until it loads: {e:#}" + ); + } + } + } + Ok(()) +} + async fn delete_log_files_from_disk_and_store( paths_to_delete: Vec, tmp_dir: &str, @@ -4734,3 +5013,54 @@ async fn manage_audit_partitions(db: &DB, retention_days: i64) { } } } + +#[cfg(all(test, feature = "enterprise"))] +mod retention_overrides_tests { + use super::parse_retention_overrides; + use serde_json::json; + + fn obj(v: serde_json::Value) -> serde_json::Map { + v.as_object().unwrap().clone() + } + + #[test] + fn parses_valid_map() { + let m = parse_retention_overrides(obj(json!({"a": 3600, "b": 0}))).unwrap(); + assert_eq!(m.get("a"), Some(&3600)); + assert_eq!(m.get("b"), Some(&0)); // 0 = keep forever, allowed + assert_eq!(m.len(), 2); + } + + #[test] + fn empty_map_is_ok() { + assert!(parse_retention_overrides(obj(json!({}))) + .unwrap() + .is_empty()); + } + + #[test] + fn rejects_negative() { + // A negative value must not silently become keep-forever; the whole map is rejected. + assert!(parse_retention_overrides(obj(json!({"a": 3600, "b": -1}))).is_err()); + } + + #[test] + fn rejects_non_integer() { + assert!(parse_retention_overrides(obj(json!({"a": "3600"}))).is_err()); + assert!(parse_retention_overrides(obj(json!({"a": 3600.5}))).is_err()); + assert!(parse_retention_overrides(obj(json!({"a": null}))).is_err()); + } + + #[test] + fn rejects_too_many_overrides() { + use windmill_common::global_settings::MAX_RETENTION_OVERRIDE_WORKSPACES; + let at_cap: serde_json::Map<_, _> = (0..MAX_RETENTION_OVERRIDE_WORKSPACES) + .map(|i| (format!("ws_{i}"), json!(3600))) + .collect(); + assert!(parse_retention_overrides(at_cap.clone()).is_ok()); + let over_cap: serde_json::Map<_, _> = (0..MAX_RETENTION_OVERRIDE_WORKSPACES + 1) + .map(|i| (format!("ws_{i}"), json!(3600))) + .collect(); + assert!(parse_retention_overrides(over_cap).is_err()); + } +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 0a58f1f4b3..9d37233228 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -61,7 +61,8 @@ use windmill_common::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, - HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, RUFF_CONFIG_SETTING, + HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES, + RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WS_BASE_URL_SETTING, @@ -1047,6 +1048,38 @@ async fn run_setting_pre_write_hook( } } } + RETENTION_PERIOD_SECS_OVERRIDES_SETTING => { + // Reject a malformed map at write time so it can never be persisted. A persisted bad + // value (negative or non-integer) would fail to parse on the next server start and, + // because the loader fails closed (skips cleanup until a known-good value is read), + // silently disable ALL job-retention cleanup indefinitely. This shape check must stay in + // sync with `parse_retention_overrides` in backend/src/monitor.rs. + match value { + // Clearing (delete row) is handled by the caller; allow it through. + serde_json::Value::Null => {} + serde_json::Value::String(s) if s.trim().is_empty() => {} + serde_json::Value::Object(map) => { + if map.len() > MAX_RETENTION_OVERRIDE_WORKSPACES { + return Err(error::Error::BadRequest(format!( + "retention_period_secs_overrides: at most {MAX_RETENTION_OVERRIDE_WORKSPACES} per-workspace overrides are allowed, got {}", + map.len() + ))); + } + for (ws, v) in map { + if !v.as_i64().is_some_and(|secs| secs >= 0) { + return Err(error::Error::BadRequest(format!( + "retention_period_secs_overrides: override for '{ws}' must be a non-negative integer number of seconds, got {v}" + ))); + } + } + } + _ => { + return Err(error::Error::BadRequest( + "retention_period_secs_overrides must be a JSON object of {workspace_id: seconds}".to_string(), + )); + } + } + } _ => {} } Ok(()) diff --git a/backend/windmill-api-settings/src/log_cleanup.rs b/backend/windmill-api-settings/src/log_cleanup.rs index f5e5eab680..ecbc38fcb7 100644 --- a/backend/windmill-api-settings/src/log_cleanup.rs +++ b/backend/windmill-api-settings/src/log_cleanup.rs @@ -30,7 +30,10 @@ use windmill_common::error::{self}; use windmill_common::jobs::delete_jobs; use windmill_common::tracing_init::{LOGS_SERVICE, TMP_WINDMILL_LOGS_SERVICE}; use windmill_common::worker::WINDMILL_DIR; -use windmill_common::{DB, INSTANCE_NAME, JOB_RETENTION_SECS, SERVICE_LOG_RETENTION_SECS}; +use windmill_common::{ + DB, INSTANCE_NAME, JOB_RETENTION_SECS, JOB_RETENTION_SECS_OVERRIDES, + JOB_RETENTION_SECS_OVERRIDES_LOADED, SERVICE_LOG_RETENTION_SECS, +}; use windmill_object_store::object_store_reexports::{ ObjectStore, ObjectStoreError, Path as ObjectPath, @@ -321,29 +324,103 @@ async fn cleanup_job_logs( store: &Arc, ) -> error::Result<()> { let retention_secs = JOB_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed); - if retention_secs <= 0 { + + // Per-workspace retention overrides (EE). Honor them exactly like the periodic monitor sweep: + // Phase 1 deletes on the instance window but EXCLUDES override workspaces, Phase 2 deletes each + // override workspace on its own window. Fail closed if the override set was never loaded (e.g. + // manual cleanup triggered right after startup) — sweeping globally with an unknown override set + // would delete jobs a longer-retention workspace asked to keep. + if !JOB_RETENTION_SECS_OVERRIDES_LOADED.load(std::sync::atomic::Ordering::Relaxed) { + tracing::warn!( + "log cleanup: per-workspace retention overrides not yet loaded; skipping job log cleanup this run" + ); return Ok(()); } + let overrides = JOB_RETENTION_SECS_OVERRIDES.load_full(); + let override_ids: Vec = overrides.keys().cloned().collect(); + let exclude: Option<&[String]> = if override_ids.is_empty() { + None + } else { + Some(&override_ids) + }; - let total: i64 = sqlx::query_scalar!( - "SELECT COUNT(*) FROM v2_job_completed - WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval", - retention_secs, - ) - .fetch_one(db) - .await? - .unwrap_or(0); + // Upfront total for the progress bar: Phase-1 candidates (instance window, excluding overrides) + // plus Phase-2 candidates (each override on its own window). Collapsed to `processed` at the end. + let mut total: i64 = if retention_secs > 0 { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM v2_job_completed + WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval + AND ($2::text[] IS NULL OR workspace_id NOT IN ( + SELECT u FROM unnest($2::text[]) AS u WHERE u IS NOT NULL + ))", + retention_secs, + exclude, + ) + .fetch_one(db) + .await? + .unwrap_or(0) + } else { + 0 + }; + for (w_id, secs) in overrides.iter() { + if *secs > 0 { + total += sqlx::query_scalar!( + "SELECT COUNT(*) FROM v2_job_completed + WHERE workspace_id = $1 + AND completed_at <= now() - ($2::bigint::text || ' s')::interval", + w_id, + secs, + ) + .fetch_one(db) + .await? + .unwrap_or(0); + } + } session.update(|p| p.total_jobs = total as u64).await; - if total <= 0 { return Ok(()); } + // Phase 1: instance window, excluding override workspaces. + if retention_secs > 0 { + run_job_log_cleanup_phase(session, db, store, retention_secs, None, exclude).await?; + } + // Phase 2: each override workspace on its own window (0 = keep forever, skipped). + for (w_id, secs) in overrides.iter() { + if *secs > 0 { + run_job_log_cleanup_phase(session, db, store, *secs, Some(w_id), None).await?; + } + } + + // Collapse the total to what we actually processed — the upfront count includes jobs whose root + // is still active (protected from deletion), so without this the progress bar would get stuck. + session.update(|p| p.total_jobs = p.processed_jobs).await; + + Ok(()) +} + +/// Runs the batched job+log delete loop for one retention scope (`only_workspace` / `exclude`), +/// deleting the returned log blobs from storage and updating progress. See `cleanup_job_logs`. +async fn run_job_log_cleanup_phase( + session: &Session, + db: &DB, + store: &Arc, + retention_secs: i64, + only_workspace: Option<&str>, + exclude_workspaces: Option<&[String]>, +) -> error::Result<()> { let mut completed_at_floor: Option> = None; loop { - let (deleted_count, rel_paths, max_completed_at) = - delete_expired_jobs_batch(db, retention_secs, JOB_BATCH, completed_at_floor).await?; + let (deleted_count, rel_paths, max_completed_at) = delete_expired_jobs_batch( + db, + retention_secs, + JOB_BATCH, + completed_at_floor, + only_workspace, + exclude_workspaces, + ) + .await?; if deleted_count == 0 { break; @@ -369,12 +446,6 @@ async fn cleanup_job_logs( }) .await; } - - // Collapse the total to what we actually processed — the upfront count - // includes jobs whose root is still active (protected from deletion), so - // without this the progress bar would get stuck at e.g. 3/44. - session.update(|p| p.total_jobs = p.processed_jobs).await; - Ok(()) } @@ -386,6 +457,8 @@ async fn delete_expired_jobs_batch( job_retention_secs: i64, batch_size: i64, completed_at_floor: Option>, + only_workspace: Option<&str>, + exclude_workspaces: Option<&[String]>, ) -> error::Result<(usize, Vec, Option>)> { let mut tx = db.begin().await?; @@ -401,53 +474,119 @@ async fn delete_expired_jobs_batch( // `completed_at_floor` carries a watermark across batches so each one resumes after the rows // the previous batch processed instead of re-scanning the (potentially undeletable) oldest - // prefix; the empty-active-roots branch skips the v2_job join entirely. See - // backend/src/monitor.rs::delete_expired_jobs_batch for the full rationale. - let (deleted_jobs, max_completed_at) = if active_root_job_ids.is_empty() { - let rows = sqlx::query!( - "DELETE FROM v2_job_completed - WHERE id IN ( - SELECT id FROM v2_job_completed - WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval - AND ($3::timestamptz IS NULL OR completed_at >= $3) - ORDER BY completed_at ASC - LIMIT $2 - FOR UPDATE SKIP LOCKED - ) - RETURNING id, completed_at", - job_retention_secs, - batch_size, - completed_at_floor, - ) - .fetch_all(&mut *tx) - .await?; - let max = rows.iter().map(|r| r.completed_at).max(); - (rows.into_iter().map(|r| r.id).collect::>(), max) - } else { - let rows = sqlx::query!( - "DELETE FROM v2_job_completed - WHERE id IN ( - SELECT jc.id FROM v2_job_completed jc - LEFT JOIN v2_job j ON j.id = jc.id - WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval - AND ($4::timestamptz IS NULL OR jc.completed_at >= $4) - AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN ( - SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL - ) - ORDER BY jc.completed_at ASC - LIMIT $2 - FOR UPDATE OF jc SKIP LOCKED - ) - RETURNING id, completed_at", - job_retention_secs, - batch_size, - &active_root_job_ids, - completed_at_floor, - ) - .fetch_all(&mut *tx) - .await?; - let max = rows.iter().map(|r| r.completed_at).max(); - (rows.into_iter().map(|r| r.id).collect::>(), max) + // prefix; the empty-active-roots branch skips the v2_job join entirely. Applied as + // `completed_at >= COALESCE($floor, '-infinity')` — the `$floor IS NULL OR ...` form is + // non-sargable and forces a Seq Scan. `only_workspace` / `exclude_workspaces` scope the sweep for + // the per-workspace retention override (Phase 1 global excluding override workspaces, Phase 2 + // per-override) — same 4-arm shape and index rationale as + // backend/src/monitor.rs::delete_expired_jobs_batch (see there for the full rationale). + let (deleted_jobs, max_completed_at) = match only_workspace { + Some(w_id) if active_root_job_ids.is_empty() => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT id FROM v2_job_completed + WHERE workspace_id = $4 + AND completed_at <= now() - ($1::bigint::text || ' s')::interval + AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz) + ORDER BY completed_at ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + completed_at_floor, + w_id, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } + Some(w_id) => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT jc.id FROM v2_job_completed jc + LEFT JOIN v2_job j ON j.id = jc.id + WHERE jc.workspace_id = $5 + AND jc.completed_at <= now() - ($1::bigint::text || ' s')::interval + AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz) + AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN ( + SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL + ) + ORDER BY jc.completed_at ASC + LIMIT $2 + FOR UPDATE OF jc SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + &active_root_job_ids, + completed_at_floor, + w_id, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } + None if active_root_job_ids.is_empty() => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT id FROM v2_job_completed + WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval + AND completed_at >= COALESCE($3::timestamptz, '-infinity'::timestamptz) + AND ($4::text[] IS NULL OR workspace_id NOT IN ( + SELECT u FROM unnest($4::text[]) AS u WHERE u IS NOT NULL + )) + ORDER BY completed_at ASC + LIMIT $2 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + completed_at_floor, + exclude_workspaces, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } + None => { + let rows = sqlx::query!( + "DELETE FROM v2_job_completed + WHERE id IN ( + SELECT jc.id FROM v2_job_completed jc + LEFT JOIN v2_job j ON j.id = jc.id + WHERE jc.completed_at <= now() - ($1::bigint::text || ' s')::interval + AND jc.completed_at >= COALESCE($4::timestamptz, '-infinity'::timestamptz) + AND ($5::text[] IS NULL OR jc.workspace_id NOT IN ( + SELECT u FROM unnest($5::text[]) AS u WHERE u IS NOT NULL + )) + AND COALESCE(j.root_job, j.flow_innermost_root_job, jc.id) NOT IN ( + SELECT u FROM unnest($3::uuid[]) AS u WHERE u IS NOT NULL + ) + ORDER BY jc.completed_at ASC + LIMIT $2 + FOR UPDATE OF jc SKIP LOCKED + ) + RETURNING id, completed_at", + job_retention_secs, + batch_size, + &active_root_job_ids, + completed_at_floor, + exclude_workspaces, + ) + .fetch_all(&mut *tx) + .await?; + let max = rows.iter().map(|r| r.completed_at).max(); + (rows.into_iter().map(|r| r.id).collect::>(), max) + } }; let deleted_count = deleted_jobs.len(); @@ -542,15 +681,28 @@ async fn cleanup_s3_orphans( let job_retention_secs = JOB_RETENTION_SECS.load(std::sync::atomic::Ordering::Relaxed); let now = Utc::now(); // Service logs always have a retention (hardcoded SERVICE_LOG_RETENTION_SECS), - // so we scan for service-log orphans regardless of JOB_RETENTION_SECS. Job-log - // orphans, by contrast, can only be considered expired relative to - // JOB_RETENTION_SECS; when that is disabled we skip the job branch entirely. + // so we scan for service-log orphans regardless of JOB_RETENTION_SECS. let service_cutoff = now - chrono::Duration::seconds(SERVICE_LOG_RETENTION_SECS); - let job_cutoff = if job_retention_secs > 0 { - Some(now - chrono::Duration::seconds(job_retention_secs)) - } else { - None - }; + + // Job-log orphans are only considered once past a job's effective retention window. That window + // is the instance one OR, for an override workspace (EE), its own — and jobs orphan their logs as + // soon as the SHORTEST applicable window elapses. Since this scan applies a single cutoff (the S3 + // path carries only the job id, not the workspace), use the MINIMUM positive window across the + // instance window and every positive override so no window's orphans are missed. Crucially this + // also covers a `0` (keep-forever) instance window that still has positive overrides — the case + // where a plain global-only cutoff would skip the job branch entirely and orphan those logs + // forever. Keep-forever windows (0) contribute nothing: their jobs are never deleted. Overrides + // are folded in only once the cache is a known state; otherwise we fall back to the instance + // window alone and the next run picks up any override-only orphans once the cache loads. + let mut min_positive_window = (job_retention_secs > 0).then_some(job_retention_secs); + if JOB_RETENTION_SECS_OVERRIDES_LOADED.load(std::sync::atomic::Ordering::Relaxed) { + for w in JOB_RETENTION_SECS_OVERRIDES.load_full().values().copied() { + if w > 0 { + min_positive_window = Some(min_positive_window.map_or(w, |m| m.min(w))); + } + } + } + let job_cutoff = min_positive_window.map(|w| now - chrono::Duration::seconds(w)); let logs_prefix = ObjectPath::from("logs/"); let mut stream = store.list(Some(&logs_prefix)); diff --git a/backend/windmill-api/src/db_health.rs b/backend/windmill-api/src/db_health.rs index c487d29da9..0b2e86567b 100644 --- a/backend/windmill-api/src/db_health.rs +++ b/backend/windmill-api/src/db_health.rs @@ -15,6 +15,7 @@ use axum::{ use serde::{Deserialize, Serialize}; use windmill_common::error::JsonResult; +use windmill_common::{JOB_RETENTION_SECS_OVERRIDES, JOB_RETENTION_SECS_OVERRIDES_LOADED}; use crate::db::{ApiAuthed, DB}; use crate::utils::require_super_admin; @@ -291,10 +292,50 @@ async fn fetch_database_size(db: &DB) -> windmill_common::error::Result windmill_common::error::Result { - let job_row = - sqlx::query!("SELECT MIN(completed_at) as oldest, COUNT(*) as total FROM v2_job_completed") - .fetch_one(db) - .await?; + // Per-workspace retention overrides (EE) make "oldest completed job vs the instance retention" + // wrong as a single global signal: each override workspace has its own effective window, so its + // intentionally-retained jobs must be judged against that window — not the instance one. We + // therefore compute the ratio per scope and report the worst: + // - global scope: oldest job across all non-override workspaces vs the instance retention; + // - each override workspace with a *positive* window: its own oldest job vs its own window; + // - keep-forever (0) overrides: excluded entirely — their jobs are retained forever by design, + // so there is no window to fall behind on. + // The majority (no-override) path keeps the original index-driven `MIN(completed_at)` with no + // performance change; the override paths use the completed_at / (workspace_id, completed_at) + // indexes and only run when overrides exist. + let overrides = JOB_RETENTION_SECS_OVERRIDES.load_full(); + let overrides_active = !overrides.is_empty() + && JOB_RETENTION_SECS_OVERRIDES_LOADED.load(std::sync::atomic::Ordering::Relaxed); + + // `true_oldest` is the real table minimum across every workspace — reported verbatim in the + // public `oldest_completed_at` field so the UI's "Oldest job" label stays honest. `global_oldest` + // excludes override workspaces and drives only the global health ratio (override workspaces are + // judged against their own window below). + type OptTs = Option>; + let (true_oldest, global_oldest, total): (OptTs, OptTs, i64) = if !overrides_active { + let r = sqlx::query!( + "SELECT MIN(completed_at) as oldest, COUNT(*) as total FROM v2_job_completed" + ) + .fetch_one(db) + .await?; + (r.oldest, r.oldest, r.total.unwrap_or(0)) + } else { + // `MIN(...) WHERE workspace_id <> ALL(...)` is still driven by the completed_at index + // (ascending scan, early-stop at the first non-override row); the plain `MIN(...)` is an + // index-only scan. Both are cheap. + let override_ids: Vec = overrides.keys().cloned().collect(); + let r = sqlx::query!( + "SELECT + (SELECT MIN(completed_at) FROM v2_job_completed) as true_oldest, + (SELECT MIN(completed_at) FROM v2_job_completed + WHERE workspace_id <> ALL($1::text[])) as global_oldest, + (SELECT COUNT(*) FROM v2_job_completed) as total", + &override_ids, + ) + .fetch_one(db) + .await?; + (r.true_oldest, r.global_oldest, r.total.unwrap_or(0)) + }; let retention_row = sqlx::query!("SELECT value FROM global_settings WHERE name = 'retention_period_secs'") @@ -304,48 +345,103 @@ async fn fetch_job_retention(db: &DB) -> windmill_common::error::Result = retention_row.map(|r| r.value).and_then(|v| v.as_i64()); - let oldest = job_row.oldest; - let total = job_row.total.unwrap_or(0); + // Oldest job per positive-window override workspace (one grouped seek on the + // `(workspace_id, completed_at)` index; only workspaces that actually have rows come back). + let positive_override_ids: Vec = if overrides_active { + overrides + .iter() + .filter(|(_, &secs)| secs > 0) + .map(|(ws, _)| ws.clone()) + .collect() + } else { + Vec::new() + }; + let mut per_workspace_oldest: Vec<(String, chrono::DateTime)> = Vec::new(); + if !positive_override_ids.is_empty() { + let rows = sqlx::query!( + "SELECT workspace_id as \"workspace_id!\", MIN(completed_at) as oldest + FROM v2_job_completed + WHERE workspace_id = ANY($1::text[]) + GROUP BY workspace_id", + &positive_override_ids, + ) + .fetch_all(db) + .await?; + for r in rows { + if let Some(oldest) = r.oldest { + per_workspace_oldest.push((r.workspace_id, oldest)); + } + } + } - let (status, message) = if let (Some(oldest_ts), Some(retention_secs)) = - (oldest, retention_period_secs) - { - let age_secs: i64 = (chrono::Utc::now() - oldest_ts).num_seconds(); - let ratio = if retention_secs > 0 { - age_secs as f64 / retention_secs as f64 - } else { - 0.0 - }; + // Evaluate each scope independently and report the WORST. Each scope contributes a candidate + // (severity, level, message); the max-severity candidate wins. This keeps the global scope's + // "no retention configured" warning visible even when a healthy override would otherwise mask it. + let now = chrono::Utc::now(); + let ratio_status = |scope: String, ratio: f64| -> (u8, HealthLevel, String) { if ratio <= 2.0 { ( + 0, HealthLevel::Green, - format!( - "Oldest job is {:.1}x the retention period. Cleanup is keeping up.", - ratio - ), + format!("{scope} is {ratio:.1}x the retention period. Cleanup is keeping up."), ) } else if ratio <= 5.0 { ( + 1, HealthLevel::Yellow, format!( - "Oldest job is {:.1}x the retention period. Cleanup may be falling behind.", - ratio + "{scope} is {ratio:.1}x the retention period. Cleanup may be falling behind." ), ) } else { - (HealthLevel::Red, format!("Oldest job is {:.1}x the retention period. Consider reducing retention or investigating cleanup.", ratio)) + (2, HealthLevel::Red, format!("{scope} is {ratio:.1}x the retention period. Consider reducing retention or investigating cleanup.")) } - } else if oldest.is_some() && retention_period_secs.is_none() { - ( + }; + + let mut candidates: Vec<(u8, HealthLevel, String)> = Vec::new(); + // Global scope: judged against the instance retention, or flagged when non-override jobs exist + // (`global_oldest` is `Some`) but no positive instance retention is configured. A `0` instance + // retention means keep-forever globally, so it contributes no candidate. + match (global_oldest, retention_period_secs) { + (Some(oldest_ts), Some(retention_secs)) if retention_secs > 0 => { + let ratio = (now - oldest_ts).num_seconds() as f64 / retention_secs as f64; + candidates.push(ratio_status("Oldest job".to_string(), ratio)); + } + (Some(_), None) => candidates.push(( + 1, HealthLevel::Yellow, "No retention_period_secs configured. Old jobs will accumulate.".to_string(), + )), + _ => {} + } + // Each positive-window override, judged against its own window. + for (ws, oldest_ts) in &per_workspace_oldest { + if let Some(&window) = overrides.get(ws) { + if window > 0 { + let ratio = (now - *oldest_ts).num_seconds() as f64 / window as f64; + candidates.push(ratio_status(format!("Workspace {ws} oldest job"), ratio)); + } + } + } + + let (status, message) = if let Some((_, level, message)) = candidates + .into_iter() + .max_by_key(|(severity, _, _)| *severity) + { + (level, message) + } else if total > 0 { + // Jobs exist but none produced a candidate: every completed job lives in a keep-forever + // scope (instance or override), so it is retained by design rather than overdue. + ( + HealthLevel::Green, + "Completed jobs are within their configured retention windows.".to_string(), ) } else { (HealthLevel::Green, "No completed jobs found.".to_string()) }; Ok(JobRetentionInfo { - oldest_completed_at: oldest, + oldest_completed_at: true_oldest, total_completed_jobs: total, retention_period_secs, status, diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 4d1d48a53d..0ee326ce6b 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -14,6 +14,12 @@ pub const WS_BASE_URL_SETTING: &str = "ws_base_url"; pub const OAUTH_SETTING: &str = "oauths"; pub const AI_CONFIG_SETTING: &str = "ai_config"; pub const RETENTION_PERIOD_SECS_SETTING: &str = "retention_period_secs"; +pub const RETENTION_PERIOD_SECS_OVERRIDES_SETTING: &str = "retention_period_secs_overrides"; +/// Upper bound on how many per-workspace retention overrides may be configured. The periodic monitor +/// sweeps each override workspace in its own transaction every pass, so this keeps a pass bounded +/// (and the feature is a targeted escape hatch for a handful of special workspaces, not a bulk knob). +/// Enforced at write time and defensively on load. +pub const MAX_RETENTION_OVERRIDE_WORKSPACES: usize = 10; pub const AUDIT_LOG_RETENTION_DAYS_SETTING: &str = "audit_log_retention_days"; pub const STORE_AUDIT_LOGS_S3_SETTING: &str = "store_audit_logs_s3"; /// `background_task_state.name` for the audit-log → object-store export cursor. diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index acf97734d8..8f3dae3203 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -260,6 +260,16 @@ lazy_static::lazy_static! { pub static ref CRITICAL_ALERTS_ON_DB_OVERSIZE: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); pub static ref JOB_RETENTION_SECS: AtomicI64 = AtomicI64::new(0); + /// Per-workspace overrides of `JOB_RETENTION_SECS` (EE-only), keyed by workspace_id, in seconds. + /// Sourced from the `retention_period_secs_overrides` global setting and cached here so the + /// cleanup sweep reads it without a per-tick DB query. A workspace may be given a longer OR + /// shorter window than the instance-wide value; `0` means "keep forever" for that workspace. + pub static ref JOB_RETENTION_SECS_OVERRIDES: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(std::collections::HashMap::new()); + /// Whether `JOB_RETENTION_SECS_OVERRIDES` has ever been loaded successfully (a valid map, an + /// explicit unset, or CE's no-op). Until then the empty cache is "unknown, not confirmed empty", + /// so the retention sweep must NOT run globally — that would delete jobs a longer-retention + /// workspace configured before its override could be read. + pub static ref JOB_RETENTION_SECS_OVERRIDES_LOADED: AtomicBool = AtomicBool::new(false); pub static ref AUDIT_LOG_RETENTION_DAYS: AtomicI64 = AtomicI64::new(0); pub static ref MONITOR_LOGS_ON_OBJECT_STORE: AtomicBool = AtomicBool::new(false); diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 834ef0b96c..10e117bb42 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -20,6 +20,7 @@ import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import SimpleEditor from './SimpleEditor.svelte' import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte' + import RetentionPeriodOverrides from './instanceSettings/RetentionPeriodOverrides.svelte' import SmtpSettings from './instanceSettings/SmtpSettings.svelte' import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte' import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte' @@ -326,6 +327,12 @@
{/if} + {:else if setting.fieldType == 'retention_overrides'} + + {:else} = { } ], Jobs: [ + { + label: 'Retention period in secs', + key: 'retention_period_secs', + description: + 'How long to keep the jobs data in the database (max 30 days on CE). Learn more', + fieldType: 'seconds', + placeholder: '30', + storage: 'setting', + ee_only: 'You can only adjust this setting to above 30 days in the EE version', + cloudonly: false + }, + { + label: 'Per-workspace retention overrides', + key: 'retention_period_secs_overrides', + description: + 'Override the job retention period for specific workspaces, independently of the instance-wide value above (longer or shorter). Jobs in a workspace without an override follow the instance-wide setting.', + fieldType: 'retention_overrides', + storage: 'setting', + ee_only: 'Per-workspace retention overrides are only available in the EE version', + cloudonly: false + }, { label: 'Job isolation', key: 'job_isolation', @@ -357,17 +379,6 @@ export const settings: Record = { description: 'Keep Job directories after execution at /tmp/windmill/WORKER/JOB_ID', storage: 'setting' }, - { - label: 'Retention period in secs', - key: 'retention_period_secs', - description: - 'How long to keep the jobs data in the database (max 30 days on CE). Learn more', - fieldType: 'seconds', - placeholder: '30', - storage: 'setting', - ee_only: 'You can only adjust this setting to above 30 days in the EE version', - cloudonly: false - }, { label: 'Workspace fairness — enabled', description: diff --git a/frontend/src/lib/components/instanceSettings/RetentionPeriodOverrides.svelte b/frontend/src/lib/components/instanceSettings/RetentionPeriodOverrides.svelte new file mode 100644 index 0000000000..e1a408939e --- /dev/null +++ b/frontend/src/lib/components/instanceSettings/RetentionPeriodOverrides.svelte @@ -0,0 +1,147 @@ + + +{#if !expanded} + +{:else} +
+ + Overrides the instance retention period for specific workspaces (longer or shorter; 0 keeps + jobs forever). Other workspaces follow the instance-wide setting. + + {#each rows as row (row.id)} + +
+ + + +
+ {/each} + + {#if atCap} + Maximum of {MAX_OVERRIDES} workspace overrides. + {/if} +
+{/if} From 1ed7fc066be3b85a3b49c91cf23d8dd2bd8ca944 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 12 Jul 2026 00:01:39 +0200 Subject: [PATCH 31/52] chore(main): release 1.755.0 (#10041) * chore(main): release 1.755.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 22 +++ backend/Cargo.lock | 184 +++++++++--------- 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, 154 insertions(+), 132 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1262b13911..ed7f835717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.755.0](https://github.com/windmill-labs/windmill/compare/v1.754.0...v1.755.0) (2026-07-11) + + +### Features + +* add per-workspace job-retention override ([#10050](https://github.com/windmill-labs/windmill/issues/10050)) ([ff774c4](https://github.com/windmill-labs/windmill/commit/ff774c46bff4bff1c532e512b163225aa7c41c11)) +* **apps:** authorize deployed-app S3 reads on-behalf of the author for logged-in viewers ([#10048](https://github.com/windmill-labs/windmill/issues/10048)) ([1e192f2](https://github.com/windmill-labs/windmill/commit/1e192f2d864b8a4671e900726972737406bc388a)) +* **mcp:** add multi-workspace MCP tokens via the gateway endpoint ([#10043](https://github.com/windmill-labs/windmill/issues/10043)) ([8343203](https://github.com/windmill-labs/windmill/commit/8343203ec2cea28a2ffd5b4ac636497e861fa3ce)) + + +### Bug Fixes + +* clearer errors on auto-draft save failure (WIN-2157) ([#10053](https://github.com/windmill-labs/windmill/issues/10053)) ([04eb7dd](https://github.com/windmill-labs/windmill/commit/04eb7ddd3906c28bec1711e276473a87c7b9500f)) +* **docker:** pin ansible tool interpreter to a persistent path ([#10054](https://github.com/windmill-labs/windmill/issues/10054)) ([6f49a1f](https://github.com/windmill-labs/windmill/commit/6f49a1f6a904442fcae9bb703f095b0a3ef61268)) +* enforce read authorization when signing S3 objects ([#10049](https://github.com/windmill-labs/windmill/issues/10049)) ([5844c32](https://github.com/windmill-labs/windmill/commit/5844c32ac5d08081b3de7f3d11b8b98eb1e1ad9a)) +* **frontend:** don't re-seed empty editor on stale ?new_draft after draft exists ([#10044](https://github.com/windmill-labs/windmill/issues/10044)) ([e668193](https://github.com/windmill-labs/windmill/commit/e668193a93b4a7df50459b31f2dc5f9a9b23d0fe)) +* **frontend:** keep draft autosave alive after AI-session round-trip ([#10052](https://github.com/windmill-labs/windmill/issues/10052)) ([7d02d9a](https://github.com/windmill-labs/windmill/commit/7d02d9a1e47760f287a71f6e09cd6fe45efb5635)) +* **frontend:** mint draft path for new SDK builder items so autosave attaches ([#10056](https://github.com/windmill-labs/windmill/issues/10056)) ([a89b896](https://github.com/windmill-labs/windmill/commit/a89b896ce5638f42f334055f9ffe6971b047aa84)) +* **frontend:** show nested restart button for subflows nested in containers ([#10042](https://github.com/windmill-labs/windmill/issues/10042)) ([3b07817](https://github.com/windmill-labs/windmill/commit/3b0781761b70667c5961bcb15d41c907716fa9e7)) +* **frontend:** show optimistic user message and fork-creation label before beforeSend ([#10037](https://github.com/windmill-labs/windmill/issues/10037)) ([1c88242](https://github.com/windmill-labs/windmill/commit/1c88242849a02b927f59e0a67c4b4707371b784f)) +* keep agent-worker server job-completed processor alive & self-healing ([#10033](https://github.com/windmill-labs/windmill/issues/10033)) ([ab38e14](https://github.com/windmill-labs/windmill/commit/ab38e1418e67be8bcc37391bb21d2f86d1ca3fc6)) + ## [1.754.0](https://github.com/windmill-labs/windmill/compare/v1.753.0...v1.754.0) (2026-07-10) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c7f0f2c2cc..03e9bd7097 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1890,18 +1890,18 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" dependencies = [ "proc-macro2", "quote", @@ -2057,9 +2057,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.66" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "jobserver", @@ -10160,9 +10160,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "ryu-js" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd29631678d6fb0903b69223673e122c32e9ae559d0960a38d574695ebc0ea15" +checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" [[package]] name = "safetensors" @@ -10663,9 +10663,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -12107,9 +12107,9 @@ dependencies = [ [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -12248,9 +12248,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -13745,7 +13745,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-nats", @@ -13827,7 +13827,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.754.0" +version = "1.755.0" dependencies = [ "async-stream", "async-trait", @@ -13860,7 +13860,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13873,7 +13873,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "argon2", @@ -14011,7 +14011,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14034,7 +14034,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14049,7 +14049,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14075,7 +14075,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.754.0" +version = "1.755.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14085,7 +14085,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14102,7 +14102,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14124,7 +14124,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14147,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14163,7 +14163,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14184,7 +14184,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14219,7 +14219,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-nats", @@ -14254,7 +14254,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14279,7 +14279,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14297,7 +14297,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14319,7 +14319,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14339,7 +14339,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14376,7 +14376,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14404,7 +14404,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.754.0" +version = "1.755.0" dependencies = [ "lazy_static", "serde", @@ -14416,7 +14416,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.754.0" +version = "1.755.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14441,7 +14441,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14455,7 +14455,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.754.0" +version = "1.755.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14490,7 +14490,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.754.0" +version = "1.755.0" dependencies = [ "chrono", "lazy_static", @@ -14504,7 +14504,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14523,7 +14523,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.754.0" +version = "1.755.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14625,7 +14625,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.754.0" +version = "1.755.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14644,7 +14644,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.754.0" +version = "1.755.0" dependencies = [ "regex", "serde", @@ -14659,7 +14659,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14683,7 +14683,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "futures", @@ -14700,7 +14700,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.754.0" +version = "1.755.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14716,7 +14716,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -14737,7 +14737,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "arc-swap", @@ -14793,7 +14793,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-stream", @@ -14827,7 +14827,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "futures", @@ -14845,7 +14845,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.754.0" +version = "1.755.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14854,7 +14854,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -14866,7 +14866,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde_json", @@ -14878,7 +14878,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "gosyn", @@ -14890,7 +14890,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -14902,7 +14902,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde_json", @@ -14914,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "nu-parser", @@ -14925,7 +14925,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14936,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14948,7 +14948,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14959,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-recursion", @@ -14981,7 +14981,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde_json", @@ -14993,7 +14993,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -15007,7 +15007,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -15037,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde", @@ -15049,7 +15049,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15083,7 +15083,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15099,7 +15099,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde", @@ -15110,7 +15110,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-recursion", @@ -15149,7 +15149,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "const_format", @@ -15188,7 +15188,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.754.0" +version = "1.755.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15199,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-recursion", @@ -15233,7 +15233,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15290,7 +15290,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15323,7 +15323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15343,7 +15343,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15377,7 +15377,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15413,7 +15413,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15436,7 +15436,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-nats", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15547,7 +15547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-trait", @@ -15572,7 +15572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15591,7 +15591,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-once-cell", @@ -15701,7 +15701,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.754.0" +version = "1.755.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 17734edd32..5441c7ec2d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.754.0" +version = "1.755.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.754.0" +version = "1.755.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 629ee9c559..649a119916 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.754.0" +version = "1.755.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.754.0" +version = "1.755.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.754.0" +version = "1.755.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.754.0" +version = "1.755.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index f3510a4f55..7f675b5e2e 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.754.0" +version = "1.755.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index f6ce5beadb..2c52e622c7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.754.0 + version: 1.755.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 7825b91127..ccc2b5b949 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.754.0"; +export const VERSION = "v1.755.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 d1550428cd..c28c2f45eb 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.754.0"; +export const VERSION = "1.755.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a88cb3bde0..54eb23c0ef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.754.0", + "version": "1.755.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.754.0", + "version": "1.755.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 8797455f13..63f0c59780 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.754.0", + "version": "1.755.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 fd46daa5db..0e1ea6fdaa 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.754.0" +wmill = ">=1.755.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 52f9f91d0f..9a5fb300a0 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.754.0 + version: 1.755.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index a3dea71302..312aefb924 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.754.0' + ModuleVersion = '1.755.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 909b7d72de..adb13f7d08 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.754.0" +version = "1.755.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 3df6a68d5a..fbd01e7122 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.754.0", + "version": "1.755.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 9a3cdaddd2..2caf959ff9 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.754.0", + "version": "1.755.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 629ceee846..15d30eb90b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.754.0 +1.755.0 From 4783c01bffd93ee0c7b9979c217d40e02651f8dd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 12 Jul 2026 08:50:19 +0200 Subject: [PATCH 32/52] ci: drop debuginfo in Windows backend tests to fix disk exhaustion (WIN-2162) (#10059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Windows integration-test build (`cargo test --all --features …`) fills the runner's C: drive during linking. profile.dev leaves the (large) windmill workspace crates at the default debug = 2, so full debug info is emitted into every object file and embedded in each test binary — the dominant consumer of the ~63GB free on the runner. The previous split-debuginfo=off knob only suppressed the separate .pdb, leaving the embedded debug info in place; it was borderline and the Rust 1.97.0 bump (v1.755.0) pushed it over into a disk-full failure. Set CARGO_PROFILE_DEV_DEBUG=0 and CARGO_PROFILE_TEST_DEBUG=0 so no debug info is generated at all for the CI dev/test profiles. This supersedes split-debuginfo=off (no debuginfo => no .pdb, no mspdbsrv type server) and substantially shrinks the target dir. CI-only; local dev builds are unaffected. Fixes WIN-2162 Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/backend-test-windows.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index ef2431d73a..b952a85e7f 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -174,13 +174,17 @@ jobs: # binary link spikes several hundred MB of transient I/O. Capping at # 8 trades ~25% wall time for headroom on the ~75GB runner disk. CARGO_BUILD_JOBS: 8 - # backend/Cargo.toml sets split-debuginfo = "unpacked", which on - # windows-msvc is coerced to "packed": every test-binary link spawns - # the mspdbsrv.exe PDB type server and writes a large .pdb. CI needs - # no debug info, so disable PDB generation for the dev/test profiles - # here (avoids both LNK1318 type-server limit and PDB disk usage). - CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off" - CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off" + # backend/Cargo.toml leaves profile.dev at the default debug = 2 for + # the (large) windmill workspace crates; that debuginfo is emitted + # into every object file and embedded in each test binary, and on + # windows-msvc also spawns the mspdbsrv.exe PDB type server. Across a + # full --all --features build it is the dominant consumer of the + # ~63GB free on the runner disk (LNK1180 / disk-full during linking). + # CI needs no debug info, so drop it entirely for the dev/test + # profiles here. debug = 0 supersedes the previous split-debuginfo=off + # knob (no debuginfo => no .pdb and no LNK1318 type-server limit). + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" # Tests' poll-time stack frames (deep nested async fn chains in # debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky # overflows under parallel-test contention. From 5cde2d5b6746be9f2d0be3a98ecdaf08777a6395 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 12 Jul 2026 10:19:08 +0200 Subject: [PATCH 33/52] fix(sessions): sync AI-session preview with workspace edits + stop phantom autosave (WIN-2160) (#10061) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(sessions): sync AI-session editor preview with workspace edits + stop phantom autosave (WIN-2160) Two related draft-sync fixes surfaced by the new AI sessions preview. 1. Session preview went stale after a workspace edit. A session's editor runtime cell (content store + loadedPath) outlives the sessions page: it survives toggling to workspace mode and MRU tab eviction. The shared per-user draft can change while the editor is unmounted — most visibly by editing the same item in the classic workspace editor, or from another device — but on the next mount the load early-returns on the still-set loadedPath and the preview keeps showing the pre-toggle content. Fix: invalidate the cell's loadedPath when SessionEditorTarget unmounts, so the next mount re-fetches the draft as a clean first load. This also sidesteps a Monaco model-reuse race (a force-reload that remounts the editor while the old one is still disposing renders a stale model) and prevents the outbound draft-sync from posting the stale store back (ready() stays false until the reload lands). Applies to all three editor kinds (script, flow, raw app) since they share SessionEditorTarget. 2. Opening a deployed script in the full-page editor autosaved a phantom draft with no user change. The deployed baseline carries a server-derived assets: [] that the editor's draft value never reproduces, so draftValuesEqual never matched baseline, discardIf returned false, and the settle-time write posted a no-op draft. Fix: ignore assets in the draft-vs-baseline comparison (it's derived from content, so it can't mask a real change). Fixes WIN-2160 Co-Authored-By: Claude Opus 4.8 (1M context) * chore(sessions): condense teardown-invalidation comment to repo comment-length rule --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/sessions/SessionEditorTarget.svelte | 13 +++++++++++++ frontend/src/lib/userDraft.svelte.ts | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/sessions/SessionEditorTarget.svelte b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte index 1c78720945..332433a928 100644 --- a/frontend/src/lib/components/sessions/SessionEditorTarget.svelte +++ b/frontend/src/lib/components/sessions/SessionEditorTarget.svelte @@ -88,6 +88,19 @@ } }) + // The runtime cell (store + `loadedPath`) outlives this component, so a draft + // changed while unmounted (workspace edit, other device) would be masked by + // `triggerLoad`'s early-return on the stale `loadedPath`. Invalidate it on + // teardown so the next mount re-fetches as a clean first load. + $effect(() => { + const c = cell + const p = path + const w = workspaceId + return () => { + if (c.slot.loadedPath === p && c.slot.loadedWorkspace === w) c.slot.loadedPath = undefined + } + }) + // Mark this editor as the live editor draft for the session's workspace so // the chat's `isLiveDraft` hint / `discard_local_draft` tool resolve to this // path — same registration the regular edit pages do. Only the visible tab of diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index b9392cee9a..6ecacbce22 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -202,7 +202,10 @@ function snapshotDraftValue(value: V | undefined): V | undefined { * 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). + * `is_draft` (backend presence flag), and `assets` (server-derived on deploy + * from the content — the editor's draft value never carries it, so a deployed + * baseline's `assets: []` would otherwise never equal the assets-less draft and + * every untouched load would autosave a phantom draft). */ const DRAFT_COMPARE_IGNORED_FIELDS = [ 'permissioned_as', @@ -214,7 +217,8 @@ const DRAFT_COMPARE_IGNORED_FIELDS = [ 'workspace_id', 'version_id', 'parent_version', - 'is_draft' + 'is_draft', + 'assets' ] as const /** From 92b7f375a90de2f78565ca06a13c79ff04eda44d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 12 Jul 2026 10:19:20 +0200 Subject: [PATCH 34/52] fix: replicate all secrets on fork when external backend is configured (#10060) * fix: replicate all secrets on fork with external backend (WIN-2161) Co-Authored-By: Claude Opus 4.8 * test: add Azure KV fork secret-replication reproduction (WIN-2161) Co-Authored-By: Claude Opus 4.8 * style: condense clone_variables invariant comment (WIN-2161) Co-Authored-By: Claude Opus 4.8 * test: drive real create_fork handler in Azure KV repro (WIN-2161) Replace the windmill-common test that mirrored clone_variables' loop with an end-to-end test in windmill-api-integration-tests that exercises the real migration, create_fork and variable-read endpoints against a local Azure KV emulator. Verified it fails (404 "not found in Azure Key Vault") without the fix and passes with it; unique per-run ids keep it robust to the emulator's persistent state. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- ...4f6e02030d8581a7e8c1f0673c7f6dae78fb3.json | 22 +++ ...819520a2ba987fd29859845c2177563ab8cfb.json | 28 --- .../tests/fork_secret_replication_azure.rs | 161 ++++++++++++++++++ .../windmill-api-workspaces/src/workspaces.rs | 31 ++-- 4 files changed, 198 insertions(+), 44 deletions(-) create mode 100644 backend/.sqlx/query-b6de5fbd5fa89e9a8ab8a61982d4f6e02030d8581a7e8c1f0673c7f6dae78fb3.json delete mode 100644 backend/.sqlx/query-e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb.json create mode 100644 backend/windmill-api-integration-tests/tests/fork_secret_replication_azure.rs diff --git a/backend/.sqlx/query-b6de5fbd5fa89e9a8ab8a61982d4f6e02030d8581a7e8c1f0673c7f6dae78fb3.json b/backend/.sqlx/query-b6de5fbd5fa89e9a8ab8a61982d4f6e02030d8581a7e8c1f0673c7f6dae78fb3.json new file mode 100644 index 0000000000..d05cc5cdd4 --- /dev/null +++ b/backend/.sqlx/query-b6de5fbd5fa89e9a8ab8a61982d4f6e02030d8581a7e8c1f0673c7f6dae78fb3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path FROM variable\n WHERE workspace_id = $1 AND is_secret = true AND value != ''", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b6de5fbd5fa89e9a8ab8a61982d4f6e02030d8581a7e8c1f0673c7f6dae78fb3" +} diff --git a/backend/.sqlx/query-e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb.json b/backend/.sqlx/query-e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb.json deleted file mode 100644 index 18c75f5722..0000000000 --- a/backend/.sqlx/query-e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT path, value FROM variable\n WHERE workspace_id = $1 AND is_secret = true AND value != ''", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "value", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "e4a46d47aee96473a2bd0f1dbdc819520a2ba987fd29859845c2177563ab8cfb" -} diff --git a/backend/windmill-api-integration-tests/tests/fork_secret_replication_azure.rs b/backend/windmill-api-integration-tests/tests/fork_secret_replication_azure.rs new file mode 100644 index 0000000000..19682de70a --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fork_secret_replication_azure.rs @@ -0,0 +1,161 @@ +//! End-to-end regression test for WIN-2161. +//! +//! Reproduces, through real product code, the state after a database-to-external +//! migration: a secret that was created under the database backend and then +//! *migrated* to an external backend (Azure Key Vault). Migration writes the +//! plaintext to the store but +//! leaves the encrypted ciphertext in `variable.value` (it never rewrites it to +//! a `$azure_kv:` marker). The bug: `clone_variables` only replicated +//! marker-valued secrets, so forking left the migrated secret unreplicated and +//! reads in the fork failed with "not found in Azure Key Vault". +//! +//! This drives the real `/migrate_secrets_to_azure_kv`, `/create_fork` and +//! `variables/get_value` endpoints against a local Azure Key Vault emulator +//! (lowkey-vault), which the `AzureKeyVaultBackend` talks to via its +//! static-token / self-signed-cert emulator mode. +//! +//! Run it: +//! ```bash +//! podman run -d --name lowkey -p 8443:8443 \ +//! -e LOWKEY_ARGS="--LOWKEY_VAULT_NAMES=default" \ +//! docker.io/nagyesta/lowkey-vault:7.3.0 +//! +//! RUN_AZURE_KV_TESTS=1 cargo test -p windmill-api-integration-tests \ +//! --features private,enterprise --test fork_secret_replication_azure -- --nocapture +//! ``` + +#[cfg(all(feature = "private", feature = "enterprise"))] +mod azure_fork { + use serde_json::json; + use sqlx::{Pool, Postgres}; + use windmill_common::variables::{build_crypt, encrypt}; + use windmill_test_utils::*; + + fn client() -> reqwest::Client { + reqwest::Client::new() + } + + fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") + } + + fn vault_url() -> String { + std::env::var("AZURE_KV_URL").unwrap_or_else(|_| "https://localhost:8443".to_string()) + } + + /// The Azure settings for the emulator: a static token switches the backend + /// into emulator mode (no Entra ID, self-signed certs accepted). + fn azure_settings() -> serde_json::Value { + json!({ + "vault_url": vault_url(), + "tenant_id": "emulator-tenant", + "client_id": "emulator-client", + "token": "emulator-token", + }) + } + + #[sqlx::test(migrations = "../migrations", fixtures("base"))] + async fn migrated_secret_is_replicated_on_fork(db: Pool) -> anyhow::Result<()> { + if std::env::var("RUN_AZURE_KV_TESTS").as_deref() != Ok("1") { + eprintln!("skipping: set RUN_AZURE_KV_TESTS=1 and start lowkey-vault to run"); + return Ok(()); + } + initialize_tracing().await; + + // The Azure KV emulator persists across runs; derive unique names per run + // so a secret written by a previous run can't mask a regression. + let suffix = uuid::Uuid::new_v4().simple().to_string(); + let short = &suffix[..8]; + let source_ws = "test-workspace"; + let path = format!("u/test-user/db_password_{short}"); + let path = path.as_str(); + let plaintext = "s3cr3t-value"; + + let ciphertext = { + let mc = build_crypt(&db, source_ws).await?; + encrypt(&mc, plaintext) + }; + sqlx::query( + "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) + VALUES ($1, $2, $3, true, '', '{}')", + ) + .bind(source_ws) + .bind(path) + .bind(&ciphertext) + .execute(&db) + .await?; + + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ('secret_backend', $1) + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value", + ) + .bind(json!({ + "type": "AzureKeyVault", + "vault_url": vault_url(), + "tenant_id": "emulator-tenant", + "client_id": "emulator-client", + "token": "emulator-token", + })) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = authed(client().post(format!( + "http://localhost:{port}/api/settings/migrate_secrets_to_azure_kv" + ))) + .json(&azure_settings()) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 200, "migrate_secrets_to_azure_kv failed: {body}"); + let report: serde_json::Value = serde_json::from_str(&body)?; + assert!( + report["migrated_count"].as_i64().unwrap_or(0) >= 1, + "expected at least one migrated secret: {report}" + ); + + // Assert the source resolves before forking, so a fork-read failure is + // attributable to replication rather than a broken seed. + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/{source_ws}/variables/get_value/{path}" + ))) + .send() + .await?; + assert_eq!(resp.status(), 200, "source read: {}", resp.text().await?); + assert_eq!(resp.json::().await?, plaintext); + + let fork_ws = format!("wm-fork-az{short}"); + let fork_ws = fork_ws.as_str(); + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/{source_ws}/workspaces/create_fork" + ))) + .json(&json!({ "id": fork_ws, "name": "Azure Fork Test" })) + .send() + .await?; + assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?); + + // The fork resolves the secret only if it was replicated under the fork's + // own workspace-id key in the external store. + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/{fork_ws}/variables/get_value/{path}" + ))) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 200, + "forked secret must resolve, got {status}: {body}" + ); + assert_eq!( + serde_json::from_str::(&body)?, + plaintext, + "fork should return the replicated plaintext" + ); + + Ok(()) + } +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 2c28a6729a..ba359cbffb 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -71,9 +71,7 @@ use hyper::StatusCode; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Postgres, Row, Transaction}; use windmill_common::oauth2::InstanceEvent; -use windmill_common::secret_backend::{ - get_secret_backend, is_external_stored_value, is_vault_backend_configured, -}; +use windmill_common::secret_backend::{get_secret_backend, is_vault_backend_configured}; use windmill_common::utils::not_found_if_none; lazy_static::lazy_static! { @@ -4703,15 +4701,13 @@ async fn clone_variables( .execute(&mut **tx) .await?; - // With an external secret backend (Vault / Azure KV / AWS SM), the copied - // `value` is only a `$vault:`/`$azure_kv:`/`$aws_sm:` marker: the actual - // secret lives in the external store under a key derived from - // (workspace_id, path). The row copy above therefore leaves the fork's - // markers pointing at keys that don't exist — replicate each secret under - // the fork's workspace id. + // With an external backend the secret lives in the store under (workspace_id, + // path), so the row copy above leaves the fork pointing at keys that don't + // exist. Replicate every secret, not just marker-valued ones: migration writes + // to the store without rewriting `value` to a `$...:` marker. if is_vault_backend_configured(db).await? { let secret_variables = sqlx::query!( - "SELECT path, value FROM variable + "SELECT path FROM variable WHERE workspace_id = $1 AND is_secret = true AND value != ''", target_workspace_id, ) @@ -4719,10 +4715,7 @@ async fn clone_variables( .await?; let backend = get_secret_backend(db).await?; - for variable in secret_variables - .into_iter() - .filter(|v| is_external_stored_value(&v.value)) - { + for variable in secret_variables { match backend .get_secret(source_workspace_id, &variable.path) .await @@ -5810,8 +5803,14 @@ async fn create_workspace_fork( .await?; // Clone all data from the parent workspace using Rust implementation - if let Err(e) = - clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed.email).await + if let Err(e) = clone_workspace_data( + &mut tx, + &db, + &parent_workspace_id, + &forked_id, + &authed.email, + ) + .await { // A genuine `\u0000` in a source `json` value (`app_version.value` / // `flow_version.schema`) aborts the clone when it is re-encoded to jsonb: From 29f4cd4b6f58a29b83b84a7a9b8a439d20ade00e Mon Sep 17 00:00:00 2001 From: lucsoft Date: Sun, 12 Jul 2026 10:26:55 +0200 Subject: [PATCH 35/52] feat(triggers): serve binary HTTP-route responses via base64 transfer encoding (#10058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an opt-in `wm_content_transfer_encoding: "base64"` field to the composite result. When set (together with `wm_content_type`), result_to_response decodes the string result into raw bytes before sending it, so sync HTTP routes/webhooks can return arbitrary binary payloads (PDFs, images, ...) with any content type — not just as base64 text or via object storage. Explicit and safe: the encoding is never guessed, invalid base64 is a hard error (no silent fallback to the encoded text), an unsupported encoding is rejected, and a transfer encoding without a content type is rejected. Existing string responses are unchanged. Closes #5986 --- backend/windmill-api-jobs/src/execution.rs | 152 ++++++++++++++++++++- 1 file changed, 148 insertions(+), 4 deletions(-) diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 8ec60a69ba..b4707fca9f 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -12,6 +12,7 @@ use axum::{ response::{IntoResponse, Response}, Json, }; +use base64::Engine as _; use http::{HeaderMap, HeaderName, HeaderValue}; use hyper::StatusCode; use serde::Deserialize; @@ -254,6 +255,8 @@ pub struct WindmillCompositeResult { windmill_content_type: Option, #[serde(alias = "wm_headers")] windmill_headers: Option>, + #[serde(alias = "wm_content_transfer_encoding")] + windmill_content_transfer_encoding: Option, result: Option>, } @@ -375,11 +378,13 @@ pub fn result_to_response(result: Box, success: bool) -> error::Result windmill_status_code, windmill_content_type, windmill_headers, + windmill_content_transfer_encoding, result: result_value, }) => { if windmill_content_type.is_none() && windmill_status_code.is_none() && windmill_headers.is_none() + && windmill_content_transfer_encoding.is_none() { return Ok(( if success { @@ -425,18 +430,54 @@ pub fn result_to_response(result: Box, success: bool) -> error::Result let serialized_json_result = result_value .map(|val| val.get().to_owned()) .unwrap_or_else(String::new); - let serialized_result = - serde_json::from_str::(serialized_json_result.as_str()) - .ok() - .unwrap_or(serialized_json_result); + let parsed_string = + serde_json::from_str::(serialized_json_result.as_str()).ok(); + let result_is_json_string = parsed_string.is_some(); + let serialized_result = parsed_string.unwrap_or(serialized_json_result); headers.insert( http::header::CONTENT_TYPE, HeaderValue::from_str(content_type.as_str()).map_err(|err| { Error::internal_err(format!("Invalid content type {content_type}: {err}")) })?, ); + // Invalid base64 is a hard error, never a silent fallback to the encoded text. + match windmill_content_transfer_encoding.as_deref() { + Some("base64") => { + // Only a JSON string carries base64; a number/bool/null/array/object + // must not have its raw JSON text decoded into arbitrary bytes. + if !result_is_json_string { + return Err(Error::ExecutionErr( + "windmill_content_transfer_encoding \"base64\" requires result \ + to be a base64-encoded string" + .to_string(), + )); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(serialized_result.as_bytes()) + .map_err(|err| { + Error::ExecutionErr(format!( + "windmill_content_transfer_encoding is \"base64\" but the \ + result is not valid base64: {err}" + )) + })?; + return Ok((status_code_or_default, headers, decoded).into_response()); + } + Some(other) => { + return Err(Error::ExecutionErr(format!( + "Unsupported windmill_content_transfer_encoding \"{other}\" \ + (only \"base64\" is supported)" + ))); + } + None => {} + } return Ok((status_code_or_default, headers, serialized_result).into_response()); } + if windmill_content_transfer_encoding.is_some() { + return Err(Error::ExecutionErr( + "windmill_content_transfer_encoding requires windmill_content_type to be set" + .to_string(), + )); + } if let Some(result_value) = result_value { return Ok((status_code_or_default, headers, Json(result_value)).into_response()); } else { @@ -960,3 +1001,106 @@ pub async fn push_script_job_by_path_into_queue<'c>( Ok((uuid, resolved_delete_secs, None)) } } + +#[cfg(test)] +mod result_to_response_tests { + use super::*; + + fn raw(json: &str) -> Box { + serde_json::from_str(json).expect("valid json") + } + + async fn body_bytes(resp: Response) -> Vec { + axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .expect("read body") + .to_vec() + } + + #[tokio::test] + async fn base64_result_is_decoded_to_raw_bytes() { + // 0x00 0x01 0x02 0xFF is not valid UTF-8, so it can only survive as bytes. + let bytes = vec![0u8, 1, 2, 255]; + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + let resp = result_to_response( + raw(&format!( + r#"{{"wm_content_type":"application/pdf","wm_content_transfer_encoding":"base64","result":"{b64}"}}"# + )), + true, + ) + .expect("response"); + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(http::header::CONTENT_TYPE).unwrap(), + "application/pdf" + ); + assert_eq!(body_bytes(resp).await, bytes); + } + + #[tokio::test] + async fn invalid_base64_is_a_hard_error() { + let res = result_to_response( + raw( + r#"{"wm_content_type":"application/pdf","wm_content_transfer_encoding":"base64","result":"not valid base64!!"}"#, + ), + true, + ); + assert!(res.is_err(), "invalid base64 must not silently fall back"); + } + + #[tokio::test] + async fn base64_mode_rejects_non_string_results() { + // A number/bool whose raw JSON text happens to be valid base64 (right length, + // base64 alphabet) must not be decoded into bytes — it must be a hard error. + for result in ["12345678", "true", "null", "[1,2,3]"] { + let res = result_to_response( + raw(&format!( + r#"{{"wm_content_type":"application/octet-stream","wm_content_transfer_encoding":"base64","result":{result}}}"# + )), + true, + ); + assert!( + res.is_err(), + "base64 mode must reject non-string result: {result}" + ); + } + } + + #[tokio::test] + async fn unsupported_transfer_encoding_is_rejected() { + let res = result_to_response( + raw( + r#"{"wm_content_type":"text/plain","wm_content_transfer_encoding":"gzip","result":"x"}"#, + ), + true, + ); + assert!(res.is_err()); + } + + #[tokio::test] + async fn transfer_encoding_without_content_type_is_rejected() { + let res = result_to_response( + raw(r#"{"wm_content_transfer_encoding":"base64","result":"aGk="}"#), + true, + ); + assert!(res.is_err()); + } + + #[tokio::test] + async fn string_result_is_still_served_verbatim() { + // Regression: without a transfer encoding, a string result is sent as-is + // (quotes stripped), not base64-decoded. + let resp = result_to_response( + raw(r#"{"wm_content_type":"text/html","result":"

hi

"}"#), + true, + ) + .expect("response"); + + assert_eq!( + resp.headers().get(http::header::CONTENT_TYPE).unwrap(), + "text/html" + ); + assert_eq!(body_bytes(resp).await, b"

hi

"); + } +} From 22b47c8823a1a10574044c01091d49d743155686 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 12 Jul 2026 10:30:58 +0200 Subject: [PATCH 36/52] chore(main): release 1.756.0 (#10062) * chore(main): release 1.756.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 13 ++ 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, 131 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed7f835717..a0dc63ad42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.756.0](https://github.com/windmill-labs/windmill/compare/v1.755.0...v1.756.0) (2026-07-12) + + +### Features + +* **triggers:** serve binary HTTP-route responses via base64 transfer encoding ([#10058](https://github.com/windmill-labs/windmill/issues/10058)) ([29f4cd4](https://github.com/windmill-labs/windmill/commit/29f4cd4b6f58a29b83b84a7a9b8a439d20ade00e)), closes [#5986](https://github.com/windmill-labs/windmill/issues/5986) + + +### Bug Fixes + +* replicate all secrets on fork when external backend is configured ([#10060](https://github.com/windmill-labs/windmill/issues/10060)) ([92b7f37](https://github.com/windmill-labs/windmill/commit/92b7f375a90de2f78565ca06a13c79ff04eda44d)) +* **sessions:** sync AI-session preview with workspace edits + stop phantom autosave (WIN-2160) ([#10061](https://github.com/windmill-labs/windmill/issues/10061)) ([5cde2d5](https://github.com/windmill-labs/windmill/commit/5cde2d5b6746be9f2d0be3a98ecdaf08777a6395)) + ## [1.755.0](https://github.com/windmill-labs/windmill/compare/v1.754.0...v1.755.0) (2026-07-11) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 03e9bd7097..9ae2df8c80 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13745,7 +13745,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-nats", @@ -13827,7 +13827,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.755.0" +version = "1.756.0" dependencies = [ "async-stream", "async-trait", @@ -13860,7 +13860,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13873,7 +13873,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "argon2", @@ -14011,7 +14011,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14034,7 +14034,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14049,7 +14049,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14075,7 +14075,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.755.0" +version = "1.756.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14085,7 +14085,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14102,7 +14102,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14124,7 +14124,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14147,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14163,7 +14163,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14184,7 +14184,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14219,7 +14219,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-nats", @@ -14254,7 +14254,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14279,7 +14279,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14297,7 +14297,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14319,7 +14319,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14339,7 +14339,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14376,7 +14376,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14404,7 +14404,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.755.0" +version = "1.756.0" dependencies = [ "lazy_static", "serde", @@ -14416,7 +14416,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.755.0" +version = "1.756.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14441,7 +14441,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14455,7 +14455,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.755.0" +version = "1.756.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14490,7 +14490,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.755.0" +version = "1.756.0" dependencies = [ "chrono", "lazy_static", @@ -14504,7 +14504,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14523,7 +14523,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.755.0" +version = "1.756.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14625,7 +14625,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.755.0" +version = "1.756.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14644,7 +14644,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.755.0" +version = "1.756.0" dependencies = [ "regex", "serde", @@ -14659,7 +14659,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14683,7 +14683,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "futures", @@ -14700,7 +14700,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.755.0" +version = "1.756.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14716,7 +14716,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -14737,7 +14737,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "arc-swap", @@ -14793,7 +14793,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-stream", @@ -14827,7 +14827,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "futures", @@ -14845,7 +14845,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.755.0" +version = "1.756.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14854,7 +14854,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -14866,7 +14866,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -14878,7 +14878,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "gosyn", @@ -14890,7 +14890,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -14902,7 +14902,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -14914,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "nu-parser", @@ -14925,7 +14925,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14936,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14948,7 +14948,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14959,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-recursion", @@ -14981,7 +14981,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -14993,7 +14993,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -15007,7 +15007,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -15037,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde", @@ -15049,7 +15049,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15083,7 +15083,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15099,7 +15099,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde", @@ -15110,7 +15110,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-recursion", @@ -15149,7 +15149,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "const_format", @@ -15188,7 +15188,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.755.0" +version = "1.756.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15199,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-recursion", @@ -15233,7 +15233,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15290,7 +15290,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15323,7 +15323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15343,7 +15343,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15377,7 +15377,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15413,7 +15413,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15436,7 +15436,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-nats", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15547,7 +15547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-trait", @@ -15572,7 +15572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15591,7 +15591,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-once-cell", @@ -15701,7 +15701,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.755.0" +version = "1.756.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5441c7ec2d..ab4d8bf345 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.755.0" +version = "1.756.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.755.0" +version = "1.756.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 649a119916..ae54e3f521 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.755.0" +version = "1.756.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.755.0" +version = "1.756.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.755.0" +version = "1.756.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.755.0" +version = "1.756.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 7f675b5e2e..edf76878e7 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.755.0" +version = "1.756.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2c52e622c7..2045ae34b3 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.755.0 + version: 1.756.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index ccc2b5b949..57f64026b7 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.755.0"; +export const VERSION = "v1.756.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 c28c2f45eb..7d27cfade7 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.755.0"; +export const VERSION = "1.756.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 54eb23c0ef..90db619840 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.755.0", + "version": "1.756.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.755.0", + "version": "1.756.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 63f0c59780..0c94286c6f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.755.0", + "version": "1.756.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 0e1ea6fdaa..2fb02de9da 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.755.0" +wmill = ">=1.756.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 9a5fb300a0..dd45a739db 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.755.0 + version: 1.756.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 312aefb924..8d5f84ca1b 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.755.0' + ModuleVersion = '1.756.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index adb13f7d08..d5eddeadf1 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.755.0" +version = "1.756.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 fbd01e7122..1cf80c83bf 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.755.0", + "version": "1.756.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 2caf959ff9..2103036c3b 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.755.0", + "version": "1.756.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 15d30eb90b..222bd3fdeb 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.755.0 +1.756.0 From 710a13a59d511614839f70e445db736e436550d7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 14 Jul 2026 10:29:48 +0200 Subject: [PATCH 37/52] fix(apps): cover script/flow component outputs in deployed-app S3 provenance gate (#10070) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(apps): cover script/flow component outputs in deployed-app S3 provenance gate Deployed apps read S3 files on-behalf of the app author for logged-in viewers (#10048). A confused-deputy guard confines those reads to files the app "produced", but the recent-production check only matched inline `appscript`/ `preview` jobs nested under the app path. Files produced by the deployed script/flow components an app is wired to run (e.g. a SQL query persisted to S3) were therefore denied "File restricted" for every viewer, admins included. Expand the provenance check to also match completed `script`/`flow`/`flowscript`/ `flownode` jobs whose `runnable_path` is one of the app's declared triggerables, and accept the author identity via `permissioned_as = on_behalf_of` (not only `created_by = caller`) so files produced on-behalf of the author are covered. Reads outside the app's declared triggerables stay denied. Adds a regression test seeding a script-kind produced file that reproduces the "File restricted" denial before the fix and passes after. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): key S3 provenance on on-behalf identity + cover flow steps (review) Addresses the CI review on the S3 provenance gate: - P1 (confused deputy): the recent-production check keyed on `created_by = caller`, so a viewer who can run a declared script/flow directly (outside the app, with un-pinned inputs) could craft a result naming an author-only key and read it back through the app as the author. Key provenance instead on the producing job's `permissioned_as` matching the on-behalf identity the download reads as (the author in author-mode); a viewer's direct run has `permissioned_as = viewer` and no longer clears the gate. Drops `created_by` from both the appscript/preview and script/flow branches, closing the same latent hole in the pre-existing inline-script branch. - P2 (dead flow-step branch): `flowscript`/`flownode` jobs have `runnable_path = /`, which exact `= ANY(...)` never matched. Split script vs flow triggerable paths; flow kinds now match the flow's own job (bare path) and its step jobs via a `/%` prefix, bounded to declared flows. - P2 (test realism): the regression test now uses the production component-prefixed triggerable key format (`:script/...`), exercises a flow-step-produced key, and asserts a viewer's own direct run of a declared script stays denied (the P1 case). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): tie deployed-app S3 provenance to an app-origination marker (review) Second CI-review round flagged that `permissioned_as` still does not prove a job was app-launched: a runnable configured with its own `on_behalf_of` makes a direct `/jobs/run` resolve `permissioned_as` to that identity (the app author), so a viewer with run access could execute a declared runnable directly, craft an S3 result, and read it back through the app. The flow-path `LIKE fp || '/%'` match also let `_`/`%` in a declared path admit unrelated flows. Introduce a real app-origination marker instead of inferring provenance: - Add `JobTriggerKind::App`; `execute_component` stamps every app-launched job with `trigger_kind = 'app'` + `trigger = `. A direct `/jobs/run` cannot set this, so it is the authoritative signal that a file was produced *by the app*. - The provenance gate's recent-production check collapses to `trigger_kind = 'app' AND trigger = ` (+ the 3h window and result containment). This drops the forgeable `created_by`/`permissioned_as`/ `runnable_path`/kind logic entirely and removes the `LIKE` wildcard issue. - Provenance is scoped to THIS app's path, so another app's jobs (even same author) do not authorize this app's reads. Regression test rewritten to the marker model: an app-produced key clears for viewer and admin; a direct run whose `permissioned_as` resolves to the author stays denied (the forgery); another app's output stays denied. Adds `app` to the OpenAPI JobTriggerKind enum. Co-Authored-By: Claude Opus 4.8 (1M context) * test(apps): assert execute_component stamps trigger_kind='app' at runtime Adds an end-to-end test that runs a real script component through the app runtime (`apps_u/execute_component`) and asserts the enqueued job carries the app-origination marker `trigger_kind = 'app'` + `trigger = ` (not the runnable path). The provenance-gate tests seed the marker directly; this proves the runtime actually produces the exact marker the gate depends on. execute_component commits the job row and returns its id, so the assertion reads the row directly — no worker needed to run the job. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(triggers): reject trigger_kind=app for suspended-job reassignment (review) `JobTriggerKind::App` (added for the app-origination S3 marker) became a valid value for the resume/cancel suspended-trigger routes, whose handler derives the table name `_trigger`. There is no `app_trigger` table, so both endpoints would fail with a missing-relation database error (500). Reject `App` in `get_suspended_trigger` alongside webhook/schedule so it returns a clean 400. Adds a regression test asserting the reassignment route returns 400 (not 500) for trigger_kind=app. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): don't stamp app-origination marker on preview runs (review) The app-origination marker (trigger_kind='app') was stamped unconditionally, including preview mode. A preview lets a `jobs:run` caller supply arbitrary `raw_code` against ANY app path without that app's deployed policy (raw_code with no path/id skips all app authorization), so a preview returning `{"s3":""}` would forge the exact marker the S3 provenance gate trusts and read the victim app author's file. Gate the marker on `!is_preview`: only deployed, policy-checked executions are app-provenanced. Preview/editor S3 display does not rely on this marker (the editor routes reads through the force_viewer allowlist), so nothing legitimate regresses. Adds a regression test asserting a preview run's job is not stamped trigger_kind='app'. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): editor-authorize preview marker + per-viewer S3 provenance isolation (review) Closes the codex P1 (preview forgery) without breaking editor preview downloads, and adds cross-viewer isolation to the provenance gate. - Preview marker now requires app write: `execute_component` stamps the app-origination marker on a preview only when the caller can EDIT that app (`require_is_writer`), instead of never stamping previews. An app editor already wields the app's author identity (they can deploy a component that reads the same file), so marking their own preview is no escalation and keeps preview-produced S3 results downloadable in the editor; a `jobs:run`-only caller who cannot edit the app still cannot forge the marker. Deployed runs are unchanged (always marked). - Per-viewer isolation: the provenance gate now also requires `j.created_by = `. The security boundary stays the un-forgeable `trigger_kind='app'` marker; `created_by` is an additional filter ANDed under it, so it only narrows — a viewer can only download keys their OWN app runs produced, not another viewer's result. Restores the per-caller scoping #10048 had, now safe on top of the marker. Tests: preview marked iff caller can edit the app; cross-viewer isolation (another viewer's app-marked key denied, no admin bypass); direct-run and other-app keys still denied; deployed run still stamped. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): require apps:write scope (not just writer ACL) to mark preview provenance (review) require_is_writer checks the user's underlying ACL but ignores token scopes, so a writer's token deliberately scoped to apps:run/apps:read/jobs:run but WITHOUT apps:write could still mark a preview and forge provenance — even though that token cannot deploy the app (update_app requires apps:write), breaking the "any marked caller can deploy equivalent code" rationale. Require BOTH apps:write: scope (check_scopes) AND the writer ACL (require_is_writer) before stamping a preview's app-origination marker. Deployed runs unchanged. Adds a scope-restricted-writer token to the test (apps:run/read + jobs:run, no apps:write) and asserts its preview stays unmarked; retains the full-editor positive case and the non-editor negative case. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): never app-provenance preview runs; read editor S3 as the caller (review) Simplifies the preview handling: a preview executes as the *caller* (Viewer mode), never as the author, so its results must be read back as the caller — never author-mode — and must never carry the app-origination marker. This removes the whole `require_is_writer` / `apps:write` / `can_preserve_on_behalf_of` reasoning (which was also unsound: a writer's token or session may not be able to deploy a component running as the app's on-behalf identity, so marking their preview could still escalate). - Backend: mark the app-origination marker for deployed runs only (`!is_preview`). - Frontend: `getS3File` (AppImage/AppPdf/AppDownload) now routes editor/preview reads through the viewer-scoped `job_helpers/download_s3_file` endpoint (reads as the caller), matching what DisplayResult/ParqetCsvTableRenderer already do; only a deployed app view uses the provenance-gated `apps_u` endpoint. This is the path that previously relied on marking previews, so nothing regresses. Test: a preview is never app-provenanced (owner's own preview and a non-editor's both stay unmarked). Cross-viewer isolation, deployed marking, and the reassignment guard are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): app components run on-behalf of the app, not the referenced runnable (review) Root-causes codex's on-behalf-preview finding: `execute_component` was overriding the app's resolved on-behalf identity with the referenced script/flow's OWN `on_behalf_of` (its `on_behalf_of_email`). That is wrong in the app context — the app's execution mode should govern: - A Viewer-mode app could execute a component AS the referenced runnable's on_behalf identity (privilege confusion / escalation), instead of as the viewer. - A preview would run as that identity rather than as the caller, so its S3 output could not be read back as the caller — the download-identity mismatch codex flagged. Always use the app-resolved identity (author in author-mode, caller in viewer/preview); a referenced runnable's own `on_behalf_of` no longer leaks into app execution. Direct `/jobs/run` still honors a runnable's `on_behalf_of` (unchanged). With this, previews always run as the caller, so reading editor/preview S3 as the caller (viewer-scoped `job_helpers`) is unconditionally correct. - Test: the deployed-component e2e now seeds the script with a distinct on_behalf and asserts the component job's `permissioned_as` is the app identity, not the script's. - Also reword the getS3File `configuration` param comment to describe current state only (AGENTS.md comment rule). Co-Authored-By: Claude Opus 4.8 (1M context) * chore(apps): surface 'app' trigger kind in Runs UI; condense provenance comments (review) Addresses codex review nits: - Add `app` to `jobTriggerKinds`, `triggerIconMap` (LayoutDashboard), and `triggerDisplayNamesMap` so app-component jobs (which now carry `trigger_kind = 'app'`) are filterable in Runs and render their trigger info. - Condense the app-origination marker, on-behalf-identity, and provenance-gate comments to state each invariant once in <=4 lines at its relevant site (AGENTS.md comment rule). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...5b5aeb572e51ebd0732e6ebf80197acb3e577.json | 25 ++ backend/tests/app_s3_onbehalf.rs | 376 ++++++++++++++++++ backend/windmill-api/openapi.yaml | 1 + backend/windmill-api/src/apps.rs | 78 ++-- .../windmill-trigger/src/global_handler.rs | 5 +- backend/windmill-types/src/jobs.rs | 6 + .../lib/components/apps/editor/appUtilsS3.ts | 45 ++- frontend/src/lib/components/triggers/utils.ts | 21 +- 8 files changed, 492 insertions(+), 65 deletions(-) create mode 100644 backend/.sqlx/query-75d1618e12c4dfe5e9632ba8dc45b5aeb572e51ebd0732e6ebf80197acb3e577.json diff --git a/backend/.sqlx/query-75d1618e12c4dfe5e9632ba8dc45b5aeb572e51ebd0732e6ebf80197acb3e577.json b/backend/.sqlx/query-75d1618e12c4dfe5e9632ba8dc45b5aeb572e51ebd0732e6ebf80197acb3e577.json new file mode 100644 index 0000000000..817a13cb2f --- /dev/null +++ b/backend/.sqlx/query-75d1618e12c4dfe5e9632ba8dc45b5aeb572e51ebd0732e6ebf80197acb3e577.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id)\n WHERE j.workspace_id = $2\n AND c.started_at > now() - interval '3 hours'\n AND c.result @> ('{\"s3\":\"' || $1 || '\"}')::jsonb\n AND j.trigger_kind = 'app'\n AND j.trigger = $3\n AND j.created_by = $4\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "75d1618e12c4dfe5e9632ba8dc45b5aeb572e51ebd0732e6ebf80197acb3e577" +} diff --git a/backend/tests/app_s3_onbehalf.rs b/backend/tests/app_s3_onbehalf.rs index 80c8d5a5f7..2e1085edd3 100644 --- a/backend/tests/app_s3_onbehalf.rs +++ b/backend/tests/app_s3_onbehalf.rs @@ -143,3 +143,379 @@ async fn test_deployed_app_s3_onbehalf_provenance(db: Pool) -> anyhow: Ok(()) } + +/// Seed a completed job whose result carries an s3 object. `app_trigger` sets the +/// app-origination marker exactly as `execute_component` stamps it: `Some(app_path)` +/// => `trigger_kind = 'app'` + `trigger = ` (an app-launched run); +/// `None` => an ordinary direct `/jobs/run` (no app marker). `created_by` is the user +/// the job ran as (the isolation key the gate confines downloads to). +async fn seed_completed_job( + db: &Pool, + created_by: &str, + app_trigger: Option<&str>, + s3_key: &str, +) -> anyhow::Result<()> { + let result = format!(r#"{{"s3":"{s3_key}"}}"#); + sqlx::query( + r#" + WITH j AS ( + INSERT INTO v2_job (id, workspace_id, kind, runnable_path, created_by, + permissioned_as, trigger_kind, trigger) + VALUES (gen_random_uuid(), 'test-workspace', 'script', 'u/test-user/query_to_s3', + $1, 'u/test-user', + CASE WHEN $2::text IS NULL THEN NULL ELSE 'app'::job_trigger_kind END, $2) + RETURNING id + ) + INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, result, started_at) + SELECT id, 'test-workspace', 1, 'success', $3::jsonb, now() FROM j + "#, + ) + .bind(created_by) + .bind(app_trigger) + .bind(&result) + .execute(db) + .await?; + Ok(()) +} + +/// A deployed app that renders S3 files it produced (e.g. a SQL query persisted to +/// S3 by a component) must clear the provenance gate for the viewer whose own app +/// run produced them, while (a) a viewer cannot forge provenance by running a +/// runnable directly (no app marker), (b) another app's outputs stay denied, and +/// (c) another viewer's outputs stay denied (cross-viewer isolation). Provenance is +/// keyed on the app-origination marker (`trigger_kind='app'` + `trigger=`) +/// that `execute_component` stamps, plus `created_by = ` for isolation. +#[sqlx::test(fixtures("base"))] +async fn test_deployed_app_s3_onbehalf_flow_script_provenance( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + const FS_APP: &str = "u/test-user/s3flowscript"; + const OTHER_APP: &str = "u/test-user/other_app"; + // Produced by test-user-2's own app run of THIS app. + const USER_KEY: &str = "results/user2_output.parquet"; + // Produced by test-user's own app run of THIS app. + const ADMIN_KEY: &str = "results/admin_output.parquet"; + // Produced by an app run of a DIFFERENT app → must stay denied. + const OTHER_APP_KEY: &str = "results/other_app_output.parquet"; + // Produced by a DIRECT run (no app marker) → the forgery attempt, must stay denied. + const FORGED_KEY: &str = "results/author_only_secret.parquet"; + + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": FS_APP, + "summary": "s3 app-origination provenance test", + "value": {}, + "policy": { "execution_mode": "anonymous", "triggerables": {} } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?); + + // Seed the produced-file jobs (all within the 3h window). + seed_completed_job(&db, "test-user-2", Some(FS_APP), USER_KEY).await?; + seed_completed_job(&db, "test-user", Some(FS_APP), ADMIN_KEY).await?; + seed_completed_job(&db, "test-user-2", Some(OTHER_APP), OTHER_APP_KEY).await?; + seed_completed_job(&db, "test-user-2", None, FORGED_KEY).await?; + + let get = |route: &str, token: &'static str| { + let url = format!("{ws}/apps_u/{route}"); + authed(client().get(url), token).send() + }; + let denied = |body: &str| body.contains("File restricted"); + let body_of = |route: String, token: &'static str| async move { + get(&route, token).await.unwrap().text().await.unwrap() + }; + + // The viewer's own app run's output clears the gate (the case that regressed to + // "File restricted"). + let body = body_of( + format!("download_s3_file/{FS_APP}?s3={USER_KEY}"), + USER_TOKEN, + ) + .await; + assert!( + !denied(&body), + "viewer's own app-produced key must clear the gate: {body}" + ); + + // The admin viewer's own app run's output clears — the gate has no admin bypass, + // it just matches the caller's own runs. + let body = body_of( + format!("download_s3_file/{FS_APP}?s3={ADMIN_KEY}"), + ADMIN_TOKEN, + ) + .await; + assert!( + !denied(&body), + "admin's own app-produced key must clear the gate: {body}" + ); + + // Cross-viewer isolation: the admin cannot pull test-user-2's result even though + // it is a genuine app-marked job of the same app (no admin bypass either). + let body = body_of( + format!("download_s3_file/{FS_APP}?s3={USER_KEY}"), + ADMIN_TOKEN, + ) + .await; + assert!( + denied(&body), + "another viewer's app-produced key must stay denied (isolation): {body}" + ); + + // A key produced by a direct run (no app marker) stays denied — the forgery the + // app-origination marker closes. + let body = body_of( + format!("download_s3_file/{FS_APP}?s3={FORGED_KEY}"), + USER_TOKEN, + ) + .await; + assert!( + denied(&body), + "key from a direct run (no app marker) must stay denied: {body}" + ); + + // A key produced by a DIFFERENT app stays denied — provenance is scoped to THIS + // app's path. + let body = body_of( + format!("download_s3_file/{FS_APP}?s3={OTHER_APP_KEY}"), + USER_TOKEN, + ) + .await; + assert!( + denied(&body), + "key produced by a different app must stay denied: {body}" + ); + + Ok(()) +} + +/// Seed a minimal deployed script so `execute_component` can resolve `script/`. +/// The script is given its OWN `on_behalf_of` (created_by test-user-2), distinct from +/// any app author, so a test can assert an app component runs as the app's identity, +/// not the referenced script's on_behalf. +async fn seed_script(db: &Pool, path: &str, content: &str) -> anyhow::Result<()> { + let mut h = 0i64; + for b in path.bytes().chain(content.bytes()) { + h = h.wrapping_mul(31).wrapping_add(b as i64); + } + sqlx::query( + r#"INSERT INTO script (workspace_id, hash, path, summary, description, content, + created_by, on_behalf_of_email, language, tag, lock) + VALUES ('test-workspace', $1, $2, '', '', $3, 'test-user-2', 'test2@windmill.dev', + 'deno'::script_lang, 'deno', '') + ON CONFLICT DO NOTHING"#, + ) + .bind(h) + .bind(path) + .bind(content) + .execute(db) + .await?; + // #[sqlx::test] isolated DBs share one workspace id and reuse script paths; the + // process-global deployed-script cache is keyed by (workspace, path), so disable + // it here so `execute_component` resolves against this test's own DB. + windmill_common::DEPLOYED_SCRIPT_CACHE_DISABLED + .store(true, std::sync::atomic::Ordering::Relaxed); + Ok(()) +} + +/// End-to-end: `execute_component` must stamp the job it enqueues with +/// `trigger_kind = 'app'` + `trigger = `. This is the marker the S3 +/// provenance gate relies on; the gate tests seed it directly, so this test proves +/// the runtime actually produces it. `execute_component` commits the job row and +/// returns its id, so we assert on the row without needing a worker to run it. +#[sqlx::test(fixtures("base"))] +async fn test_execute_component_stamps_app_trigger(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + const APP_PATH: &str = "u/test-user/trigger_marker_app"; + const SCRIPT_PATH: &str = "u/test-user/query_to_s3"; + + seed_script(&db, SCRIPT_PATH, "export function main() { return 1 }").await?; + + // Anonymous app wired to run the deployed script; keys use the production + // component-prefixed triggerable form. + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": APP_PATH, + "summary": "trigger marker test", + "value": {}, + "policy": { + "execution_mode": "anonymous", + "triggerables_v2": { + format!("comp1:script/{SCRIPT_PATH}"): { "static_inputs": {}, "one_of_inputs": {} } + } + } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?); + + // Run the script component through the app runtime. + let resp = authed( + client().post(format!("{ws}/apps_u/execute_component/{APP_PATH}")), + ADMIN_TOKEN, + ) + .json(&json!({ + "component": "comp1", + "path": format!("script/{SCRIPT_PATH}"), + "args": {} + })) + .send() + .await?; + let status = resp.status(); + let job_id = resp.text().await?; + assert_eq!(status, 200, "execute_component: {job_id}"); + let job_id = job_id.trim().trim_matches('"'); + + // The enqueued job must carry the app-origination marker (trigger_kind = 'app', + // trigger = the app path, NOT the runnable path) and must run on-behalf of the + // APP's identity (u/test-user, the anonymous app's author), NOT the referenced + // script's own on_behalf (u/test-user-2). + let (trigger_kind, trigger, permissioned_as): (Option, Option, String) = + sqlx::query_as( + "SELECT trigger_kind::text, trigger, permissioned_as FROM v2_job \ + WHERE id = $1::uuid AND workspace_id = 'test-workspace'", + ) + .bind(job_id) + .fetch_one(&db) + .await?; + + assert_eq!( + trigger_kind.as_deref(), + Some("app"), + "execute_component must stamp trigger_kind = 'app' (got {trigger_kind:?})" + ); + assert_eq!( + trigger.as_deref(), + Some(APP_PATH), + "trigger must be the app path, not the runnable path (got {trigger:?})" + ); + assert_eq!( + permissioned_as, "u/test-user", + "component must run on-behalf of the APP identity, not the referenced script's on_behalf (got {permissioned_as})" + ); + + Ok(()) +} + +/// `JobTriggerKind::App` (added for the app-origination S3 marker) is now a valid +/// value for the suspended-trigger reassignment routes, but there is no +/// `app_trigger` table. The handler must reject it with a clean 400 rather than +/// failing on a missing-relation database error (500). +#[sqlx::test(fixtures("base"))] +async fn test_app_trigger_kind_rejected_for_reassignment(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + let resp = authed( + client().post(format!( + "{ws}/trigger/app/resume_suspended_trigger_jobs/u/test-user/x" + )), + ADMIN_TOKEN, + ) + .json(&json!({})) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "app reassignment must be a clean 400, not 500: {body}" + ); + assert!( + body.contains("do not support job reassignment"), + "expected reassignment-unsupported message, got: {body}" + ); + + Ok(()) +} + +/// A preview run is NEVER app-provenanced. Preview executes as the *caller* (Viewer +/// mode), so its results are read back as the caller via the viewer-scoped +/// job_helpers endpoint — never author-mode. Marking a preview would let any +/// `jobs:run` caller supply arbitrary `raw_code` against a victim app path and forge +/// the marker the S3 gate trusts; and it is never needed. Even the app owner's own +/// preview stays unmarked. +#[sqlx::test(fixtures("base"))] +async fn test_preview_is_not_app_provenanced(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let ws = format!("http://localhost:{port}/api/w/test-workspace"); + + const APP: &str = "u/test-user/preview_app"; + let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) + .json(&json!({ + "path": APP, + "summary": "preview marker test", + "value": {}, + "policy": { "execution_mode": "anonymous", "triggerables": {} } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "app create: {}", resp.text().await?); + + // Preview arbitrary inline code against the app (force_viewer_static_fields => + // preview mode; raw_code with no path/id skips all app authorization), as `token`. + let preview_trigger_kind = |token: &'static str| { + let ws = ws.clone(); + let db = db.clone(); + async move { + let resp = authed( + client().post(format!("{ws}/apps_u/execute_component/{APP}")), + token, + ) + .json(&json!({ + "component": "comp1", + "raw_code": { "content": "export function main() { return 1 }", "language": "deno" }, + "force_viewer_static_fields": {}, + "args": {} + })) + .send() + .await + .unwrap(); + let status = resp.status(); + let job_id = resp.text().await.unwrap(); + assert_eq!(status, 200, "preview execute_component: {job_id}"); + let job_id = job_id.trim().trim_matches('"').to_string(); + let trigger_kind: Option = sqlx::query_scalar( + "SELECT trigger_kind::text FROM v2_job WHERE id = $1::uuid AND workspace_id = 'test-workspace'", + ) + .bind(job_id) + .fetch_one(&db) + .await + .unwrap(); + trigger_kind + } + }; + + // The app owner (test-user, admin) previewing their own app → still NOT marked. + let trigger_kind = preview_trigger_kind(ADMIN_TOKEN).await; + assert_eq!( + trigger_kind, None, + "the app owner's own preview must NOT be app-provenanced (got {trigger_kind:?})" + ); + + // A non-editor (test-user-2) previewing a victim app → NOT marked. + let trigger_kind = preview_trigger_kind(USER_TOKEN).await; + assert_eq!( + trigger_kind, None, + "a non-editor's preview must NOT be app-provenanced (got {trigger_kind:?})" + ); + + Ok(()) +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2045ae34b3..c4e68526fa 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -26282,6 +26282,7 @@ components: - github - asset - freshness + - app TriggerMode: description: job trigger mode diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 5889f17891..596d28ed64 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -62,8 +62,9 @@ use windmill_common::{ error::{to_anyhow, Error, JsonResult, Result}, jobs::{ get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion, - JobPayload, RawCode, + JobPayload, JobTriggerKind, RawCode, }, + triggers::TriggerMetadata, user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, utils::{ @@ -3134,23 +3135,26 @@ async fn execute_component( } .filter(|t| !t.is_empty()) }; - let (job_payload, tag, on_behalf_of) = match (payload.path, payload.raw_code, payload.id) { - // flow or script: - (Some(path), None, None) => get_payload_tag_from_prefixed_path(&path, &db, &w_id).await?, - // inline script: "preview" mode, or run mode without an entry in the - // `app_script` table (legacy `rawscript/`-keyed triggerables). - (None, Some(raw_code), None) => { - let tag = resolved_inline_tag(raw_code.tag.clone()); - (JobPayload::Code(raw_code), tag, None) - } - // inline script: run mode (deployed app) with an entry in `app_script`. - (None, Some(RawCode { language, path, cache_ttl, tag, .. }), Some(id)) => ( - JobPayload::AppScript { id: AppScriptId(id), cache_ttl, language, path }, - resolved_inline_tag(tag), - None, - ), - _ => unreachable!(), - }; + let (job_payload, tag, _runnable_on_behalf_of) = + match (payload.path, payload.raw_code, payload.id) { + // flow or script: + (Some(path), None, None) => { + get_payload_tag_from_prefixed_path(&path, &db, &w_id).await? + } + // inline script: "preview" mode, or run mode without an entry in the + // `app_script` table (legacy `rawscript/`-keyed triggerables). + (None, Some(raw_code), None) => { + let tag = resolved_inline_tag(raw_code.tag.clone()); + (JobPayload::Code(raw_code), tag, None) + } + // inline script: run mode (deployed app) with an entry in `app_script`. + (None, Some(RawCode { language, path, cache_ttl, tag, .. }), Some(id)) => ( + JobPayload::AppScript { id: AppScriptId(id), cache_ttl, language, path }, + resolved_inline_tag(tag), + None, + ), + _ => unreachable!(), + }; // Preview honors the client-supplied inline tag (`resolved_inline_tag`), so // — like `/jobs/run/preview` — confine it to worker tags the caller may use // (a `if_jobs:filter_tags`-restricted token must not escape its filter). @@ -3167,18 +3171,22 @@ async fn execute_component( // and would add unnecessary breakage risk to the legitimate editor flow. let tx = PushIsolationLevel::IsolatedRoot(db.clone()); - let (email, permissioned_as) = if let Some(on_behalf_of) = on_behalf_of.as_ref() { - ( - on_behalf_of.email.as_str(), - on_behalf_of.permissioned_as.clone(), - ) - } else { - (email.as_str(), permissioned_as) - }; + // An app component runs on-behalf of the APP identity (resolved above), never + // the referenced runnable's own `on_behalf_of` — else a Viewer-mode app could + // execute as that identity and a preview would run as it, not the caller. + // (Direct `/jobs/run` still honors a runnable's `on_behalf_of`.) + let (email, permissioned_as) = (email.as_str(), permissioned_as); let end_user_email = get_end_user_email(&db, opt_authed.as_ref(), tokened.token.as_deref()).await; + // Stamp app-origination (trigger_kind='app' + trigger=), the signal + // the deployed-app S3 provenance gate trusts (unforgeable via `/jobs/run`). + // Deployed runs only: a preview runs as the caller and is read back as the caller + // (viewer-scoped), so it must never be app-provenanced (else it could forge one). + let app_trigger = + (!is_preview).then(|| TriggerMetadata::new(Some(path.to_string()), JobTriggerKind::App)); + let (uuid, mut tx) = push( &db, tx, @@ -3209,7 +3217,7 @@ async fn execute_component( None, false, end_user_email, - None, + app_trigger, None, ) .await?; @@ -3867,10 +3875,12 @@ async fn check_if_allowed_to_access_s3_file_from_app( // tokens are excluded (untrusted app JS stays confined below). Ok(()) } else { - // Author-mode (Anonymous/Publisher) or embed token: confine to the app's - // declared keys or files it recently produced. Without this gate a - // logged-in viewer could launder the author's S3 perms via an arbitrary - // file_key (confused deputy). Producing identity = caller else `anonymous`. + // Author-mode/embed: confine reads to the app's declared keys or files THIS + // app produced, else a viewer could launder the author's S3 perms via an + // arbitrary file_key (confused deputy). Provenance is the un-forgeable + // app-origination marker (`trigger_kind='app'` + `trigger=`); + // `created_by=` is ANDed only as a per-viewer isolation filter (it + // can narrow — one viewer can't read another's result — never forge). let creator = opt_authed .as_ref() .map(|authed| authed.username.clone()) @@ -3883,11 +3893,11 @@ async fn check_if_allowed_to_access_s3_file_from_app( r#"SELECT EXISTS ( SELECT 1 FROM v2_job_completed c JOIN v2_job j USING (id) WHERE j.workspace_id = $2 - AND (j.kind = 'appscript' OR j.kind = 'preview') - AND j.created_by = $4 AND c.started_at > now() - interval '3 hours' - AND j.runnable_path LIKE $3 || '/%' AND c.result @> ('{"s3":"' || $1 || '"}')::jsonb + AND j.trigger_kind = 'app' + AND j.trigger = $3 + AND j.created_by = $4 )"#, file_query.s3, w_id, diff --git a/backend/windmill-trigger/src/global_handler.rs b/backend/windmill-trigger/src/global_handler.rs index 98b689dda9..00727eac68 100644 --- a/backend/windmill-trigger/src/global_handler.rs +++ b/backend/windmill-trigger/src/global_handler.rs @@ -36,8 +36,11 @@ async fn get_suspended_trigger( trigger_kind: &JobTriggerKind, path: &str, ) -> Result { + // Only trigger kinds backed by a `_trigger` table support reassignment. + // `app` (and webhook/schedule) have no such table, so reject them with a clear + // error instead of failing on a missing-relation database error below. match trigger_kind { - JobTriggerKind::Webhook | JobTriggerKind::Schedule => { + JobTriggerKind::Webhook | JobTriggerKind::Schedule | JobTriggerKind::App => { return Err(Error::BadRequest(format!( "{} triggers do not support job reassignment", trigger_kind diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index f9372b949d..95ac69bc2c 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -49,6 +49,11 @@ pub enum JobTriggerKind { // A run pushed by the pipeline freshness watchdog (EE) because the // script's `// freshness` window elapsed without a successful run. Freshness, + // A run launched by a deployed app's runtime (`execute_component`). `trigger` + // carries the app path. This is the authoritative app-origination marker: a + // direct `/jobs/run` cannot set it, so it distinguishes files an app actually + // produced from files a viewer forged by running a declared runnable directly. + App, } impl std::fmt::Display for JobTriggerKind { @@ -72,6 +77,7 @@ impl std::fmt::Display for JobTriggerKind { JobTriggerKind::CiTest => "ci_test", JobTriggerKind::Asset => "asset", JobTriggerKind::Freshness => "freshness", + JobTriggerKind::App => "app", }; write!(f, "{}", kind) } diff --git a/frontend/src/lib/components/apps/editor/appUtilsS3.ts b/frontend/src/lib/components/apps/editor/appUtilsS3.ts index f9811edd76..15353382cc 100644 --- a/frontend/src/lib/components/apps/editor/appUtilsS3.ts +++ b/frontend/src/lib/components/apps/editor/appUtilsS3.ts @@ -90,20 +90,6 @@ export function isPartialS3Object( return input != undefined && typeof input === 'object' && typeof input['s3'] === 'string' } -function computeForceViewerPolicies({ - isEditor, - configuration -}: { - isEditor: boolean - configuration: RichConfigurations -}) { - if (!isEditor) { - return undefined - } - const policy = computeS3FileViewerPolicy(configuration) - return policy -} - export async function getS3File({ source, storage, @@ -112,8 +98,7 @@ export async function getS3File({ username, workspace, token, - isEditor, - configuration + isEditor }: { source: string | undefined storage?: string @@ -123,23 +108,39 @@ export async function getS3File({ workspace: string token: string | undefined isEditor: boolean - configuration: RichConfigurations + // Optional; not read here. Editor reads go through the viewer-scoped endpoint + // and deployed reads through the app-scoped one, independent of the component + // configuration. + configuration?: RichConfigurations }) { if (!source) return '' + + // Editor/preview runs execute as the *caller* (Viewer mode), so read their + // results back as the caller through the viewer-scoped `job_helpers` endpoint — + // never author-mode — consistent with DisplayResult/ParqetCsvTableRenderer. Only + // a deployed app view reads on-behalf of the author via the provenance-gated + // `apps_u` endpoint. + if (isEditor) { + const params = new URLSearchParams() + params.append('file_key', source) + if (storage) { + params.append('storage', storage) + } + if (token && token != '') { + params.append('token', token) + } + return `/api/w/${workspace}/job_helpers/download_s3_file?${params.toString()}${presigned ? `&${presigned}` : ''}` + } + const appPathOrUser = defaultIfEmptyString(appPath, `u/${username ?? 'unknown'}/newapp`) const params = new URLSearchParams() params.append('s3', source) if (storage) { params.append('storage', storage) } - if (token && token != '') { params.append('token', token) } - const forceViewerPolicies = computeForceViewerPolicies({ isEditor, configuration }) - if (forceViewerPolicies) { - params.append('force_viewer_allowed_s3_keys', JSON.stringify([forceViewerPolicies])) - } return `/api/w/${workspace}/apps_u/download_s3_file/${appPathOrUser}?${params.toString()}${presigned ? `&${presigned}` : ''}` } diff --git a/frontend/src/lib/components/triggers/utils.ts b/frontend/src/lib/components/triggers/utils.ts index 3d17491791..d8066eb18a 100644 --- a/frontend/src/lib/components/triggers/utils.ts +++ b/frontend/src/lib/components/triggers/utils.ts @@ -7,7 +7,8 @@ import { Database, Terminal, Timer, - Zap + Zap, + LayoutDashboard } from 'lucide-svelte' import KafkaIcon from '$lib/components/icons/KafkaIcon.svelte' import NatsIcon from '$lib/components/icons/NatsIcon.svelte' @@ -93,7 +94,8 @@ export const jobTriggerKinds: JobTriggerKind[] = [ 'google', 'github', 'asset', - 'freshness' + 'freshness', + 'app' ] export type Trigger = { @@ -131,10 +133,12 @@ export const triggerIconMap = { google: GoogleIcon, github: GithubIcon, // Job-attribution-only kinds (no trigger CRUD page): the pipeline asset - // cascade and the freshness watchdog. Needed so the Runs filter and job - // detail render these trigger kinds instead of a blank label / no icon. + // cascade, the freshness watchdog, and app-component runs. Needed so the Runs + // filter and job detail render these trigger kinds instead of a blank label / + // no icon. asset: Zap, - freshness: Timer + freshness: Timer, + app: LayoutDashboard } export const triggerDisplayNamesMap = { @@ -157,10 +161,11 @@ export const triggerDisplayNamesMap = { google: 'Google', github: 'GitHub', asset: 'Asset cascade', - freshness: 'Freshness' - // `asset` / `freshness` are job-attribution-only (JobTriggerKind, not + freshness: 'Freshness', + app: 'App' + // `asset` / `freshness` / `app` are job-attribution-only (JobTriggerKind, not // TriggerType) — hence the union in the satisfies below. -} as const satisfies Record +} as const satisfies Record /** * Converts a TriggerType to a CaptureTriggerKind when a mapping exists From 4f65187f9e647ec78ebf9a4c63ec11b7a19899ec Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 14 Jul 2026 10:46:55 +0200 Subject: [PATCH 38/52] chore(main): release 1.756.1 (#10072) * chore(main): release 1.756.1 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 7 + backend/Cargo.lock | 252 +++++++++--------- 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, 173 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0dc63ad42..6614223d65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.756.1](https://github.com/windmill-labs/windmill/compare/v1.756.0...v1.756.1) (2026-07-14) + + +### Bug Fixes + +* **apps:** cover script/flow component outputs in deployed-app S3 provenance gate ([#10070](https://github.com/windmill-labs/windmill/issues/10070)) ([710a13a](https://github.com/windmill-labs/windmill/commit/710a13a59d511614839f70e445db736e436550d7)) + ## [1.756.0](https://github.com/windmill-labs/windmill/compare/v1.755.0...v1.756.0) (2026-07-12) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9ae2df8c80..c0f443e802 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -815,7 +815,7 @@ dependencies = [ "bytes-utils", "fastrand", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "percent-encoding", "pin-project-lite", "tracing", @@ -1119,7 +1119,7 @@ dependencies = [ "futures-core", "futures-util", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "percent-encoding", "pin-project-lite", @@ -1211,7 +1211,7 @@ dependencies = [ "http 0.2.12", "http 1.4.2", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "pin-project-lite", "pin-utils", @@ -1249,7 +1249,7 @@ dependencies = [ "http 0.2.12", "http 1.4.2", "http-body 0.4.6", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "itoa", "num-integer", @@ -1306,7 +1306,7 @@ dependencies = [ "bytes", "futures-util", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "itoa", "matchit 0.7.3", @@ -1334,7 +1334,7 @@ dependencies = [ "form_urlencoded", "futures-util", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "hyper 1.10.1", "hyper-util", @@ -1367,7 +1367,7 @@ dependencies = [ "bytes", "futures-util", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", @@ -1386,7 +1386,7 @@ dependencies = [ "bytes", "futures-core", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", @@ -4622,7 +4622,7 @@ dependencies = [ "futures-core", "futures-sink", "nanorand", - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -5236,9 +5236,9 @@ dependencies = [ [[package]] name = "gosyn" -version = "0.2.11" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fed1657682b1c3f63ece1fe5b60fc6c5f5923612a20d19f6af38ce79eaf361e3" +checksum = "938fc2d49f0620e342cf316c6775a1c6722124755cbaab3211cd3dcdce6157c6" dependencies = [ "anyhow", "strum", @@ -5599,9 +5599,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http 1.4.2", @@ -5609,14 +5609,14 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "pin-project-lite", ] @@ -5703,7 +5703,7 @@ dependencies = [ "futures-core", "h2 0.4.15", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "httparse", "httpdate", "itoa", @@ -5858,13 +5858,13 @@ dependencies = [ "futures-channel", "futures-util", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "hyper 1.10.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.6.5", "system-configuration", "tokio", "tower-service", @@ -6103,7 +6103,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.4", + "socket2 0.6.5", "widestring", "windows-registry", "windows-result 0.4.1", @@ -6401,7 +6401,7 @@ dependencies = [ "futures", "home", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "hyper 1.10.1", "hyper-http-proxy", @@ -6497,7 +6497,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -7137,9 +7137,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -7201,7 +7201,7 @@ dependencies = [ "httparse", "memchr", "mime", - "spin 0.9.8", + "spin 0.9.9", "version_check", ] @@ -7250,7 +7250,7 @@ dependencies = [ "percent-encoding", "rand 0.10.2", "serde", - "socket2 0.6.4", + "socket2 0.6.5", "thiserror 2.0.18", "tokio", "tokio-native-tls", @@ -8986,7 +8986,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.3", "rustls 0.23.35", - "socket2 0.6.4", + "socket2 0.6.5", "thiserror 2.0.18", "tokio", "tracing", @@ -9025,7 +9025,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -9441,7 +9441,7 @@ dependencies = [ "futures-util", "h2 0.4.15", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "hyper 1.10.1", "hyper-rustls 0.27.9", @@ -9489,7 +9489,7 @@ dependencies = [ "futures-util", "h2 0.4.15", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "hyper 1.10.1", "hyper-rustls 0.27.9", @@ -9653,7 +9653,7 @@ dependencies = [ "chrono", "futures", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "oauth2", "pastey", @@ -10869,9 +10869,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -10914,9 +10914,9 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -11196,7 +11196,7 @@ checksum = "39f24a9b78c40b90817bbcd1821c74ddfd74916aadd29403d001532a9195532d" dependencies = [ "bytes", "futures-util", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "pin-project-lite", ] @@ -12383,7 +12383,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.9.0", - "socket2 0.6.4", + "socket2 0.6.5", "tokio", "tokio-util", "whoami", @@ -12587,7 +12587,7 @@ dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -12596,7 +12596,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -12613,7 +12613,7 @@ dependencies = [ "flate2", "h2 0.4.15", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "hyper 1.10.1", "hyper-timeout", @@ -12645,7 +12645,7 @@ dependencies = [ "bytes", "h2 0.4.15", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "hyper 1.10.1", "hyper-timeout", @@ -12732,7 +12732,7 @@ dependencies = [ "futures-core", "futures-util", "http 1.4.2", - "http-body 1.0.1", + "http-body 1.1.0", "http-body-util", "mime", "pin-project-lite", @@ -13350,9 +13350,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.23.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "ea5fab0d6c3c01ae70085a09cb03d4c7a1d6314e2b3e075392783396d724ca0a" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -13745,7 +13745,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-nats", @@ -13827,7 +13827,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.756.0" +version = "1.756.1" dependencies = [ "async-stream", "async-trait", @@ -13860,7 +13860,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -13873,7 +13873,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "argon2", @@ -14011,7 +14011,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14034,7 +14034,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14049,7 +14049,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14075,7 +14075,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.756.0" +version = "1.756.1" dependencies = [ "reqwest 0.12.28", "serde", @@ -14085,7 +14085,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14102,7 +14102,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14124,7 +14124,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14147,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14163,7 +14163,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14184,7 +14184,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14219,7 +14219,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-nats", @@ -14254,7 +14254,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14279,7 +14279,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "flate2", @@ -14297,7 +14297,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14319,7 +14319,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14339,7 +14339,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14376,7 +14376,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14404,7 +14404,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.756.0" +version = "1.756.1" dependencies = [ "lazy_static", "serde", @@ -14416,7 +14416,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.756.0" +version = "1.756.1" dependencies = [ "argon2", "axum 0.8.9", @@ -14441,7 +14441,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14455,7 +14455,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.756.0" +version = "1.756.1" dependencies = [ "axum 0.8.9", "chrono", @@ -14490,7 +14490,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.756.0" +version = "1.756.1" dependencies = [ "chrono", "lazy_static", @@ -14504,7 +14504,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "axum 0.8.9", @@ -14523,7 +14523,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.756.0" +version = "1.756.1" dependencies = [ "aes-gcm", "aho-corasick", @@ -14625,7 +14625,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.756.0" +version = "1.756.1" dependencies = [ "chrono", "itertools 0.14.0", @@ -14644,7 +14644,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.756.0" +version = "1.756.1" dependencies = [ "regex", "serde", @@ -14659,7 +14659,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14683,7 +14683,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "futures", @@ -14700,7 +14700,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.756.0" +version = "1.756.1" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14716,7 +14716,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -14737,7 +14737,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "arc-swap", @@ -14793,7 +14793,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-stream", @@ -14827,7 +14827,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "futures", @@ -14845,7 +14845,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.756.0" +version = "1.756.1" dependencies = [ "convert_case 0.6.0", "serde", @@ -14854,7 +14854,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -14866,7 +14866,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde_json", @@ -14878,7 +14878,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "gosyn", @@ -14890,7 +14890,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -14902,7 +14902,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde_json", @@ -14914,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "nu-parser", @@ -14925,7 +14925,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14936,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14948,7 +14948,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "rustpython-ast", @@ -14959,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-recursion", @@ -14981,7 +14981,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde_json", @@ -14993,7 +14993,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -15007,7 +15007,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -15037,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde", @@ -15049,7 +15049,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15083,7 +15083,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "rustpython-ast", @@ -15099,7 +15099,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde", @@ -15110,7 +15110,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-recursion", @@ -15149,7 +15149,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "const_format", @@ -15188,7 +15188,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.756.0" +version = "1.756.1" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15199,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-recursion", @@ -15233,7 +15233,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15290,7 +15290,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15323,7 +15323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15343,7 +15343,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15377,7 +15377,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15413,7 +15413,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15436,7 +15436,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-nats", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15547,7 +15547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-trait", @@ -15572,7 +15572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15591,7 +15591,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-once-cell", @@ -15701,7 +15701,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.756.0" +version = "1.756.1" dependencies = [ "bytes", "futures", @@ -16301,9 +16301,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -16525,9 +16525,9 @@ checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ab4d8bf345..20f530a8fb 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.756.0" +version = "1.756.1" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.756.0" +version = "1.756.1" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index ae54e3f521..b3ac94a3d3 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.756.0" +version = "1.756.1" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.756.0" +version = "1.756.1" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.756.0" +version = "1.756.1" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.756.0" +version = "1.756.1" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index edf76878e7..243859434c 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.756.0" +version = "1.756.1" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c4e68526fa..32ce02b752 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.756.0 + version: 1.756.1 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 57f64026b7..ad2e9075d9 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.756.0"; +export const VERSION = "v1.756.1"; 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 7d27cfade7..c751507f74 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.756.0"; +export const VERSION = "1.756.1"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 90db619840..09b58d699d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.756.0", + "version": "1.756.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.756.0", + "version": "1.756.1", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 0c94286c6f..e910c51432 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.756.0", + "version": "1.756.1", "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 2fb02de9da..f3dbdd423b 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.756.0" +wmill = ">=1.756.1" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index dd45a739db..8de4505a53 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.756.0 + version: 1.756.1 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 8d5f84ca1b..403df81817 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.756.0' + ModuleVersion = '1.756.1' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index d5eddeadf1..b7630b33b3 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.756.0" +version = "1.756.1" 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 1cf80c83bf..661dbdac38 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.756.0", + "version": "1.756.1", "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 2103036c3b..c66de3c5b6 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.756.0", + "version": "1.756.1", "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 222bd3fdeb..1754b79a18 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.756.0 +1.756.1 From 207ce8649cf7026c62eea2b1b2f462c7df8c4e5a Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 14 Jul 2026 12:12:00 +0200 Subject: [PATCH 39/52] fix(ai-agent): don't mark repeated tool calls as failed in flow graph (#10075) * fix(ai-agent): don't mark repeated tool calls as failed in flow graph Co-Authored-By: Claude Opus 4.8 (1M context) * test(ai-agent): cover reporter's mixed repeated-tool-call scenario Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../graph/renderers/nodes/AIToolNode.svelte | 7 +- .../graph/renderers/nodes/AIToolNode.test.ts | 100 ++++++++++++++++++ 2 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte index 5fd064c033..b6ff05c193 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte @@ -160,7 +160,12 @@ data: { tool: tool.name, type: tool.type, - nameError: getToolNameError(tool.name, tool.type, siblingNames), + // agentActions are runtime tool calls: the same tool called multiple times + // yields duplicate names, which is expected and must not read as a Failure. + // Only validate names in the editor, where they define the static tool set. + nameError: agentActions + ? undefined + : getToolNameError(tool.name, tool.type, siblingNames), eventHandlers, moduleId: tool.id, insertable, diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts new file mode 100644 index 0000000000..cdc6fa3f3b --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock the component wrapper so importing the .svelte module doesn't pull in the +// full render-time dependency graph. +vi.mock('./NodeWrapper.svelte', () => ({ default: {} })) + +import { computeAIToolNodes } from './AIToolNode.svelte' + +const eventHandlers = {} as any + +function aiAgentNode(id: string, tools: any[]): any { + return { + id, + type: 'module', + position: { x: 0, y: 0 }, + data: { module: { id, value: { type: 'aiagent', tools } } } + } +} + +describe('computeAIToolNodes', () => { + it('does not flag duplicate names when the same tool is called multiple times at runtime', () => { + // One statically-defined tool that the agent called twice. The runtime + // agent_actions therefore carry the same function_name twice — this is + // expected and must not surface as a `nameError` (which renders as Failure). + const node = aiAgentNode('agent', [ + { id: 'tool_a', summary: 'my_tool', value: { tool_type: 'flowmodule', type: 'script' } } + ]) + const flowModuleStates = { + agent: { + type: 'Success', + agent_actions: [ + { type: 'tool_call', function_name: 'my_tool', module_id: 'tool_a', job_id: 'j1' }, + { type: 'tool_call', function_name: 'my_tool', module_id: 'tool_a', job_id: 'j2' } + ] + } + } as any + + const { toolNodes } = computeAIToolNodes([node], eventHandlers, false, flowModuleStates) + + expect(toolNodes.length).toBe(2) + for (const n of toolNodes) { + expect((n.data as any).nameError).toBeUndefined() + } + }) + + it('does not flag any node in a mixed run where one tool repeats (reporter scenario)', () => { + // Repo-intel run: query_stored called 3x plus two single calls, all succeeded. + // Before the fix the three query_stored nodes rendered red (Failure) purely + // from the duplicate-name check, while the unique tools stayed green. + const node = aiAgentNode('chat', [ + { id: 'q', summary: 'query_stored', value: { tool_type: 'flowmodule', type: 'script' } }, + { id: 'h', summary: 'hybrid_search', value: { tool_type: 'flowmodule', type: 'script' } }, + { + id: 't', + summary: 'trace_outbound_calls', + value: { tool_type: 'flowmodule', type: 'script' } + } + ]) + const call = (name: string, job: string) => ({ + type: 'tool_call', + function_name: name, + module_id: name[0], + job_id: job + }) + const flowModuleStates = { + chat: { + type: 'Success', + agent_actions: [ + call('query_stored', 'j1'), + call('query_stored', 'j2'), + call('query_stored', 'j3'), + call('hybrid_search', 'j4'), + call('trace_outbound_calls', 'j5') + ] + } + } as any + + const { toolNodes } = computeAIToolNodes([node], eventHandlers, false, flowModuleStates) + + expect(toolNodes.length).toBe(5) + for (const n of toolNodes) { + expect((n.data as any).nameError).toBeUndefined() + } + }) + + it('still flags genuinely duplicate tool names in the editor (static tool set)', () => { + const node = aiAgentNode('agent2', [ + { id: 't1', summary: 'dup', value: { tool_type: 'flowmodule', type: 'script' } }, + { id: 't2', summary: 'dup', value: { tool_type: 'flowmodule', type: 'script' } } + ]) + + const { toolNodes } = computeAIToolNodes([node], eventHandlers, true, undefined) + + const toolCallNodes = toolNodes.filter((n) => n.type === 'aiTool') + expect(toolCallNodes.length).toBe(2) + for (const n of toolCallNodes) { + expect((n.data as any).nameError).toBe('Duplicate tool name') + } + }) +}) From b4c834f3cd80213e45d0f88dbef797a1d9cc3459 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 14 Jul 2026 12:16:49 +0200 Subject: [PATCH 40/52] ci: run Codex/Pi review on fork PRs when a maintainer triggers it (#10069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: run Codex review on fork PRs when a maintainer triggers it The fork skip in codex-pr-review.yml unconditionally bailed on cross-repository PRs, so even a maintainer's /codex or /review comment (routed through pr-review-commands.yml via workflow_call, gated by check-write-access) skipped external PRs. Gate the skip on the automatic pull_request trigger only, detected via an empty INPUT_PR_NUMBER (the metadata step already branches on this at the same step). The workflow_call path now reviews fork PRs; the auto pull_request trigger still skips them. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: run Pi review on fork PRs when a maintainer triggers it Apply the same fork-skip gating as the Codex review: skip fork PRs only on the automatic pull_request trigger (empty INPUT_PR_NUMBER), so a maintainer's /pi or /review comment (workflow_call, gated by check-write-access) reviews external PRs. Claude's pr-ready-review.yml needs no change: it has no fork skip, checks out main (not the fork ref), and reviews via gh pr diff/view with a restricted tool allowlist, so it already handles fork PRs on the command path. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: harden fork-review path against secret exfiltration Addresses the CI review of the fork-review enablement. On the fork path (maintainer-triggered workflow_call for a cross-repository PR), the reviewer ran an autonomous agent over the attacker-controlled merge checkout with the EE token present, full-access sandbox, and the review prompt itself read from that untrusted checkout — so a malicious fork could rewrite the reviewer's own instructions to exfiltrate secrets. For fork PRs only (detected via the is_fork step output): - withhold WINDMILL_EE_PRIVATE_ACCESS: skip the EE access/checkout/ substitution steps, so the private-repo token is never in the env. - read REVIEW.md and the prompt file from the trusted base ref (git show origin/:...) instead of the merge checkout. - restrict the agent: Codex runs with -s workspace-write (network off) instead of danger-full-access; Pi drops the bash tool. Non-fork PRs are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: redact provider credentials from fork review comments The model call needs the provider credential in its environment/config, so a network-disabled sandbox alone can't stop a prompt-injected fork review from reading the key (Codex: $HOME/.codex/auth.json; Pi: /proc/self/environ) and emitting it in the final message, which both workflows post verbatim. GitHub Actions log masking does not cover comments posted via the API. Strip the known credential values (OpenAI key + raw Codex auth JSON and its nested tokens; DeepSeek key) from the review body before posting, closing the comment as an exfiltration channel. Applied unconditionally since a credential should never appear in a review comment regardless of trigger. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: don't persist github.token in fork review checkout actions/checkout writes github.token into .git/config (http.extraheader) by default. The review agent can read the checked-out tree, so on the fork path a prompt injection could exfiltrate that token (issue/PR write) via .git/config — the provider-credential redaction added earlier didn't cover it. Set persist-credentials: false on the merge-ref checkout so the token is never written to disk. Safe on both paths: the only later git op is an unauthenticated fetch from the public origin, EE checkout uses its own token, and gh uses GH_TOKEN. Also redact github.token from the posted comment as defense-in-depth. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: disable Pi project-local discovery on fork reviews Pi auto-discovers and executes project-local .pi extensions (.ts/.js) at startup with DEEPSEEK_API_KEY in its environment — before the --tools allowlist applies — so a fork could add an extension that exfiltrates the key over the network, which output redaction can't catch. On the fork path (cwd is the fork checkout), pass --no-extensions to disable extension discovery, plus --no-skills/--no-prompt-templates/--no-themes/ --no-context-files so fork-controlled skills, templates, themes, and AGENTS.md/CLAUDE.md aren't auto-loaded into the reviewer's prompt as an injection vector. Non-fork behavior unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: use unguessable delimiter for untrusted PR metadata outputs The PR title/body were written to $GITHUB_OUTPUT with a fixed heredoc terminator (PR_BODY_EOF). A fork author could embed that terminator in their PR body to close the heredoc early and append their own output lines — e.g. is_fork=false, which (last-write-wins) overrides the real is_fork=true and puts fork code back on the trusted path (EE checkout + substitute_ee_code.sh with the private token, full-access agent). Generate a per-run random delimiter (128 bits from /dev/urandom) for the title and body heredocs so the terminator can't be predicted or embedded. Everything else in the block is single-line and newline-free, so this closes the injection. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: set PI_OFFLINE=1 on fork Pi reviews to block package resolution --no-extensions only filters which resources are *loaded*; Pi still resolves packages declared in a fork's .pi/settings.json first, running `npm install` / the configured npmCommand and lifecycle scripts with DEEPSEEK_API_KEY in env and network available — before the extension filter applies. Set PI_OFFLINE=1 on the fork path so the resolver's installMissing() short- circuits (returns false) for every missing package, skipping all install/clone/ lifecycle execution. It gates only startup network ops (installs, helper-binary downloads), not the provider inference call, so the review still runs. Verified: a fork .pi/settings.json with a malicious npmCommand does not execute under the flag. Non-fork path unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: run fork Pi review from an isolated dir to cut off project config Root cause of the recurring fork-review exposure: Pi resolves every project config from /.pi — settings/packages, extensions, skills, themes, prompts, SYSTEM.md, APPEND_SYSTEM.md — so running inside the fork checkout let a fork inject any of them to execute code or rewrite the reviewer's system prompt with DEEPSEEK_API_KEY in env. Per-flag opt-outs (--no-extensions, PI_OFFLINE, ...) only covered discovered vectors one at a time (SYSTEM.md wasn't covered). Discovery is cwd-based (single level, no walk-up; global fallback is the trusted runner home), so run Pi from a fresh mktemp dir where no fork .pi/* is on the path. The fork agent has no shell, so pre-compute the diff (base...head SHAs are trusted) into the context file it reads; it may still read fork files by absolute path for extra context — reads are safe, only config discovery and code execution were the risk. Outputs now use absolute workspace paths since cwd moved. The --no-* flags and PI_OFFLINE stay as belt-and-suspenders. Non-fork path unchanged. Verified: a fork .pi/SYSTEM.md sentinel is not discovered from the isolated cwd. Co-Authored-By: Claude Opus 4.8 (1M context) * ci: keep review artifacts outside the checkout to defeat symlink writes Both workflows wrote generated files (final message, event stream, review context, prior-comments) into $GITHUB_WORKSPACE. On the fork path the merge tree is attacker-controlled, so a fork could commit any of those paths as a symlink (e.g. codex-final-message.md -> ../../_actions/actions/github-script/v7/dist/ index.js). Our write would follow it and overwrite the next action's code, which then executes with the provider credential and the write-capable GitHub token — no prompt injection required. Route every generated file through $RUNNER_TEMP, which is runner-created and outside the checkout, so no fork-committed symlink is on the path: - prior-comments.json and pr-review-context.md are written to RUNNER_TEMP; the context step reads prior-comments from there. - The agent is given the context file's absolute RUNNER_TEMP path (appended to the prompt); prompt files updated to reference it instead of a checkout- relative path. Pi (no shell on forks) gets the diff pre-computed into that context file; the isolated-cwd hardening is retained. - Codex writes -o to RUNNER_TEMP; Pi writes its events/final message there; both post steps read from RUNNER_TEMP. Non-fork behavior is functionally unchanged (trusted checkout; same review inputs, now sourced from RUNNER_TEMP). Co-Authored-By: Claude Opus 4.8 (1M context) * ci: condense fork-review comments to the 4-line limit AGENTS.md requires each invariant stated in <=4 lines. Trim the security comments added in this branch (fork-skip rationale, output delimiter, isolated cwd, RUNNER_TEMP artifacts, credential redaction) to comply without dropping the constraint each one records. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/codex/pr-review.prompt.md | 2 +- .github/pi/pr-review.prompt.md | 2 +- .github/workflows/codex-pr-review.yml | 109 ++++++++++++++++++---- .github/workflows/pi-pr-review.yml | 128 ++++++++++++++++++++++---- 4 files changed, 204 insertions(+), 37 deletions(-) diff --git a/.github/codex/pr-review.prompt.md b/.github/codex/pr-review.prompt.md index fef52dba85..97a6217062 100644 --- a/.github/codex/pr-review.prompt.md +++ b/.github/codex/pr-review.prompt.md @@ -1,5 +1,5 @@ # Codex output format -- Read `./.github/codex/pr-review-context.md` for PR metadata and the diff commands. +- Read the review context file whose absolute path is given at the end of these instructions; it holds the PR metadata and the diff commands. - Return a markdown PR comment starting with `## Codex Review`. - Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently. diff --git a/.github/pi/pr-review.prompt.md b/.github/pi/pr-review.prompt.md index 92f128c6b1..952ba4c428 100644 --- a/.github/pi/pr-review.prompt.md +++ b/.github/pi/pr-review.prompt.md @@ -1,6 +1,6 @@ # Pi output format -- Read `./.github/pi/pr-review-context.md` for PR metadata and the diff commands. +- Read the review context file whose absolute path is given at the end of these instructions; it holds the PR metadata and the diff (or the git commands to produce it). - Return a markdown PR comment starting with `## Pi Review`. - Tag each finding with a severity (P0 / P1 / P2), file path, and line number when known confidently. - Output ONLY the final review markdown — no preamble, no thinking, no tool transcripts. diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index b395db98be..68bea1d715 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -104,24 +104,36 @@ jobs: IS_FORK="$EVENT_FORK" PR_AUTHOR="$EVENT_AUTHOR" fi - if [ "$IS_FORK" = "true" ]; then - echo "Skipping Codex review for fork PR." + # Fork PRs run untrusted code with secrets present, so the automatic + # pull_request trigger never reviews them. A non-empty INPUT_PR_NUMBER + # means we arrived via workflow_call (a maintainer /codex comment gated + # by check-write-access), so allow forks only on that path. + if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then + echo "Skipping Codex review for fork PR (automatic trigger)." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi + # PR title/body are attacker-controlled free text. Use an unguessable + # per-run delimiter so a fork can't embed a fixed heredoc terminator to + # inject extra outputs — e.g. is_fork=false (last-write-wins), which + # would re-enable the EE checkout and trusted-path settings for forks. + RAND=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n') + TITLE_EOF="TITLE_EOF_${RAND}" + BODY_EOF="BODY_EOF_${RAND}" { echo "skip=false" + echo "is_fork=$IS_FORK" echo "pr_number=$PR_NUMBER" echo "base_ref=$BASE_REF" echo "base_sha=$BASE_SHA" echo "head_sha=$HEAD_SHA" echo "pr_author=$PR_AUTHOR" - echo 'title<> "$GITHUB_OUTPUT" - name: Checkout repository @@ -130,9 +142,17 @@ jobs: with: ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge fetch-depth: 1 + # Don't persist github.token in .git/config: the review agent can read + # the checkout, and on the fork path that token (issue/PR write) would + # otherwise be exfiltratable. All later git ops target the public origin + # and need no auth; EE checkout and gh use their own explicit tokens. + persist-credentials: false + # Never expose the EE private-repo token to untrusted fork code. Skipping + # this step leaves steps.ee.outputs.available empty, so the EE checkout and + # substitution steps below are skipped too. - name: Check EE access - if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' && steps.pr.outputs.is_fork != 'true' id: ee env: EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} @@ -206,9 +226,12 @@ jobs: REPO: ${{ github.repository }} PR_NUMBER: ${{ steps.pr.outputs.pr_number }} run: | + # Write outside the checkout: on the fork path the merge tree is + # attacker-controlled, and a committed symlink at this path would + # redirect the write. gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \ --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \ - > prior-comments.json || echo "[]" > prior-comments.json + > "$RUNNER_TEMP/prior-comments.json" || echo "[]" > "$RUNNER_TEMP/prior-comments.json" - name: Write Codex review context if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' @@ -222,9 +245,9 @@ jobs: PR_AUTHOR: ${{ steps.pr.outputs.pr_author }} EXTRA_PROMPT: ${{ inputs.extra_prompt }} run: | - mkdir -p .github/codex node <<'NODE' const fs = require('fs'); + const tmp = process.env.RUNNER_TEMP; const lines = [ `Repository: ${process.env.PR_REPOSITORY}`, `PR number: ${process.env.PR_NUMBER}`, @@ -254,9 +277,9 @@ jobs: if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) { lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim()); } - if (fs.existsSync('prior-comments.json')) { + if (fs.existsSync(`${tmp}/prior-comments.json`)) { try { - const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8')); + const comments = JSON.parse(fs.readFileSync(`${tmp}/prior-comments.json`, 'utf8')); if (Array.isArray(comments) && comments.length > 0) { lines.push( '', @@ -271,19 +294,39 @@ jobs: } } catch (_) {} } - fs.writeFileSync('.github/codex/pr-review-context.md', `${lines.join('\n')}\n`); + fs.writeFileSync(`${tmp}/pr-review-context.md`, `${lines.join('\n')}\n`); NODE - name: Run Codex review if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + env: + PR_IS_FORK: ${{ steps.pr.outputs.is_fork }} + PR_BASE_REF: ${{ steps.pr.outputs.base_ref }} run: | - cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md + if [ "$PR_IS_FORK" = "true" ]; then + # Fork code is untrusted. Read the review policy/prompt from the base + # ref (git show) rather than the attacker-controlled merge checkout, + # so a malicious fork can't rewrite the reviewer's own instructions, + # and run in a network-disabled sandbox to block secret exfiltration. + git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/codex-prompt.md + git show "origin/$PR_BASE_REF:.github/codex/pr-review.prompt.md" >> /tmp/codex-prompt.md + SANDBOX_MODE=workspace-write + else + cat REVIEW.md .github/codex/pr-review.prompt.md > /tmp/codex-prompt.md + SANDBOX_MODE=danger-full-access + fi + # The context file lives in RUNNER_TEMP (outside the attacker-controlled + # checkout); tell the agent its absolute path. + printf '\nReview context file (absolute path): %s\n' "$RUNNER_TEMP/pr-review-context.md" >> /tmp/codex-prompt.md + # Write the final message outside the checkout too: a fork could commit + # codex-final-message.md as a symlink and redirect this write to overwrite + # e.g. a GitHub Action's index.js, which then runs with our credentials. codex exec \ -C "$GITHUB_WORKSPACE" \ -m gpt-5.6-sol \ -c 'model_reasoning_effort="xhigh"' \ - -s danger-full-access \ - -o codex-final-message.md \ + -s "$SANDBOX_MODE" \ + -o "$RUNNER_TEMP/codex-final-message.md" \ - < /tmp/codex-prompt.md - name: Post Codex review comment @@ -291,20 +334,52 @@ jobs: uses: actions/github-script@v7 env: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }} + GH_JOB_TOKEN: ${{ github.token }} with: github-token: ${{ github.token }} script: | const fs = require('fs'); - const path = `${process.env.GITHUB_WORKSPACE}/codex-final-message.md`; + const path = `${process.env.RUNNER_TEMP}/codex-final-message.md`; if (!fs.existsSync(path)) { core.info('Codex did not produce a final message; skipping PR comment.'); return; } - const body = fs.readFileSync(path, 'utf8').trim(); + let body = fs.readFileSync(path, 'utf8').trim(); if (!body) { core.info('Codex final message was empty; skipping PR comment.'); return; } + // Defense-in-depth for fork reviews: the model call needs the provider + // credential in the env, and the posted comment bypasses Actions log + // masking. Strip any credential (API key, raw auth JSON, nested + // tokens) that leaked into the review text before posting. + const secrets = []; + const addSecret = (v, min) => { + if (typeof v === 'string' && v.length >= min) secrets.push(v); + }; + addSecret(process.env.OPENAI_API_KEY, 8); + addSecret(process.env.CODEX_AUTH_JSON, 8); + addSecret(process.env.GH_JOB_TOKEN, 8); + if (process.env.CODEX_AUTH_JSON) { + try { + const collect = (o) => { + if (typeof o === 'string') addSecret(o, 20); + else if (Array.isArray(o)) o.forEach(collect); + else if (o && typeof o === 'object') Object.values(o).forEach(collect); + }; + collect(JSON.parse(process.env.CODEX_AUTH_JSON)); + } catch (_) {} + } + for (const s of [...new Set(secrets)].sort((a, b) => b.length - a.length)) { + body = body.split(s).join('[REDACTED]'); + } + body = body.trim(); + if (!body) { + core.info('Codex final message was empty after redaction; skipping PR comment.'); + return; + } await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index 72553b0d83..03c9599480 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -97,24 +97,36 @@ jobs: IS_FORK="$EVENT_FORK" PR_AUTHOR="$EVENT_AUTHOR" fi - if [ "$IS_FORK" = "true" ]; then - echo "Skipping Pi review for fork PR." + # Fork PRs run untrusted code with secrets present, so the automatic + # pull_request trigger never reviews them. A non-empty INPUT_PR_NUMBER + # means we arrived via workflow_call (a maintainer /pi comment gated by + # check-write-access), so allow forks only on that path. + if [ "$IS_FORK" = "true" ] && [ -z "$INPUT_PR_NUMBER" ]; then + echo "Skipping Pi review for fork PR (automatic trigger)." echo "skip=true" >> "$GITHUB_OUTPUT" exit 0 fi + # PR title/body are attacker-controlled free text. Use an unguessable + # per-run delimiter so a fork can't embed a fixed heredoc terminator to + # inject extra outputs — e.g. is_fork=false (last-write-wins), which + # would re-enable the EE checkout and trusted-path settings for forks. + RAND=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n') + TITLE_EOF="TITLE_EOF_${RAND}" + BODY_EOF="BODY_EOF_${RAND}" { echo "skip=false" + echo "is_fork=$IS_FORK" echo "pr_number=$PR_NUMBER" echo "base_ref=$BASE_REF" echo "base_sha=$BASE_SHA" echo "head_sha=$HEAD_SHA" echo "pr_author=$PR_AUTHOR" - echo 'title<> "$GITHUB_OUTPUT" - name: Checkout repository @@ -123,9 +135,17 @@ jobs: with: ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge fetch-depth: 1 + # Don't persist github.token in .git/config: the review agent can read + # the checkout, and on the fork path that token (issue/PR write) would + # otherwise be exfiltratable. All later git ops target the public origin + # and need no auth; EE checkout and gh use their own explicit tokens. + persist-credentials: false + # Never expose the EE private-repo token to untrusted fork code. Skipping + # this step leaves steps.ee.outputs.available empty, so the EE checkout and + # substitution steps below are skipped too. - name: Check EE access - if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' + if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' && steps.pr.outputs.is_fork != 'true' id: ee env: EE_TOKEN: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} @@ -178,9 +198,12 @@ jobs: REPO: ${{ github.repository }} PR_NUMBER: ${{ steps.pr.outputs.pr_number }} run: | + # Write outside the checkout: on the fork path the merge tree is + # attacker-controlled, and a committed symlink at this path would + # redirect the write. gh api "repos/$REPO/issues/$PR_NUMBER/comments?per_page=100" \ --jq '[.[] | {user: .user.login, created_at: .created_at, body: (.body | .[:4000])}] | sort_by(.created_at) | .[-20:]' \ - > prior-comments.json || echo "[]" > prior-comments.json + > "$RUNNER_TEMP/prior-comments.json" || echo "[]" > "$RUNNER_TEMP/prior-comments.json" - name: Write Pi review context if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' @@ -194,9 +217,9 @@ jobs: PR_AUTHOR: ${{ steps.pr.outputs.pr_author }} EXTRA_PROMPT: ${{ inputs.extra_prompt }} run: | - mkdir -p .github/pi node <<'NODE' const fs = require('fs'); + const tmp = process.env.RUNNER_TEMP; const lines = [ `Repository: ${process.env.PR_REPOSITORY}`, `PR number: ${process.env.PR_NUMBER}`, @@ -226,9 +249,9 @@ jobs: if (process.env.EXTRA_PROMPT && process.env.EXTRA_PROMPT.trim()) { lines.push('', 'Additional reviewer instructions:', process.env.EXTRA_PROMPT.trim()); } - if (fs.existsSync('prior-comments.json')) { + if (fs.existsSync(`${tmp}/prior-comments.json`)) { try { - const comments = JSON.parse(fs.readFileSync('prior-comments.json', 'utf8')); + const comments = JSON.parse(fs.readFileSync(`${tmp}/prior-comments.json`, 'utf8')); if (Array.isArray(comments) && comments.length > 0) { lines.push( '', @@ -243,7 +266,7 @@ jobs: } } catch (_) {} } - fs.writeFileSync('.github/pi/pr-review-context.md', `${lines.join('\n')}\n`); + fs.writeFileSync(`${tmp}/pr-review-context.md`, `${lines.join('\n')}\n`); NODE - name: Run Pi review @@ -251,16 +274,69 @@ jobs: env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} PI_SKIP_VERSION_CHECK: '1' + PR_IS_FORK: ${{ steps.pr.outputs.is_fork }} + PR_BASE_REF: ${{ steps.pr.outputs.base_ref }} + PR_BASE_SHA: ${{ steps.pr.outputs.base_sha }} + PR_HEAD_SHA: ${{ steps.pr.outputs.head_sha }} run: | set -o pipefail - cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md + PI_HARDEN_FLAGS=() + # Keep generated files (final message, events, context) outside the + # checkout: on the fork path a committed symlink at any of these paths + # would redirect our write and could overwrite an action's code that + # then runs with our credentials. RUNNER_TEMP is outside the checkout. + OUT_DIR="$RUNNER_TEMP" + CTX="$RUNNER_TEMP/pr-review-context.md" + if [ "$PR_IS_FORK" = "true" ]; then + # Fork code is untrusted. Read the review policy/prompt from the base + # ref (git show) rather than the attacker-controlled merge checkout, + # so a malicious fork can't rewrite the reviewer's own instructions, + # and drop the bash tool so the agent has no shell to exfiltrate with. + git show "origin/$PR_BASE_REF:REVIEW.md" > /tmp/pi-prompt.md + git show "origin/$PR_BASE_REF:.github/pi/pr-review.prompt.md" >> /tmp/pi-prompt.md + PI_TOOLS=read,grep,find,ls + + # The agent has no shell, so pre-compute the diff (base...head SHAs are + # trusted) into the context file it reads. It may still read fork files + # by absolute path for extra context — reads are safe. + { + echo "" + echo "## Pre-computed review diff (base...head)" + echo "You have no shell. The full diff is below. The repository checkout" + echo "is at $GITHUB_WORKSPACE — you may read files there by absolute path." + echo '```diff' + git -C "$GITHUB_WORKSPACE" diff --unified=0 "$PR_BASE_SHA...$PR_HEAD_SHA" + echo '```' + } >> "$CTX" + + # Pi resolves ALL project config from /.pi (settings/packages, + # extensions, skills, themes, prompts, SYSTEM.md); inside the fork + # checkout a fork could inject any to run code or rewrite our system + # prompt. Discovery is cwd-based, so run from a fresh empty dir. + PI_WORKDIR=$(mktemp -d) + cd "$PI_WORKDIR" + + # Belt-and-suspenders on top of the isolated cwd: refuse discovery of + # extensions/skills/templates/themes/context-files, and PI_OFFLINE=1 to + # block any startup network op or package install. PI_OFFLINE gates only + # startup network ops, not the provider inference call. + PI_HARDEN_FLAGS=(--no-extensions --no-skills --no-prompt-templates --no-themes --no-context-files) + export PI_OFFLINE=1 + else + cat REVIEW.md .github/pi/pr-review.prompt.md > /tmp/pi-prompt.md + PI_TOOLS=read,grep,find,ls,bash + fi + # The context file lives in RUNNER_TEMP (outside the checkout); tell the + # agent its absolute path. + printf '\nReview context file (absolute path): %s\n' "$CTX" >> /tmp/pi-prompt.md pi -p \ --provider deepseek \ --model deepseek-v4-pro \ - --tools read,grep,find,ls,bash \ + --tools "$PI_TOOLS" \ + "${PI_HARDEN_FLAGS[@]}" \ --mode json \ < /tmp/pi-prompt.md \ - | tee pi-events.jsonl \ + | tee "$OUT_DIR/pi-events.jsonl" \ | jq -rc --unbuffered ' if .type == "agent_start" then "🤖 pi agent started" elif .type == "turn_start" then "── turn ──" @@ -288,27 +364,43 @@ jobs: | map(select(.role == "assistant")) | last | (.content[]? | select(.type == "text") | .text) - ' pi-events.jsonl > pi-final-message.md + ' "$OUT_DIR/pi-events.jsonl" > "$OUT_DIR/pi-final-message.md" - name: Post Pi review comment if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' uses: actions/github-script@v7 env: PR_NUMBER: ${{ steps.pr.outputs.pr_number }} + DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} + GH_JOB_TOKEN: ${{ github.token }} with: github-token: ${{ github.token }} script: | const fs = require('fs'); - const path = `${process.env.GITHUB_WORKSPACE}/pi-final-message.md`; + const path = `${process.env.RUNNER_TEMP}/pi-final-message.md`; if (!fs.existsSync(path)) { core.info('Pi did not produce a final message; skipping PR comment.'); return; } - const body = fs.readFileSync(path, 'utf8').trim(); + let body = fs.readFileSync(path, 'utf8').trim(); if (!body) { core.info('Pi final message was empty; skipping PR comment.'); return; } + // Defense-in-depth for fork reviews: the model call needs the provider + // credential in the environment (readable via /proc/self/environ), and + // the posted comment is an exfiltration channel that bypasses GitHub + // Actions log masking. Strip the credential if it leaked into the text. + for (const s of [process.env.DEEPSEEK_API_KEY, process.env.GH_JOB_TOKEN]) { + if (typeof s === 'string' && s.length >= 8) { + body = body.split(s).join('[REDACTED]'); + } + } + body = body.trim(); + if (!body) { + core.info('Pi final message was empty after redaction; skipping PR comment.'); + return; + } await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, From 46f07ab0328d508ca385915d66edf7eceeeba315 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 14 Jul 2026 12:21:08 +0200 Subject: [PATCH 41/52] docs: mandate local-review-codex alongside local-review before PRs (#10078) Co-authored-by: Claude Opus 4.8 (1M context) --- .agents/skills/pr/SKILL.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index 73551436f4..61758550b2 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -96,7 +96,11 @@ and continue once they confirm it's done. 1. Run `git status` to check for uncommitted changes 2. Run `git log main..HEAD --oneline` to see all commits in this branch 3. Run `git diff main...HEAD` to see the full diff against main -4. **Invoke the `local-review` skill** before creating the PR (`/local-review` in Claude Code, `$local-review` in Codex, `pi --skill local-review` / `/skill:local-review` in Pi). If issues are found, fix them and commit before proceeding. Do not skip this step. +4. **Review the diff before creating the PR — run both reviews, do not skip:** + - **`local-review`** — Claude-native branch-diff-reviewer (`/local-review` in Claude Code, `$local-review` in Codex, `pi --skill local-review` / `/skill:local-review` in Pi). + - **`local-review-codex`** — cold Codex pass, the same review CI runs, for an independent perspective the Claude pass misses (`/local-review-codex` in Claude Code, or `bash .agents/skills/local-review-codex/run.sh`). If the `codex` CLI is missing or older than the version pinned in that skill, note it in your summary and continue — never block the PR on codex being unavailable. + + Run both — they catch different things. If either surfaces issues, fix them and commit before proceeding. 5. **Screenshots for frontend changes**: if `git diff main...HEAD --name-only` matches `^frontend/`, capture and embed screenshots of the affected UI per "Screenshots" above before writing the PR body (skip only if there is no visible UI effect). 6. Check if remote branch exists and is up to date: ```bash From 851e30914e3172dad2a3ccaa6951a3ffe1e67495 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 14 Jul 2026 12:24:22 +0200 Subject: [PATCH 42/52] feat(saml): add ALLOW_PRIVATE_SAML_METADATA_URLS SSRF bypass (#10077) * feat(saml): add ALLOW_PRIVATE_SAML_METADATA_URLS SSRF bypass Introduce the ALLOW_PRIVATE_SAML_METADATA_URLS env var and its allow_private_saml_metadata_urls() helper, mirroring the existing ALLOW_PRIVATE_MCP_SERVER_URLS opt-out. This lets self-hosted deployments with internal SAML IdPs (private IPs, no public DNS) skip the metadata-URL SSRF check that otherwise blocks server startup. The companion EE change (saml_ee.rs) consumes the helper to gate the validate_url_for_ssrf() call and additionally treats a cleared (empty/whitespace-only) SAML_METADATA setting as no SAML configured. Fixes WIN-2169 Co-Authored-By: Claude Opus 4.8 (1M context) * feat(saml): surface opt-in hint and record SSRF control in threat model Add saml_ssrf_error_message() so private-IdP metadata URL rejections point to ALLOW_PRIVATE_SAML_METADATA_URLS (mirroring the MCP helper), with a unit test. Record the new SSRF opt-in under T2 in THREAT_MODEL.md, and bump the EE ref for the companion saml_ee.rs change. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(saml): add validate_saml_metadata_url with opt-in unit tests Factor the SAML metadata SSRF gating into validate_saml_metadata_url() (mirroring validate_mcp_server_url) so the private-URL opt-in branch is unit-tested at the ssrf layer: blocks private by default, allows on true/1, and keeps scheme/host syntax guards when the opt-in is on. Bump the EE ref for the companion saml_ee.rs change. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: update ee-repo-ref to 394ad23242de429aef4074cc1dc28867dac95870 This commit updates the EE repository reference after PR #659 was merged in windmill-ee-private. Previous ee-repo-ref: 86da208c5aef2570568e18c7ab98f4d58adeec18 New ee-repo-ref: 394ad23242de429aef4074cc1dc28867dac95870 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/THREAT_MODEL.md | 2 +- backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/ssrf.rs | 143 ++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/backend/THREAT_MODEL.md b/backend/THREAT_MODEL.md index 9501b5bf0d..0468ebf721 100644 --- a/backend/THREAT_MODEL.md +++ b/backend/THREAT_MODEL.md @@ -97,7 +97,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical). | id | threat | actor | surface | asset | impact | likelihood | status | controls | evidence | |---|---|---|---|---|---|---|---|---|---| | T1 | SQL injection in app/internal query builders and trigger clauses compromises the metadata DB and connected databases | remote_auth | EP8 | Database, downstream connected systems | critical | almost_certain | partially_mitigated | sqlx parameterized queries elsewhere; query-builder safety reviews | GHSA-225c-j3xq-g6x6, GHSA-78p7-jc72-gv66, GHSA-hvc7-f67h-jx3g, GHSA-wrrg-f89m-f84q, GHSA-79vf-3qwm-2w64, GHSA-55p6-fxj4-v983, GHSA-5g4v-49rj-r52r, GHSA-x6cq-7xr8-53x3, 2cf4bb180b | -| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 | +| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; SAML IdP metadata URLs are SSRF-validated at load time behind the `ALLOW_PRIVATE_SAML_METADATA_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 | | T3 | Broken authorization / IDOR lets a scoped token or low-privilege member read scripts, job data, and secrets across folders and workspaces | remote_auth | EP5, EP2, EP1 | Scripts, job data, secrets, isolation | critical | almost_certain | partially_mitigated | RLS, token scopes, folder ACLs, view-token HMAC (added incrementally); on managed, sensitive tenants can opt into dedicated DB/worker/namespace, but the shared tier IS the software boundary | GHSA-qfg7-x243-5hg4, GHSA-8x8x-88qc-qp4r, GHSA-2ppx-66jv-wpw5, GHSA-x3x7-g97v-mp59, GHSA-j276-g4h8-g6h5, GHSA-8mv7-hmrg-96xv, GHSA-x2wf-f962-7frq, GHSA-qc7c-gcw6-h4xp, GHSA-vxc5-w28p-m9xw, GHSA-2g34-wfvr-5qqj, GHSA-w7p6-wpxm-pp66, 7edf3f0212, 89a7a37776, ab11c7747a, 664edcdfb7 | | T4 | Remote code execution by injecting attacker-controlled identifiers into generated worker wrappers | remote_auth | EP10 | Worker host, isolation, downstream | critical | likely | partially_mitigated | entrypoint/env-var-name validation added | GHSA-wxjq-w5pj-jqhx, GHSA-5f5q-2vg2-r2x4, GHSA-8q8j-mm3g-5c2q (CVE-2026-33881), bf93657fee, bd05bcadde, 22ec4da5f0 | | T5 | Worker compromise & cross-tenant access via weak-by-default isolation (nsjail off by default → user code runs with only PID-ns `unshare`); sandbox escape where nsjail/dind/podman is enabled | remote_auth | EP9, EP15 | Worker host, isolation, downstream | critical | likely | unmitigated | nsjail off by default everywhere (`DISABLE_NSJAIL=true`); shipped compose gives PID-ns `unshare` only (`FAVOR_UNSHARE_PID=true`), bare installs get no isolation. Where nsjail enabled: read-only remounts, jail-tmp refusal, podman socket gating | GHSA-6qr8-xhg4-453q, GHSA-3vpp-vf62-wqp6, f8467f38c8, df5aec0f5d, f1b6746e0e | diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 589a683dba..27c0e3ebfa 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -2ba6a2a75b6fc97858b306b2c98ada481e363c10 +394ad23242de429aef4074cc1dc28867dac95870 diff --git a/backend/windmill-common/src/ssrf.rs b/backend/windmill-common/src/ssrf.rs index 2f100d5ae1..627636b8f5 100644 --- a/backend/windmill-common/src/ssrf.rs +++ b/backend/windmill-common/src/ssrf.rs @@ -4,6 +4,8 @@ use crate::error::Error; pub const ALLOW_PRIVATE_MCP_SERVER_URLS_ENV: &str = "ALLOW_PRIVATE_MCP_SERVER_URLS"; +pub const ALLOW_PRIVATE_SAML_METADATA_URLS_ENV: &str = "ALLOW_PRIVATE_SAML_METADATA_URLS"; + /// Why a URL failed SSRF validation. /// /// The distinction matters for callers that gate private endpoints behind a @@ -124,6 +126,30 @@ pub fn allow_private_mcp_server_urls() -> bool { .is_some_and(|v| v == "true" || v == "1") } +pub fn allow_private_saml_metadata_urls() -> bool { + std::env::var(ALLOW_PRIVATE_SAML_METADATA_URLS_ENV) + .ok() + .is_some_and(|v| v == "true" || v == "1") +} + +pub async fn validate_saml_metadata_url(url: &str) -> Result<(), SsrfValidationError> { + let parsed = + url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?; + + match parsed.scheme() { + "http" | "https" => {} + scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())), + } + + parsed.host_str().ok_or(SsrfValidationError::MissingHost)?; + + if allow_private_saml_metadata_urls() { + return Ok(()); + } + + validate_url_for_ssrf(url).await +} + pub async fn validate_mcp_server_url(url: &str) -> Result<(), SsrfValidationError> { let parsed = url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?; @@ -161,6 +187,16 @@ pub fn mcp_ssrf_error_message(e: &SsrfValidationError) -> String { } } +pub fn saml_ssrf_error_message(e: &SsrfValidationError) -> String { + match e { + SsrfValidationError::Private { .. } => format!( + "{e}. If you need to use private/internal SAML metadata URLs, \ + set the {ALLOW_PRIVATE_SAML_METADATA_URLS_ENV}=true environment variable" + ), + _ => e.to_string(), + } +} + fn is_private_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(ipv4) => is_private_ipv4(ipv4), @@ -223,6 +259,30 @@ mod tests { } } + struct PrivateSamlMetadataUrlsEnvGuard { + previous: Option, + } + + impl PrivateSamlMetadataUrlsEnvGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var(ALLOW_PRIVATE_SAML_METADATA_URLS_ENV).ok(); + match value { + Some(value) => std::env::set_var(ALLOW_PRIVATE_SAML_METADATA_URLS_ENV, value), + None => std::env::remove_var(ALLOW_PRIVATE_SAML_METADATA_URLS_ENV), + } + Self { previous } + } + } + + impl Drop for PrivateSamlMetadataUrlsEnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(ALLOW_PRIVATE_SAML_METADATA_URLS_ENV, value), + None => std::env::remove_var(ALLOW_PRIVATE_SAML_METADATA_URLS_ENV), + } + } + } + #[test] fn test_private_ipv4() { assert!(is_private_ipv4(&"127.0.0.1".parse().unwrap())); @@ -360,4 +420,87 @@ mod tests { .unwrap_err(); assert!(!mcp_ssrf_error_message(&invalid_error).contains(ALLOW_PRIVATE_MCP_SERVER_URLS_ENV)); } + + #[tokio::test] + async fn allow_private_saml_metadata_urls_defaults_to_false() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateSamlMetadataUrlsEnvGuard::set(None); + assert!(!allow_private_saml_metadata_urls()); + } + + #[tokio::test] + async fn allow_private_saml_metadata_urls_honors_true_and_one() { + let _lock = TEST_ENV_LOCK.lock().await; + + let _guard = PrivateSamlMetadataUrlsEnvGuard::set(Some("true")); + assert!(allow_private_saml_metadata_urls()); + + let _guard = PrivateSamlMetadataUrlsEnvGuard::set(Some("1")); + assert!(allow_private_saml_metadata_urls()); + + let _guard = PrivateSamlMetadataUrlsEnvGuard::set(Some("false")); + assert!(!allow_private_saml_metadata_urls()); + } + + #[tokio::test] + async fn validate_saml_metadata_url_blocks_private_by_default() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateSamlMetadataUrlsEnvGuard::set(None); + + assert!(matches!( + validate_saml_metadata_url("http://127.0.0.1/metadata").await, + Err(SsrfValidationError::Private { resolved: false }) + )); + } + + #[tokio::test] + async fn validate_saml_metadata_url_allows_private_when_env_is_enabled() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateSamlMetadataUrlsEnvGuard::set(Some("true")); + + assert!(validate_saml_metadata_url("http://127.0.0.1/metadata") + .await + .is_ok()); + } + + #[tokio::test] + async fn validate_saml_metadata_url_allows_private_when_env_is_one() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateSamlMetadataUrlsEnvGuard::set(Some("1")); + + assert!(validate_saml_metadata_url("http://10.0.0.1/metadata") + .await + .is_ok()); + } + + #[tokio::test] + async fn validate_saml_metadata_url_keeps_syntax_guards_when_private_urls_are_allowed() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateSamlMetadataUrlsEnvGuard::set(Some("true")); + + assert!(matches!( + validate_saml_metadata_url("ftp://example.com/metadata").await, + Err(SsrfValidationError::DisallowedScheme(_)) + )); + assert!(matches!( + validate_saml_metadata_url("not-a-url").await, + Err(SsrfValidationError::InvalidUrl(_)) + )); + } + + #[tokio::test] + async fn saml_ssrf_error_message_includes_env_hint_only_for_private_urls() { + let private_error = validate_url_for_ssrf("http://127.0.0.1/metadata") + .await + .unwrap_err(); + assert!(saml_ssrf_error_message(&private_error) + .contains("ALLOW_PRIVATE_SAML_METADATA_URLS=true")); + + let invalid_error = validate_url_for_ssrf("ftp://example.com/metadata") + .await + .unwrap_err(); + assert!( + !saml_ssrf_error_message(&invalid_error).contains(ALLOW_PRIVATE_SAML_METADATA_URLS_ENV) + ); + } } From 1ffe5a107582946989bfb7995302a67d221ef044 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 14 Jul 2026 12:47:53 +0200 Subject: [PATCH 43/52] chore(main): release 1.757.0 (#10080) * chore(main): release 1.757.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 12 ++ 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, 130 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6614223d65..51542f3ea4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.757.0](https://github.com/windmill-labs/windmill/compare/v1.756.1...v1.757.0) (2026-07-14) + + +### Features + +* **saml:** add ALLOW_PRIVATE_SAML_METADATA_URLS SSRF bypass ([#10077](https://github.com/windmill-labs/windmill/issues/10077)) ([851e309](https://github.com/windmill-labs/windmill/commit/851e30914e3172dad2a3ccaa6951a3ffe1e67495)) + + +### Bug Fixes + +* **ai-agent:** don't mark repeated tool calls as failed in flow graph ([#10075](https://github.com/windmill-labs/windmill/issues/10075)) ([207ce86](https://github.com/windmill-labs/windmill/commit/207ce8649cf7026c62eea2b1b2f462c7df8c4e5a)) + ## [1.756.1](https://github.com/windmill-labs/windmill/compare/v1.756.0...v1.756.1) (2026-07-14) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c0f443e802..e145f1135c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -13745,7 +13745,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-nats", @@ -13827,7 +13827,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.756.1" +version = "1.757.0" dependencies = [ "async-stream", "async-trait", @@ -13860,7 +13860,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13873,7 +13873,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "argon2", @@ -14011,7 +14011,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14034,7 +14034,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14049,7 +14049,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14075,7 +14075,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.756.1" +version = "1.757.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14085,7 +14085,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14102,7 +14102,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14124,7 +14124,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14147,7 +14147,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14163,7 +14163,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14184,7 +14184,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14205,7 +14205,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14219,7 +14219,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-nats", @@ -14254,7 +14254,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14279,7 +14279,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14297,7 +14297,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14319,7 +14319,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14339,7 +14339,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14376,7 +14376,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14404,7 +14404,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.756.1" +version = "1.757.0" dependencies = [ "lazy_static", "serde", @@ -14416,7 +14416,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.756.1" +version = "1.757.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14441,7 +14441,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14455,7 +14455,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.756.1" +version = "1.757.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14490,7 +14490,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.756.1" +version = "1.757.0" dependencies = [ "chrono", "lazy_static", @@ -14504,7 +14504,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14523,7 +14523,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.756.1" +version = "1.757.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14625,7 +14625,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.756.1" +version = "1.757.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14644,7 +14644,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.756.1" +version = "1.757.0" dependencies = [ "regex", "serde", @@ -14659,7 +14659,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14683,7 +14683,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "futures", @@ -14700,7 +14700,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.756.1" +version = "1.757.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14716,7 +14716,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -14737,7 +14737,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "arc-swap", @@ -14793,7 +14793,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-stream", @@ -14827,7 +14827,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "futures", @@ -14845,7 +14845,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.756.1" +version = "1.757.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14854,7 +14854,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -14866,7 +14866,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde_json", @@ -14878,7 +14878,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "gosyn", @@ -14890,7 +14890,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -14902,7 +14902,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde_json", @@ -14914,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "nu-parser", @@ -14925,7 +14925,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14936,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14948,7 +14948,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14959,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-recursion", @@ -14981,7 +14981,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde_json", @@ -14993,7 +14993,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -15007,7 +15007,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -15037,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde", @@ -15049,7 +15049,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15083,7 +15083,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15099,7 +15099,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde", @@ -15110,7 +15110,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-recursion", @@ -15149,7 +15149,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "const_format", @@ -15188,7 +15188,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.756.1" +version = "1.757.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15199,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-recursion", @@ -15233,7 +15233,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15290,7 +15290,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15323,7 +15323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15343,7 +15343,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15377,7 +15377,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15413,7 +15413,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15436,7 +15436,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-nats", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15547,7 +15547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-trait", @@ -15572,7 +15572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15591,7 +15591,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-once-cell", @@ -15701,7 +15701,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.756.1" +version = "1.757.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 20f530a8fb..e207dffbae 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.756.1" +version = "1.757.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.756.1" +version = "1.757.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 b3ac94a3d3..f415ddf6dc 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.756.1" +version = "1.757.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.756.1" +version = "1.757.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.756.1" +version = "1.757.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.756.1" +version = "1.757.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 243859434c..af7aac8ff1 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.756.1" +version = "1.757.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 32ce02b752..7f9ff752d1 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.756.1 + version: 1.757.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index ad2e9075d9..cc27b34abc 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.756.1"; +export const VERSION = "v1.757.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 c751507f74..23d7737cff 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.756.1"; +export const VERSION = "1.757.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 09b58d699d..93ada12c71 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.756.1", + "version": "1.757.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.756.1", + "version": "1.757.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index e910c51432..10c0e1d110 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.756.1", + "version": "1.757.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 f3dbdd423b..84462829b0 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.756.1" +wmill = ">=1.757.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 8de4505a53..712046f912 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.756.1 + version: 1.757.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 403df81817..32c9044cb2 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.756.1' + ModuleVersion = '1.757.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index b7630b33b3..a8c9d93f77 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.756.1" +version = "1.757.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 661dbdac38..693aa210cc 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.756.1", + "version": "1.757.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 c66de3c5b6..6129b03573 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.756.1", + "version": "1.757.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 1754b79a18..6e261e2641 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.756.1 +1.757.0 From bfcec7e8ac71e86ab16d7db789556b5fc7cfd3c7 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 14 Jul 2026 13:21:06 +0200 Subject: [PATCH 44/52] feat(sessions): support many pending sessions persisted in IndexedDB (#10076) * feat(sessions): support many pending sessions persisted in IndexedDB Allow several unsent AI sessions to be set up in parallel. Split the transient flag into "in-memory, not yet persisted" (unsent is derived from workspace_id), persist a pending session to IndexedDB on first touch with its own draftPrompt, show pending sessions in the sidebar under the family filter, and reconcile them by pending_workspace_id. The + button reuses the untouched draft in the active family so idle clicks don't pile blank entries; touching one spawns a fresh blank. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(sessions): focus composer when + reuses the untouched draft When there are no pending changes, `+` reuses the active family's untouched draft instead of creating a new session (unchanged). But when the reused draft is the one already on screen, currentSessionId doesn't change, so nothing navigated and the click gave no feedback. Bump a composerFocusRequest nonce in the reuse branch and have SessionWrapper's focus effect depend on it, so the composer re-focuses and the user can type right away. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(sessions): per-session debounce for draft prompt flush A single module-level flush timer let a keystroke in one pending draft cancel a sibling draft's pending first-touch flush, so the earlier draft was never written and its typed prompt vanished on reload. Key the debounce per session so parallel drafts persist independently. Also collapse the touch rationale repeated across the preview-tab/collapse/size setters onto persistTouched. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../components/sessions/SessionPicker.svelte | 9 +- .../components/sessions/SessionWrapper.svelte | 18 +- .../sessions/sessionRuntime.svelte.ts | 3 +- .../sessions/sessionState.svelte.ts | 249 +++++++++--------- .../components/sessions/sessionState.test.ts | 55 +++- .../sessions/sessionStateIndexedDb.test.ts | 164 ++++++++---- .../sessions/sessionSwitch.svelte.ts | 7 +- 7 files changed, 292 insertions(+), 213 deletions(-) diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index e3d1c5d21e..fa68cdfd3b 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -145,7 +145,8 @@ // total, and keyboard navigation. const visibleSessions = $derived( sessionState.sessions.filter((s) => { - if (s.transient) return false + // Pending (unsent) sessions show like any other, so several drafts can be + // set up in parallel; they group by pending_workspace_id via sessionRootOf. // The open session always stays in the list, ignoring both filters. if (s.id === sessionState.currentSessionId) return true if (s.archived && !showArchived.val) return false @@ -309,10 +310,8 @@ async function createAndOpen() { const fresh = createSession() // A new session opened from a Windmill page adopts that page as its first - // preview tab (resetSessionPreviewTabs handles a reused transient whose - // tabs still show a previous destination). Skip when already on the - // sessions page (nothing meaningful to capture) so the preview starts - // empty until the chat opens something. + // preview tab. Skip when already on the sessions page (nothing meaningful to + // capture) so the preview starts empty until the chat opens something. if (!onSessionsPage) { const url = page.url.pathname + page.url.search resetSessionPreviewTabs(fresh.id, url) diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index f5fa09c6d0..4f94dc2888 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -28,13 +28,14 @@ import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' import SessionChangesBar from './SessionChangesBar.svelte' import { + composerFocusRequest, createSession, deleteSessionsForWorkspace, getEffectiveWorkspaceId, moveSessionToNewFork, moveSessionToWorkspace, - peekTransientDraftPrompt, - queueTransientDraftPrompt, + getSessionDraftPrompt, + setSessionDraftPrompt, reconcileAfterWorkspaceChange, renameSession, selectSession, @@ -69,9 +70,9 @@ // Reactive session reference (mutations to summary/target propagate via the $state proxy) const session = $derived(sessionState.sessions.find((s) => s.id === sessionId)) - // Seed the composer with the unsent prompt a reload preserved in the - // transient draft slot (script-init: AIChatInput reads it once at mount). - const restoredDraftPrompt = peekTransientDraftPrompt(sessionId) + // Seed the composer with the unsent prompt a reload preserved on the session + // record (script-init: AIChatInput reads it once at mount). + const restoredDraftPrompt = getSessionDraftPrompt(sessionId) // The workspace the session acts on, shown in the header "Acting on" strip via the shared // WorkspaceScopeTrigger chip. `targetId` is also the workspace the chip's ellipsis menu targets. @@ -232,6 +233,11 @@ // loading. let aiChat: AIChat | undefined = $state(undefined) $effect(() => { + // Focus the composer when this session becomes active, or on an explicit + // focus request — the latter covers `+` reusing the untouched draft you're + // already viewing, where currentSessionId doesn't change so activation alone + // wouldn't re-run this. + void composerFocusRequest.nonce if (sessionState.currentSessionId !== sessionId) return if (!aiChat) return if (!$copilotInfo.enabled) return @@ -415,7 +421,7 @@ hideModeSelector wideLayout initialInstructions={restoredDraftPrompt} - onDraftChange={(text) => queueTransientDraftPrompt(sessionId, text)} + onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)} forceDisabled={isUnavailable || !!session.archived} forceDisabledMessage={isUnavailable ? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.' diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 18d7366134..9f2b750989 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -403,8 +403,7 @@ function createRuntime(session: Session): SessionRuntime { // Hydrate the preview-tab owner from the session record (the durable backing); // from here on the owner is the single live copy and writes back through the // adapter. setSessionTabs / setSessionPreviewCollapsed stay the low-level record - // writers (a transient session's writes land in the localStorage draft slot - // until it materialises). + // writers (opening/moving a tab is a touch that persists an in-memory draft). const previewTabs = new SessionPreviewTabs(hydratePreviewTabs(session), { persist: (snap) => { setSessionTabs(session.id, snap.tabs, snap.activeId) diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 79af616918..8a88b10afa 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -35,7 +35,7 @@ export function syncWorkspaceTo(workspaceId: string | undefined): void { import { WorkspaceService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import type HistoryManager from '$lib/components/copilot/chat/HistoryManager.svelte' -import { onUserChange, scopedKey } from '$lib/userScopedStorage' +import { onUserChange } from '$lib/userScopedStorage' // A destination the session preview can open as an editor: a workspace item // (`path`) for flow/script/raw_app, or — for 'pipeline' — a folder name (not an @@ -99,10 +99,13 @@ export type Session = { // archived (not by the user). Lets reconciliation auto-unarchive the session // when the workspace is unarchived, while leaving user-archived sessions be. archivedByWorkspace?: boolean - // In-memory-only flag: the session exists but isn't written to - // IndexedDB until the user sends their first message. Avoids - // piling abandoned drafts across `+` clicks — createSession reuses - // the existing transient if one is already open. + // In-memory-only flag: the session exists but hasn't been written to + // IndexedDB yet. Set at creation, cleared on the first genuine user touch + // (typed prompt, workspace/fork pick, preview tab, rename) which persists + // the record. Decoupled from "unsent" — a pending session is unsent while + // `workspace_id` is undefined, whether or not it has been persisted. An + // untouched draft never persists, so idle `+` clicks vanish on reload + // instead of littering the sidebar. transient?: boolean // Per-session unread watermark: the displayMessages count the last time // the user was on this session's page. Compared against the runtime's @@ -120,6 +123,10 @@ export type Session = { // Preview split size (preview pane %, 0-100) the user dragged for this session. // Per-session so each session restores its own layout. previewSize?: number + // Unsent composer text for a pending (uncommitted) session, persisted with + // the record so each parallel draft restores its own typed-but-unsent prompt. + // Only tracked while unsent; cleared once the workspace commits at first send. + draftPrompt?: string } // One preview tab: `url` is the URL we command the iframe to load, `loc` the @@ -136,11 +143,6 @@ export type SessionPreviewTab = { id: string; url: string; loc: string; friendly const SESSIONS_DB = 'windmill-sessions' const LEGACY_SESSIONS_KEY = 'windmill_sessions' const LEGACY_LAST_SEEN_KEY = 'windmill_sessions_last_seen_counts' -// The single unsent (transient) draft, kept in localStorage (user-scoped) so a -// reload doesn't lose what the user set up before their first message: name, -// workspace/fork choice, editor target, preview tabs and the typed-but-unsent -// prompt. -const TRANSIENT_DRAFT_KEY = 'wm_session_transient_draft' interface SessionSchema extends DBSchema { sessions: { key: string; value: Session } @@ -292,79 +294,62 @@ export const sessionState = $state<{ hydrated: false }) -type TransientDraft = Session & { - prompt?: string -} - -// The unsent prompt for the current transient session, held here so every -// draft write (which snapshots only the Session record) can carry it along. -let transientPrompt: { sessionId: string; text: string } | undefined - -function writeTransientDraft(s: Session): void { - const key = scopedKey(TRANSIENT_DRAFT_KEY) - if (!key) return - const draft: TransientDraft = { - ...($state.snapshot(s) as Session), - prompt: transientPrompt?.sessionId === s.id ? transientPrompt.text : undefined - } - storeLocalSetting(key, JSON.stringify(draft)) -} - -function readTransientDraft(): TransientDraft | undefined { - const key = scopedKey(TRANSIENT_DRAFT_KEY) - if (!key) return undefined - const raw = getLocalSetting(key) - if (!raw) return undefined - try { - const d = JSON.parse(raw) - if (!d || typeof d.id !== 'string' || typeof d.name !== 'string') return undefined - return d as TransientDraft - } catch { - return undefined - } -} - -function clearTransientDraft(): void { - const key = scopedKey(TRANSIENT_DRAFT_KEY) - if (key) storeLocalSetting(key, undefined) - transientPrompt = undefined -} - -// Debounced write-behind of the chat input for a transient session, so the -// typed-but-unsent prompt survives a reload with the rest of the draft. -let transientPromptFlushHandle: ReturnType | undefined -export function queueTransientDraftPrompt(sessionId: string, text: string): void { +// Debounced write-behind of the composer text for a pending (uncommitted) +// session, so a typed-but-unsent prompt survives a reload as part of the record. +// Keyed per session: a single shared timer would let a keystroke in one draft +// cancel a sibling draft's pending flush, dropping that draft's first-touch write. +const draftPromptFlushHandles = new Map>() +export function setSessionDraftPrompt(sessionId: string, text: string): void { const s = sessionState.sessions.find((x) => x.id === sessionId) - if (!s?.transient) return - transientPrompt = { sessionId, text } - clearTimeout(transientPromptFlushHandle) - transientPromptFlushHandle = setTimeout(() => writeTransientDraft(s), 400) + if (!s || s.workspace_id) return + // No-op on an unchanged prompt. Crucially, this treats the composer's + // mount-time onDraftChange('') as a non-touch (draftPrompt is undefined), + // so merely opening an untouched draft never persists it. + if ((s.draftPrompt ?? '') === text) return + s.draftPrompt = text + clearTimeout(draftPromptFlushHandles.get(sessionId)) + draftPromptFlushHandles.set( + sessionId, + setTimeout(() => { + draftPromptFlushHandles.delete(sessionId) + persistTouched(s) + }, 400) + ) } -// Read back the restored draft prompt when the session's runtime (and its chat -// manager) is created. Peek, not take: later draft writes keep carrying it. -export function peekTransientDraftPrompt(sessionId: string): string | undefined { - return transientPrompt?.sessionId === sessionId ? transientPrompt.text : undefined +// Read back the persisted composer text when a pending session's chat mounts. +// Returns nothing once the session is committed (its draft prompt was consumed). +export function getSessionDraftPrompt(sessionId: string): string | undefined { + const s = sessionState.sessions.find((x) => x.id === sessionId) + if (!s || s.workspace_id) return undefined + return s.draftPrompt } -// Write-behind a single session record. Transient (unsent) sessions are not -// written to IndexedDB — they live in memory plus a single localStorage draft -// slot until materializeTransient() promotes them at first send. -// Awaits DB-open so a write racing hydration still lands; no-ops (degrades to -// in-memory) when the DB can't be opened. In-memory $state is the read surface, -// so callers fire-and-forget. +// Persist a session on a genuine user edit, promoting an in-memory-only +// (transient) pending session to a durable IndexedDB record on first touch. +// Non-touch writers (runtime chatId seeding, unread watermark) call putSession +// directly, so an untouched draft stays in memory and vanishes on reload. +function persistTouched(s: Session): void { + if (s.transient) delete s.transient + void putSession(s) +} + +// Write-behind a single session record. Transient sessions are in-memory only +// (not yet touched) and are not written to IndexedDB; materializeTransient() / +// persistTouched() clear the flag first. Awaits DB-open so a write racing +// hydration still lands; no-ops (degrades to in-memory) when the DB can't be +// opened. In-memory $state is the read surface, so callers fire-and-forget. export async function putSession(s: Session): Promise { if (!BROWSER) return - if (s.transient) { - writeTransientDraft(s) - return - } - // Never resurrect a session whose committed workspace is gone. A live runtime - // can still write through here after reconciliation deletes its record (chatId - // seed, unread watermark), so guard once the workspace list is loaded. - if (s.workspace_id) { + if (s.transient) return + // Never resurrect a session whose workspace is gone — committed (workspace_id) + // or pre-send (pending_workspace_id). A live runtime can still write through + // here after reconciliation deletes its record (chatId seed, unread watermark), + // so guard once the workspace list is loaded. + const boundWs = s.workspace_id ?? s.pending_workspace_id + if (boundWs) { const all = get(userWorkspaces) - if (all.length > 0 && !all.some((w) => w.id === s.workspace_id)) return + if (all.length > 0 && !all.some((w) => w.id === boundWs)) return } ensureSessionRootId(s) const db = await sessionsDb.whenReady() @@ -408,19 +393,8 @@ async function hydrateSessions({ dropTransients = false } = {}): Promise { const changed = all.filter((s) => ensureSessionRootId(s)) for (const s of changed) await db.put('sessions', s) all.sort((a, b) => b.createdAt - a.createdAt) - // Restore the (user-scoped) unsent draft, unless it already materialised - // (present in the DB — e.g. sent from another browser tab) or the same - // draft is still live in memory. - const draft = readTransientDraft() - if (draft) { - if (all.some((s) => s.id === draft.id)) { - clearTransientDraft() - } else if (!transients.some((s) => s.id === draft.id)) { - const { prompt, ...rec } = draft - transients.push({ ...rec, transient: true }) - if (prompt) transientPrompt = { sessionId: rec.id, text: prompt } - } - } + // In-memory (untouched) drafts are prepended, newest-first as createSession + // maintains; persisted sessions follow, sorted by createdAt. sessionState.sessions = [...transients, ...all] } catch (e) { console.error('Failed to load sessions from IndexedDB', e) @@ -480,7 +454,13 @@ export async function reconcileSessionsLifecycle(): Promise { if (!db) return const wsIds = new Set() const sessions = await db.getAll('sessions') - for (const s of sessions) if (s.workspace_id) wsIds.add(s.workspace_id) + // Committed sessions reconcile on workspace_id; persisted pending drafts on + // their pre-send pending_workspace_id, so a workspace deleted/archived under + // an unsent draft applies the same never-orphaned rule to the draft. + for (const s of sessions) { + const ws = s.workspace_id ?? s.pending_workspace_id + if (ws) wsIds.add(ws) + } if (wsIds.size === 0) return let status: Record @@ -496,8 +476,9 @@ export async function reconcileSessionsLifecycle(): Promise { const deletedIds = new Set() try { for (const s of sessions) { - if (!s.workspace_id) continue - const { action, patch } = decideSessionLifecycle(s, status[s.workspace_id]) + const ws = s.workspace_id ?? s.pending_workspace_id + if (!ws) continue + const { action, patch } = decideSessionLifecycle(s, status[ws]) if (action === 'delete') { await db.delete('sessions', s.id) // GC linked files too, matching deleteSession — a record-only delete @@ -636,23 +617,33 @@ export function findSessionByName(name: string): Session | undefined { return sessionState.sessions.find((s) => s.name === name) } +// Bumped to ask the active session's composer to re-focus even when +// `currentSessionId` doesn't change — the `+` reuse path lands you back on the +// untouched draft you're already viewing, so nothing navigates, but the click +// should still drop the cursor in the composer. SessionWrapper's focus effect +// depends on `nonce`. +export const composerFocusRequest = $state<{ nonce: number }>({ nonce: 0 }) +export function requestComposerFocus(): void { + composerFocusRequest.nonce++ +} + export function createSession(): Session { - // Reuse the existing transient session (if any) so the user can hit - // the "+" button repeatedly without piling drafts. The transient - // becomes a real session at first-message-send time. Only a transient - // from the active workspace family qualifies — reusing one left over - // from another family would hand the user a session still acting on - // that family. A cross-family leftover is dropped instead (it was - // never sent, so only the draft slot holds it). - const existingTransient = sessionState.sessions.find((s) => s.transient) - if (existingTransient) { - if (sessionInCurrentFamily(existingTransient)) { - sessionState.currentSessionId = existingTransient.id - return existingTransient - } - sessionState.sessions = sessionState.sessions.filter((s) => s.id !== existingTransient.id) - clearTransientDraft() + // Reuse an existing untouched draft from the active family rather than pile a + // blank entry on every `+`. "Untouched" is exactly `transient`: a pending + // session leaves the in-memory-only state the moment the user touches it + // (types a prompt, picks a workspace, opens the panel, renames), at which + // point it persists and is its own session — so several pending sessions can + // still be built up in parallel, one touch at a time. A cross-family leftover + // draft is dropped instead of reused (reusing it would act on that family). + const reusable = sessionState.sessions.find((s) => s.transient && sessionInCurrentFamily(s)) + if (reusable) { + sessionState.currentSessionId = reusable.id + // Reusing an already-active draft doesn't change currentSessionId, so ask + // the composer to focus explicitly — the caller still navigates/redirects. + requestComposerFocus() + return reusable } + sessionState.sessions = sessionState.sessions.filter((s) => !s.transient) const existingNumbers = sessionState.sessions .map((s) => /^session-(\d+)$/.exec(s.name)?.[1]) .map((n) => (n ? parseInt(n, 10) : 0)) @@ -691,23 +682,20 @@ export function createSession(): Session { } sessionState.sessions = [session, ...sessionState.sessions] sessionState.currentSessionId = session.id - // Transient until first send: no DB record yet, but the draft slot keeps it - // (name, workspace/fork choice, prompt) across reloads. - writeTransientDraft(session) + // Transient until first touch: no DB record yet. Persisting is deferred to the + // first user edit (the mutation helpers below route through persistTouched). return session } -// Promote an in-memory transient session to a persisted one. No-op when -// the session isn't transient. Called by the chat manager's beforeSend -// hook so the session is only written to localStorage once the user -// commits to it by sending their first message. +// Promote an in-memory transient session to a persisted IndexedDB record. +// No-op when the session isn't transient (already persisted by a prior touch). +// Called on the first genuine user touch (via persistTouched) and, idempotently, +// from the chat manager's beforeSend so a send always hits a persisted record. export function materializeTransient(id: string): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s || !s.transient) return delete s.transient void putSession(s) - // Promoted to IndexedDB — the localStorage draft slot is now stale. - clearTransientDraft() } export function setSessionPendingWorkspace(id: string, workspace_id: string) { @@ -717,7 +705,7 @@ export function setSessionPendingWorkspace(id: string, workspace_id: string) { s.pending_workspace_id = workspace_id // Picking an existing workspace cancels any pending fork intent. s.pending_fork = undefined - if (changed) void putSession(s) + if (changed) persistTouched(s) } // Records the user's intent to create a new fork without firing the API @@ -727,7 +715,7 @@ export function setSessionPendingFork(id: string, fork: PendingFork) { if (!s) return s.pending_fork = { ...fork } s.pending_workspace_id = fork.parent_workspace_id - void putSession(s) + persistTouched(s) } // One-shot commit: locks in workspace_id at first user-message send. @@ -742,6 +730,9 @@ export async function commitSessionWorkspace( const s = sessionState.sessions.find((x) => x.id === id) if (!s) return undefined if (s.workspace_id) return s.workspace_id + // A commit is a send: the record must be durable regardless of prior touches + // (a draft sent without ever being touched is still transient here). + if (s.transient) delete s.transient if (s.pending_fork) { const fork = s.pending_fork @@ -774,6 +765,8 @@ export async function commitSessionWorkspace( s.pending_fork = undefined s.pending_workspace_id = undefined s.workspace_root_id = workspaceRootId(newId, get(userWorkspaces)) ?? newId + // The draft prompt has been consumed as the first message. + delete s.draftPrompt await putSession(s) // The global workspaceStore is intentionally left untouched: the session // chat targets its own workspace via AIChatManager.operatingWorkspace, so @@ -786,6 +779,8 @@ export async function commitSessionWorkspace( s.workspace_id = ws s.pending_workspace_id = undefined s.workspace_root_id = workspaceRootId(ws, get(userWorkspaces)) ?? ws + // The draft prompt has been consumed as the first message. + delete s.draftPrompt await putSession(s) // The global workspaceStore is intentionally left untouched (see the fork // branch above): the session chat reads its committed workspace through the @@ -800,32 +795,29 @@ export function getEffectiveWorkspaceId(session: Session): string | undefined { return session.workspace_id ?? session.pending_workspace_id } -// Persist the session's preview tabs. Fire-and-forget write-behind (transient -// sessions land in the localStorage draft slot). +// Persist the session's preview tabs (a touch — see persistTouched). export function setSessionTabs(id: string, tabs: SessionPreviewTab[], activeTabId: string): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s) return s.previewTabs = tabs.map((t) => ({ ...t })) s.activePreviewTabId = activeTabId - void putSession(s) + persistTouched(s) } -// Persist whether the preview panel is collapsed for this session. Fire-and-forget -// write-behind (transient sessions land in the localStorage draft slot). +// Persist whether the preview panel is collapsed for this session (a touch). export function setSessionPreviewCollapsed(id: string, collapsed: boolean): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s || !!s.previewCollapsed === collapsed) return s.previewCollapsed = collapsed - void putSession(s) + persistTouched(s) } -// Persist the preview split size the user dragged for this session. Fire-and-forget -// write-behind (transient sessions land in the localStorage draft slot). +// Persist the preview split size the user dragged for this session (a touch). export function setSessionPreviewSize(id: string, size: number): void { const s = sessionState.sessions.find((x) => x.id === id) if (!s || s.previewSize === size) return s.previewSize = size - void putSession(s) + persistTouched(s) } export function selectSession(id: string) { @@ -838,7 +830,7 @@ export function renameSession(id: string, newSummary: string) { if (!s) return s.summary = trimmed.length > 0 ? trimmed : undefined s.summarySource = 'manual' - void putSession(s) + persistTouched(s) } export function setGeneratedSessionSummary( @@ -944,13 +936,12 @@ export function setSessionArchived(id: string, archived: boolean) { delete s.archived delete s.archivedByWorkspace } - void putSession(s) + persistTouched(s) } export function deleteSession(id: string) { const s = sessionState.sessions.find((x) => x.id === id) if (!s) return - if (s.transient) clearTransientDraft() sessionState.sessions = sessionState.sessions.filter((x) => x.id !== id) if (sessionState.currentSessionId === id) { sessionState.currentSessionId = sessionState.sessions[0]?.id diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts index 43694e9f3f..1a74f7c6ab 100644 --- a/frontend/src/lib/components/sessions/sessionState.test.ts +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -338,33 +338,34 @@ describe('sessionInCurrentFamily', () => { }) }) -describe('createSession — transient reuse is family-scoped', () => { - it('reuses a transient from the active family', () => { +describe('createSession — reuses an untouched draft, family-scoped', () => { + it('reuses an untouched (transient) draft from the active family', () => { const restore = withTwoFamilies('rootA') const prevCurrent = sessionState.currentSessionId - const transient = session({ - id: 'transient-same-family', + const untouched = session({ + id: 'untouched-same-family', name: 'session-901', pending_workspace_id: 'forkA', transient: true }) - sessionState.sessions.push(transient) + sessionState.sessions.push(untouched) try { const created = createSession() - expect(created.id).toBe('transient-same-family') - expect(sessionState.currentSessionId).toBe('transient-same-family') + // No new entry piled up: `+` switched back to the pristine draft. + expect(created.id).toBe('untouched-same-family') + expect(sessionState.currentSessionId).toBe('untouched-same-family') } finally { - sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'transient-same-family') + sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'untouched-same-family') sessionState.currentSessionId = prevCurrent restore() } }) - it('drops a transient left over from another family and starts in the active workspace', () => { + it('drops an untouched draft left over from another family and starts in the active workspace', () => { const restore = withTwoFamilies('rootB') const prevCurrent = sessionState.currentSessionId const stale = session({ - id: 'transient-other-family', + id: 'untouched-other-family', name: 'session-902', pending_workspace_id: 'forkA', transient: true @@ -374,12 +375,40 @@ describe('createSession — transient reuse is family-scoped', () => { try { const created = createSession() createdId = created.id - expect(created.id).not.toBe('transient-other-family') + expect(created.id).not.toBe('untouched-other-family') expect(created.pending_workspace_id).toBe('rootB') - expect(sessionState.sessions.some((s) => s.id === 'transient-other-family')).toBe(false) + expect(sessionState.sessions.some((s) => s.id === 'untouched-other-family')).toBe(false) } finally { sessionState.sessions = sessionState.sessions.filter( - (s) => s.id !== 'transient-other-family' && s.id !== createdId + (s) => s.id !== 'untouched-other-family' && s.id !== createdId + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) + + it('does not reuse a touched (persisted) pending session — those spawn a fresh draft', () => { + const restore = withTwoFamilies('rootA') + const prevCurrent = sessionState.currentSessionId + // Touched pending session: persisted (no transient flag), same family. + const touched = session({ + id: 'touched-same-family', + name: 'session-903', + pending_workspace_id: 'rootA', + draftPrompt: 'already typed' + }) + sessionState.sessions.push(touched) + let createdId: string | undefined + try { + const created = createSession() + createdId = created.id + expect(created.id).not.toBe('touched-same-family') + expect(created.transient).toBe(true) + // Both coexist: a touched draft stays put, the new blank is its own entry. + expect(sessionState.sessions.some((s) => s.id === 'touched-same-family')).toBe(true) + } finally { + sessionState.sessions = sessionState.sessions.filter( + (s) => s.id !== 'touched-same-family' && s.id !== createdId ) sessionState.currentSessionId = prevCurrent restore() diff --git a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts index 2e2fd9e21f..1e96bb8010 100644 --- a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts +++ b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts @@ -38,8 +38,9 @@ import { archiveSessionsForWorkspace, deleteSessionsForWorkspace, materializeTransient, - peekTransientDraftPrompt, - queueTransientDraftPrompt, + getSessionDraftPrompt, + setSessionDraftPrompt, + setSessionTabs, reconcileSessionsLifecycle, setSessionArchived, setSessionPreviewSize, @@ -94,49 +95,43 @@ describe('sessionState IndexedDB persistence', () => { await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['s2', 's1'])) }) - it('keeps a transient session as a user-scoped localStorage draft, not in IndexedDB', async () => { + it('does not persist a transient (untouched) session — it is in-memory only', async () => { const user = freshUser() userStore.set(user) await flush() - await putSession(session({ id: 't1', transient: true })) - // Same user reload: the draft is restored, still transient (i.e. it came - // from the localStorage slot — an IndexedDB record would have the flag - // stripped by materialisation). - await rehydrate(user) - await flush() - expect(sessionState.sessions.map((s) => ({ id: s.id, transient: s.transient }))).toEqual([ - { id: 't1', transient: true } - ]) - - // The slot is user-scoped: another user sees nothing. - await rehydrate(freshUser()) - await flush() - expect(sessionState.sessions).toEqual([]) + const s = session({ id: 't1', transient: true, pending_workspace_id: 'wsA' }) + sessionState.sessions = [s] + // putSession no-ops for a transient session: nothing reaches IndexedDB. + await putSession(s) + const db = await openDB(`windmill-sessions::${user.email}`, 1) + const all = (await db.getAll('sessions' as never)) as Session[] + db.close() + expect(all).toEqual([]) }) - it('round-trips a transient session preview state through the draft slot', async () => { + it('persists a pending session to IndexedDB on first touch, keeping its pending workspace and tabs', async () => { const user = freshUser() userStore.set(user) await flush() - await putSession( - session({ - id: 't1b', - transient: true, - previewTabs: [{ id: 'session', url: '/x', loc: '/x' }], - activePreviewTabId: 'session', - previewCollapsed: false, - previewSize: 70 - }) - ) - await rehydrate(user) - await flush() - const restored = sessionState.sessions.find((s) => s.id === 't1b') - expect(restored?.previewTabs).toEqual([{ id: 'session', url: '/x', loc: '/x' }]) - expect(restored?.activePreviewTabId).toBe('session') - expect(restored?.previewCollapsed).toBe(false) - expect(restored?.previewSize).toBe(70) + const s = session({ id: 't1b', transient: true, pending_workspace_id: 'wsA' }) + sessionState.sessions = [s] + // A genuine touch (opening a preview tab) promotes the draft out of the + // in-memory-only state and writes it through. + setSessionTabs('t1b', [{ id: 'session', url: '/x', loc: '/x' }], 'session') + expect(s.transient).toBeUndefined() + + const db = await openDB(`windmill-sessions::${user.email}`, 1) + const rec = await vi.waitFor(async () => { + const r = (await db.get('sessions' as never, 't1b')) as Session | undefined + expect(r).toBeTruthy() + return r! + }) + db.close() + expect(rec.transient).toBeUndefined() + expect(rec.pending_workspace_id).toBe('wsA') + expect(rec.previewTabs).toEqual([{ id: 'session', url: '/x', loc: '/x' }]) }) it('setSessionPreviewSize persists a dragged width and round-trips it', async () => { @@ -156,52 +151,68 @@ describe('sessionState IndexedDB persistence', () => { expect(sessionState.sessions.find((x) => x.id === 'ps1')?.previewSize).toBe(42) }) - it('materializeTransient promotes the draft to IndexedDB and clears the slot', async () => { + it('materializeTransient promotes an in-memory draft to a persisted IndexedDB record', async () => { const user = freshUser() userStore.set(user) await flush() const s = session({ id: 't2', transient: true }) sessionState.sessions = [s] - await putSession(s) - expect(localStorage.getItem(`wm_session_transient_draft::${user.email}`)).not.toBeNull() - materializeTransient('t2') - await flush() - expect(localStorage.getItem(`wm_session_transient_draft::${user.email}`)).toBeNull() + expect(s.transient).toBeUndefined() await rehydrate(user) await vi.waitFor(() => expect(sessionState.sessions.map((x) => x.id)).toEqual(['t2'])) expect(sessionState.sessions[0].transient).toBeUndefined() }) - it('round-trips the unsent prompt through the draft slot', async () => { + it('round-trips the unsent draft prompt on the session record', async () => { const user = freshUser() userStore.set(user) await flush() - const s = session({ id: 't3', transient: true }) + const s = session({ id: 't3', transient: true, pending_workspace_id: 'wsA' }) sessionState.sessions = [s] - await putSession(s) - queueTransientDraftPrompt('t3', 'draft prompt') - // The prompt write-behind debounces 400ms. - await new Promise((r) => setTimeout(r, 450)) + // Typing is a touch: it sets draftPrompt and persists (debounced 400ms). + setSessionDraftPrompt('t3', 'draft prompt') + await new Promise((r) => setTimeout(r, 500)) await rehydrate(user) - await flush() - expect(sessionState.sessions.map((x) => x.id)).toEqual(['t3']) - expect(peekTransientDraftPrompt('t3')).toBe('draft prompt') + await vi.waitFor(() => expect(sessionState.sessions.map((x) => x.id)).toEqual(['t3'])) + expect(getSessionDraftPrompt('t3')).toBe('draft prompt') }) - it('deleteSession discards the transient draft', async () => { + it('persists parallel drafts independently — a keystroke in one never cancels another', async () => { + const user = freshUser() + userStore.set(user) + await flush() + + const a = session({ id: 'da', transient: true, pending_workspace_id: 'wsA' }) + const b = session({ id: 'db', transient: true, pending_workspace_id: 'wsA' }) + sessionState.sessions = [a, b] + // Interleave within the 400ms debounce window: b's keystroke must not clear + // a's pending flush (guards the per-session timer against a shared handle). + setSessionDraftPrompt('da', 'alpha') + setSessionDraftPrompt('db', 'beta') + await new Promise((r) => setTimeout(r, 500)) + + await rehydrate(user) + await vi.waitFor(() => + expect(sessionState.sessions.map((x) => x.id).sort()).toEqual(['da', 'db']) + ) + expect(getSessionDraftPrompt('da')).toBe('alpha') + expect(getSessionDraftPrompt('db')).toBe('beta') + }) + + it('deleteSession removes an in-memory transient draft', async () => { const user = freshUser() userStore.set(user) await flush() const s = session({ id: 't4', transient: true }) sessionState.sessions = [s] - await putSession(s) deleteSession('t4') + expect(sessionState.sessions).toEqual([]) await rehydrate(user) await flush() @@ -248,8 +259,7 @@ describe('sessionState IndexedDB persistence', () => { // A starts an unsent draft — transient, in-memory only, never persisted. sessionState.sessions = [session({ id: 'a-draft', transient: true }), ...sessionState.sessions] - // Switch to B: A's transient must not bleed into B's list (it would - // otherwise be reused by createSession and inherit A's pending state). + // Switch to B: A's in-memory draft must not bleed into B's list. userStore.set(b) await vi.waitFor(() => { expect(sessionState.sessions.some((s) => s.id === 'a-draft')).toBe(false) @@ -458,6 +468,52 @@ describe('sessionState IndexedDB persistence', () => { }) }) + it('deletes a persisted pending draft when its pending workspace is deleted', async () => { + const user = freshUser() + usersWorkspaceStore.set({ + email: user.email, + workspaces: [{ id: 'pending-ws', name: 'pending', disabled: false }] as never + }) + userStore.set(user) + await flush() + // A touched (persisted) but still-unsent draft scoped to its pre-send workspace. + await putSession(session({ id: 'draft', createdAt: 1, pending_workspace_id: 'pending-ws' })) + + // Reconcile keyed on pending_workspace_id: a deleted pre-send workspace deletes + // the draft. Read the DB directly — reconcile works off it, not in-memory state. + vi.mocked(WorkspaceService.getSessionWorkspaceStatus).mockResolvedValueOnce({ + 'pending-ws': 'deleted' + } as never) + await reconcileSessionsLifecycle() + + const db = await openDB(`windmill-sessions::${user.email}`, 1) + const rec = await db.get('sessions' as never, 'draft') + db.close() + expect(rec).toBeUndefined() + }) + + it('archives a persisted pending draft (tagged) when its pending workspace is archived', async () => { + const user = freshUser() + usersWorkspaceStore.set({ + email: user.email, + workspaces: [{ id: 'pending-ws2', name: 'pending', disabled: false }] as never + }) + userStore.set(user) + await flush() + await putSession(session({ id: 'draft2', createdAt: 1, pending_workspace_id: 'pending-ws2' })) + + vi.mocked(WorkspaceService.getSessionWorkspaceStatus).mockResolvedValueOnce({ + 'pending-ws2': 'archived' + } as never) + await reconcileSessionsLifecycle() + + const db = await openDB(`windmill-sessions::${user.email}`, 1) + const rec = (await db.get('sessions' as never, 'draft2')) as Session + db.close() + expect(rec.archived).toBe(true) + expect(rec.archivedByWorkspace).toBe(true) + }) + it('clears the in-memory list on logout', async () => { const user = freshUser() userStore.set(user) diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index 45d72ae3ae..38ca3bf6dd 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -75,10 +75,9 @@ export async function openEditorInSession( target: SessionTarget, workspaceId?: string ): Promise { - // createSession() reuses an existing transient draft, whose preview tabs - // (persisted with the draft and/or held by a live runtime) may still show a - // different item — so seed the preview with a single tab on `target`, resetting - // whatever it was showing. + // Seed the fresh session's preview with a single tab on `target` so it opens + // straight onto the editor the caller wants (resetSessionPreviewTabs also + // writes through a live runtime if one already exists for this id). const session = createSession() if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId) const url = sessionTargetHref(target) From 15391f6399eef5b85dac116b3ae04e71f70263a1 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 14 Jul 2026 15:15:48 +0200 Subject: [PATCH 45/52] perf(runs): index-bound batch re-run selection with a lossless completed_at bound (WIN-2168) (#10074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Re-run all jobs matching filters" selects completed jobs via list_filtered_uuids windowed by started_before/started_after (the timeframe). v2_job_completed has no index on started_at (only completed_at), so that filter alone forces a workspace-wide seq scan — a query observed at ~48s on a large instance. started_at >= minTs implies completed_at >= minTs (a job completes at/after it starts), so adding completedAfter = minTs is a lossless bound: it drops no row the started_at window keeps, but lets the (workspace_id, completed_at DESC) index start the scan at the window's lower edge instead of scanning the whole table. The selected cohort is unchanged (started_at stays the exact filter); this is purely a plan improvement. EXPLAIN: seq scan -> completed_at index scan. Not completedBefore: a job can start in-window but finish after maxTs, and bounding completed_at above would drop it. Scoped to re-run; batch cancel (v2_job_queue, small) is untouched. Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/lib/components/RunsPage.svelte | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte index b520faec7a..9b447a7d0c 100644 --- a/frontend/src/lib/components/RunsPage.svelte +++ b/frontend/src/lib/components/RunsPage.svelte @@ -427,8 +427,13 @@ loadingToast.destroy() return } + // started_at is unindexed on v2_job_completed, so windowing by it alone seq-scans the + // workspace. started_at >= minTs implies completed_at >= minTs, so completedAfter adds a + // lossless indexed lower bound ((workspace_id, completed_at DESC)); started_at stays the + // exact recheck. (completedBefore is omitted: it would drop jobs that finish after maxTs.) selectedIds = await JobService.listFilteredJobsUuids({ ...selectedFilters, + completedAfter: selectedFilters.startedAfter, jobKinds: 'script,flow' }) loadingToast.destroy() From f2869d8c1a6f84837168d59724a496b39080bcce Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Tue, 14 Jul 2026 15:17:26 +0200 Subject: [PATCH 46/52] =?UTF-8?q?fix(apps):=20load=20themes=20when=20selec?= =?UTF-8?q?ting=20the=20Resources=20=E2=86=92=20Theme=20tab=20(#10086)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Resources page dispatches per-tab data loads from the Tabs `on:selected` handler and `reload()`, but both only handled `cache` and `states` — selecting the Theme tab never called `loadTheme()`, so `themeResources` stayed undefined and the tab rendered empty even though app themes existed. The reload `$effect` reads `tab` inside `untrack`, so it didn't re-fire on tab change either (only a filter or workspace change did, which is why typing in the filter "fixed" it). Add the missing `theme` branch in both places. Co-authored-by: Claude Opus 4.8 (1M context) --- frontend/src/routes/(root)/(logged)/resources/+page.svelte | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index 7f5a104d33..5c0f538d3f 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -449,6 +449,8 @@ await loadCache() } else if (tab == 'states') { await loadState() + } else if (tab == 'theme') { + await loadTheme() } else { await loadResources() } @@ -902,6 +904,9 @@ } else if (e.detail == 'states') { loading.resources = true loadState() + } else if (e.detail == 'theme') { + loading.resources = true + loadTheme() } }} > From eff9076e9127eaa9b6a47897d5664932c720990b Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 14 Jul 2026 15:52:20 +0200 Subject: [PATCH 47/52] fix(sessions): reopen script test panel when preview goes full screen (#10082) Co-authored-by: Claude Opus 4.8 (1M context) --- .../src/lib/components/ScriptBuilder.svelte | 4 +- .../src/lib/components/ScriptEditor.svelte | 41 +++++++++++++++---- frontend/src/lib/components/script_builder.ts | 7 ++-- .../components/sessions/PreviewTabHost.svelte | 6 ++- .../sessions/ScriptEditorView.svelte | 8 ++-- .../(root)/(logged)/sessions/+page.svelte | 1 + 6 files changed, 49 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 9b55b86165..d7214e9a24 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -135,7 +135,7 @@ onNavigate, onTestJob, disableAi, - initialTestPanelCollapsed = false, + testPanelCollapsed = false, initialPathChosen = false, onResetToDeployed, loadedFromDraft = false, @@ -2152,7 +2152,7 @@ bind:assets={script.assets} bind:modules={script.modules} enablePreprocessorSnippet - {initialTestPanelCollapsed} + {testPanelCollapsed} />
{:else} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 710f0c2bdb..aaca5752c1 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -210,11 +210,13 @@ // Fired whenever a test run is started from this editor, with the // preview job id. Used by whitelabel embedders to track test jobs. onTestJob?: (e: { jobId: string }) => void - // When true the right-hand test/run pane mounts collapsed. The user - // can still expand it via `toggleTestPanel`. Defaults to false so the - // regular /scripts/edit route keeps its current open-by-default UX; - // the session preview opts in to save vertical real estate. - initialTestPanelCollapsed?: boolean + // Drives the right-hand test/run pane collapsed state. Seeds the pane + // collapsed at mount when true, and is edge-triggered afterwards: a later + // change collapses/expands the pane (but the user's own toggles in between + // are preserved — the effect only acts on a transition). Defaults to false + // so the regular /scripts/edit route keeps its open-by-default UX; the + // session preview collapses it to save space and reopens it in full screen. + testPanelCollapsed?: boolean // Lets the AI toolbar button open the script in a fresh AI session // instead of the inline chat panel (see OpenInSessionButton for gating). sessionOpen?: OpenInSessionSource @@ -269,7 +271,7 @@ previewLayout = 'right', onTestStateChange, onTestJob, - initialTestPanelCollapsed = false, + testPanelCollapsed = false, sessionOpen = undefined, schemaContractContext = undefined, workspaceOverride = undefined @@ -1626,10 +1628,10 @@ // dynamic minimum below — so when the editor shrinks, the displayed test // pane grows to honor the new minimum without needing an effect. The code // pane's size is purely derived from it (100 - test). - // `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while + // `testPanelCollapsed` seeds the raw value at 0 (collapsed) while // keeping the "remembered" size at 30, so the user's first toggle expands // the pane to a sensible width rather than 0. - let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30))) + let rawTestPanelSize = $state(untrack(() => (testPanelCollapsed ? 0 : 30))) let storedTestPanelSize = 30 const testPanelSize = $derived( rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent) @@ -1637,7 +1639,12 @@ const codePanelSize = $derived(100 - testPanelSize) function expandTestPanel() { - rawTestPanelSize = Math.max(storedTestPanelSize, testPaneMinPercent) + // Restore the remembered *intent* only. `testPanelSize` clamps up to the + // dynamic pixel-min reactively, so we must NOT bake `testPaneMinPercent` + // into the raw size here: when the container is still narrow (e.g. the + // frame the session preview enters full screen, before the pane widens), + // that min is a huge fraction and would stick as an oversized pane. + rawTestPanelSize = storedTestPanelSize } function collapseTestPanel() { @@ -1655,6 +1662,22 @@ } } + // React to an external `testPanelCollapsed` change (e.g. the session preview + // entering/leaving full screen) without clobbering the user's own toggles: + // only act on a genuine transition, reading the live size untracked so a drag + // never re-runs this. The mount seed already matches `testPanelCollapsed`, so + // the initial run is a no-op. + $effect(() => { + const collapsed = testPanelCollapsed + untrack(() => { + if (collapsed && testPanelSize > 0) { + collapseTestPanel() + } else if (!collapsed && testPanelSize === 0) { + expandTestPanel() + } + }) + }) + // When the compact preview shows a SchemaForm above the logs // (`argsAboveLogs`), give the preview pane extra height so the args // form doesn't shrink the logs/result area. This is a deliberate diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index 0609c6737d..69916f321b 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -78,9 +78,10 @@ export interface ScriptBuilderProps { // Fired whenever a test run is started from the script editor, with the // preview job id. Used by whitelabel embedders to track test jobs. onTestJob?: (e: { jobId: string }) => void - // Forwarded to the underlying ScriptEditor. When true, the right-hand - // test/run pane opens collapsed. Used by the session preview. - initialTestPanelCollapsed?: boolean + // Forwarded to the underlying ScriptEditor. Seeds the right-hand test/run + // pane collapsed, and edge-triggers a collapse/expand on later changes. The + // session preview drives it from full-screen state. + testPanelCollapsed?: boolean // Treat the path as already chosen (seeds the path "dirty" flag) so the // summary→path auto-slug for new scripts (initialPath == '') doesn't // overwrite it. Used by the session preview, which opens AI-created scripts diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index e0d1f4c971..7ee4b02b12 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -23,6 +23,7 @@ mounted, label, darkMode, + fullscreen = false, onNavigate, onLoad }: { @@ -31,6 +32,9 @@ runtime: SessionRuntime | undefined /** Visible tab — only one is at a time; the rest stay mounted but hidden. */ active: boolean + /** Preview panel is in full screen — forwarded to editor views so a script + * editor reopens its test pane when there's room. */ + fullscreen?: boolean /** Lazy-mount gate: content only renders once the tab has been activated. */ mounted: boolean /** Short tab label, for the iframe title. */ @@ -122,7 +126,7 @@ {onNavigate} {isActiveSession} {active} - initialTestPanelCollapsed + {fullscreen} /> {:else if slot.editorKind === 'pipeline'} diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index e040fec744..0118d1f0c3 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -15,7 +15,7 @@ path, workspaceId, onNavigate, - initialTestPanelCollapsed = false, + fullscreen = false, isActiveSession = true, active = true }: { @@ -23,7 +23,9 @@ path: string workspaceId: string onNavigate?: (item: WorkspaceItem) => void - initialTestPanelCollapsed?: boolean + /** Preview panel is in full screen: collapse the test pane in the narrow + * side-by-side layout, reopen it when there's room in full screen. */ + fullscreen?: boolean /** Forwarded to SessionEditorTarget — only the visible session claims the * workspace's single live-editor slot. */ isActiveSession?: boolean @@ -112,7 +114,7 @@ condensedHeader={true} {diffDrawer} {onNavigate} - {initialTestPanelCollapsed} + testPanelCollapsed={!fullscreen} onDeploy={(e) => { // Fires on every deploy (primary, "Deploy & Stay here", and lib — we // ignore e.stay since the session always stays). Toast, then sync the diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 4baa06c819..1e0bd25333 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -761,6 +761,7 @@ mounted={mountedTabKeys.has(tabKey(s.id, tab.id))} label={tabLabelFor(tab)} darkMode={isDarkMode.val} + {fullscreen} onNavigate={navigateEditorTo} onLoad={(frame) => tabs && onTabLoad(tabs, tab, frame)} /> From 89bb63cff531f806f5a9f435a7dda303400eb3c3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 14 Jul 2026 16:58:24 +0200 Subject: [PATCH 48/52] ci: drop debuginfo in backend integration tests to prevent runner OOM (#10088) Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/backend-test.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 5a6dc27b82..3d6d7d8dee 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -246,6 +246,18 @@ jobs: RUST_LOG_STYLE: never CARGO_NET_GIT_FETCH_WITH_CLI: true CARGO_BUILD_JOBS: 12 + # backend/Cargo.toml leaves profile.dev at the default debug = 2 for + # the (large) windmill workspace crates; that debug info is emitted + # into every object file and embedded in each test binary. Across the + # full --all --features build it is the dominant memory/disk consumer + # when mold links the windmill-api-integration-tests binary, tipping + # the runner over (lost runner reported as a canceled step). CI needs + # no debug info, so drop it entirely for the dev/test profiles here. + # (test profile inherits dev, but the workspace crates link in as + # dev-profile deps, so both must be set.) CI-only; local dev builds + # are unaffected. + CARGO_PROFILE_DEV_DEBUG: "0" + CARGO_PROFILE_TEST_DEBUG: "0" # Tests' poll-time stack frames (deep nested async fn chains in # debug builds) reach ~1.8MB, leaving very thin headroom on the # default 2MB thread stack. 4MB gives ~2x buffer against flaky From 32f32d9a29bb214d6aae58c501b8892ceb1c6453 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 14 Jul 2026 17:18:01 +0200 Subject: [PATCH 49/52] feat(ai-chat): port flow-group and sticky-note instructions to global chat (#10090) * feat(ai-chat): port flow-group and sticky-note instructions to global mode Global-mode AI chat inherited only the bare FlowGroup schema and had no sticky-note support, so it never proactively segmented flows into groups and could not author flow-wide notes. Flow mode carried this guidance inline in its own prompt and set_flow_json tool. Bring global mode to parity: - Enrich write_flow's `groups` description (color palette + fields) and add a `notes` field mirroring flow mode's set_flow_json. - Thread `notes` through editableFlowToDraftValue and the write_flow handler so notes reach FlowValue.value and survive the deploy round-trip. Reads and patch_flow_json already carried notes via the shared editableFlowJson helpers. - Expand getFlowInstructions with the groups/notes organizing guidance (strongly-recommended proactive grouping, color palette, when-to-use-which) and mention notes in the write/read/compact-view/structural-edit bullets. Add a write_flow -> read_workspace_item notes round-trip test. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(ai-chat): trim write_flow groups/notes schema descriptions The write_flow tool schema is re-sent every chat loop iteration, so the verbose groups/notes descriptions were a per-iteration token tax that duplicated the on-demand getFlowInstructions() prose. Trim the .describe() calls to the correctness-critical bits (color palette, type "free", null semantics) and point to get_instructions for the full field reference, which getFlowInstructions() already carries. Addresses CI review feedback (Claude + Pi). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../copilot/chat/global/core.test.ts | 39 +++++++++++++++++++ .../components/copilot/chat/global/core.ts | 37 ++++++++++++++---- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 2438302a00..d65452f1d9 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -2760,6 +2760,45 @@ describe('global AI tools', () => { expect(item.value.value).toBeUndefined() }) + it('writes and reads back free-floating flow notes', async () => { + const writeResult = JSON.parse( + await callGlobalTool('write_flow', { + path: 'f/flows/with-notes', + summary: 'Flow with notes', + modules: JSON.stringify([ + { + id: 'start', + summary: 'Start', + value: { type: 'identity' } + } + ]), + notes: JSON.stringify([ + { id: 'n1', type: 'free', text: 'What this flow does', color: 'blue' } + ]) + }) + ) + + expect(writeResult.success).toBe(true) + + const item = JSON.parse( + await callGlobalTool('read_workspace_item', { + type: 'flow', + path: 'f/flows/with-notes' + }) + ) + + expect(item.value.notes).toHaveLength(1) + expect(item.value.notes[0]).toMatchObject({ + id: 'n1', + type: 'free', + text: 'What this flow does', + color: 'blue' + }) + // Free notes with no explicit geometry get auto-placed/sized by validation. + expect(item.value.notes[0].position).toBeDefined() + expect(item.value.notes[0].size).toBeDefined() + }) + it('test_run_script previews draft script content by path', async () => { const content = 'export async function main(name: string) {\n\treturn `hello ${name}`\n}' await callGlobalTool('write_script', { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 7dffbe1fec..738de05caf 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -363,7 +363,14 @@ const writeFlowSchema = z.object({ .optional() .nullable() .describe( - 'JSON string containing the optional array of semantic flow groups. Pass null to clear groups.' + 'JSON string, array of semantic flow groups (call get_instructions subject:"flow" for the full field reference). color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes. Pass null to clear groups.' + ), + notes: z + .string() + .optional() + .nullable() + .describe( + 'JSON string, array of free-floating sticky notes (type must be "free"; call get_instructions subject:"flow" for the full field reference). color MUST be one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray — never hex codes. Pass null to clear notes.' ), override: draftOverrideField }) @@ -386,7 +393,8 @@ function editableFlowToDraftValue(editable: EditableFlowJson): FlowDraftValue { modules: editable.modules, preprocessor_module: editable.preprocessor_module ?? undefined, failure_module: editable.failure_module ?? undefined, - groups: editable.groups ?? undefined + groups: editable.groups ?? undefined, + notes: editable.notes ?? undefined } return { value, @@ -1620,20 +1628,34 @@ function getFlowInstructions(): string { - Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata. - Paths follow the conventions in the system prompt: default to \`u//\` when the user gave a bare name; only use \`f//\` when the folder is known to exist. Never invent a folder. -- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. -- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, and \`groups\`. +- \`write_flow\` mirrors flow mode's \`set_flow_json\`: pass \`path\`, optional \`summary\`, optional \`description\`, required \`modules\`, and optional \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`. \`summary\` and \`description\` are top-level flow metadata (not part of the compact value \`patch_flow_json\` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. +- \`read_workspace_item\` returns a compact flow \`value\` object with \`modules\`, \`schema\`, \`preprocessor_module\`, \`failure_module\`, \`groups\`, and \`notes\`. - \`modules\` contains normal sequential modules. Use top-level \`preprocessor_module\` and \`failure_module\` for special modules; do not put \`preprocessor\` or \`failure\` in \`modules\`. - Every module needs a stable unique \`id\` and a useful \`summary\` when the schema supports it. - Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed. - When writing rawscript module code, call \`get_instructions\` with \`subject: "script"\` and the rawscript language first. +## Organizing flows: groups and notes + +- \`groups\`: Array of semantic groups for organizing modules in the editor (optional, but **strongly recommended** — proactively segment any non-trivial flow into groups so it reads clearly; don't wait to be asked). Each group has \`summary\` (display name), \`note\` (markdown description shown below the group header — attached directly to the group, not a separate sticky note), \`autocollapse\`, \`start_id\`, \`end_id\`, and \`color\`. \`start_id\` and \`end_id\` must reference existing module IDs in the flow (not \`preprocessor\` or \`failure\`). \`color\` MUST be one of these exact names: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — do NOT use hex codes, CSS colors, or any other strings. Omit \`color\` entirely if no preference and the editor will assign one automatically. Groups do not affect execution — they provide naming and collapsibility in the editor. Pass \`null\` to clear existing groups. +- \`notes\`: Array of free-floating sticky notes shown in the editor (optional). Each note has \`id\` (unique string), \`text\` (markdown content), \`color\` (same palette as groups: \`yellow\`, \`blue\`, \`green\`, \`purple\`, \`pink\`, \`orange\`, \`red\`, \`cyan\`, \`lime\`, \`gray\` — never hex codes), and optional \`position\` {x, y} / \`size\` {width, height} (omit both — the editor auto-places and sizes the note). Always set \`type\` to \`free\`. The \`group\` note type is **deprecated** — do not create group notes; use the \`groups\` field to segment a flow instead. Notes are documentation only and do not affect execution. Pass \`null\` to clear existing notes. + +### When to use notes vs groups + +**Strongly prefer \`groups\` to organize flows.** Groups are the primary way to make a flow readable: whenever a flow has more than a couple of steps, or any time consecutive steps form a logical stage (e.g. "fetch", "transform", "notify"), segment them into \`groups\`. Each group spans a range of steps (\`start_id\`..\`end_id\`), carries its own \`summary\`, \`note\` (markdown under the group header), and \`color\`, and can be collapsed. Proactively add or update groups when building or restructuring a flow — do not wait to be asked. Aim for every meaningful step to belong to a semantic group. + +- **\`groups\` (default, use liberally):** segment a flow into labelled semantic sections. This is the main organizational tool — reach for it on essentially any non-trivial flow, not just "complex" ones. +- **\`notes\` (free sticky notes, use sparingly):** reserve for important flow-wide information that does not belong to a specific span of steps — overall purpose, key assumptions, warnings, or TODOs. Usually a single note is enough; do not use notes to label sequences of steps (that is what \`groups\` are for). +- Do **not** use \`group\`-type notes (deprecated) — \`groups\` is the supported way to group steps. +- With \`patch_flow_json\`, edit \`groups\` and \`notes\` the same way as any other field — they appear as top-level keys in the compact flow value. + ## Compact view: how rawscript bodies surface in tool I/O -- \`read_workspace_item\` and \`patch_flow_json\` operate on a **compact view** of the flow: every rawscript module's \`value.content\` is replaced with the placeholder \`"inline_script."\` so inline script bodies don't bloat tool I/O. Schema, groups, preprocessor_module and failure_module are all shown in this view. +- \`read_workspace_item\` and \`patch_flow_json\` operate on a **compact view** of the flow: every rawscript module's \`value.content\` is replaced with the placeholder \`"inline_script."\` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view. - Inline rawscript content is **not** part of the JSON \`patch_flow_json\` sees. Edits to inline bodies happen via dedicated tools: - \`read_flow_module_code(path, module_id)\` — returns the raw inline script content for one module. - \`set_flow_module_code(path, module_id, code)\` — overwrites that module's inline script content; saves to the draft. -- Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups. Use \`set_flow_module_code\` for changes inside a specific rawscript body. +- Use \`patch_flow_json\` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups/notes. Use \`set_flow_module_code\` for changes inside a specific rawscript body. - \`write_flow\` is for full overwrites / create-from-scratch. Its \`modules\`, \`preprocessor_module\`, and \`failure_module\` arguments use **non-compact** flow modules (rawscript content is the actual code, not a placeholder). # Windmill flow authoring reference @@ -2376,7 +2398,8 @@ export const globalTools: Tool<{}>[] = [ 'preprocessor_module' ), failure_module: parseOptionalJsonArg(parsed.failure_module, 'failure_module'), - groups: parseOptionalJsonArg(parsed.groups, 'groups') + groups: parseOptionalJsonArg(parsed.groups, 'groups'), + notes: parseOptionalJsonArg(parsed.notes, 'notes') }) return writeFlowDraft( { From 98e6cca75d4dbc7417c7dd64289e0e8e56b84b0e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 14 Jul 2026 17:18:42 +0200 Subject: [PATCH 50/52] feat(cli): add --tag override to script and flow run/preview (#10079) * feat: custom tags on CLI runs, show previews in default runs view Co-Authored-By: Claude Opus 4.8 (1M context) * revert: don't include previews in default runs view Deferring the runs-view UX change; keeping only the CLI --tag work. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cli): forward --tag for codebase/bundle script previews The bundled-preview branch posts a multipart payload to /jobs/run/preview_bundle; --tag was only wired into the non-bundled runScriptPreview call, so codebase previews silently used the default tag. Include tag in the preview payload (backend reads preview.tag). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- cli/src/commands/flow/flow.ts | 18 +++++++++++++++++- cli/src/commands/script/script.ts | 13 +++++++++++++ cli/src/guidance/skills.gen.ts | 4 ++++ .../auto-generated/cli/cli-commands.md | 4 ++++ system_prompts/auto-generated/prompts.ts | 4 ++++ .../skills/cli-commands/SKILL.md | 4 ++++ 6 files changed, 46 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 71d90af6fe..17a5e1febb 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -405,6 +405,7 @@ async function run( opts: GlobalOptions & { data?: string; silent: boolean; + tag?: string; }, path: string ) { @@ -433,6 +434,7 @@ async function run( const id = await wmill.runFlowByPath({ workspace: workspace.workspaceId, path, + tag: opts.tag, requestBody: input, }); @@ -587,6 +589,7 @@ async function preview( silent: boolean; remote?: boolean; step?: string; + tag?: string; } & SyncOptions, flowPath: string ) { @@ -699,7 +702,7 @@ async function preview( const flowWmPath = stripFlowSuffix(flowPath).replaceAll(SEP, "/"); if (opts.step) { - await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent); + await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent, opts.tag); return; } @@ -714,6 +717,7 @@ async function preview( value: localFlow.value, path: flowWmPath, args: input, + tag: opts.tag, temp_script_refs: tempScriptRefs, }, }); @@ -747,6 +751,7 @@ async function previewStep( baseArgs: Record, tempScriptRefs: Record | undefined, silent: boolean, + tag: string | undefined, ) { const module = findStepInFlowValue(localFlow.value, stepId); if (!module) { @@ -778,6 +783,7 @@ async function previewStep( path: `${flowWmPath}/${stepId}`, flow_path: flowWmPath, args, + tag, temp_script_refs: tempScriptRefs, }, }); @@ -804,6 +810,7 @@ async function previewStep( path: moduleValue.path, flow_path: flowWmPath, args, + tag, temp_script_refs: tempScriptRefs, }, }); @@ -812,6 +819,7 @@ async function previewStep( jobId = await wmill.runFlowByPath({ workspace: workspace.workspaceId, path: moduleValue.path, + tag, requestBody: args, }); } else { @@ -1122,6 +1130,10 @@ const command = new Command() "-s --silent", "Do not ouput anything other then the final output. Useful for scripting." ) + .option( + "--tag ", + "Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag)." + ) .action(run as any) .command( "preview", @@ -1144,6 +1156,10 @@ const command = new Command() "--step ", "Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does." ) + .option( + "--tag ", + "Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag)." + ) .action(preview as any) .command( "generate-locks", diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index ed57b1747d..3db9c7b53e 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -1045,6 +1045,7 @@ async function run( opts: GlobalOptions & { data?: string; silent: boolean; + tag?: string; }, path: string ) { @@ -1075,6 +1076,7 @@ async function run( id = await wmill.runScriptByPath({ workspace: workspace.workspaceId, path, + tag: opts.tag, requestBody: input, }); } catch (e: any) { @@ -1486,6 +1488,7 @@ async function preview( opts: GlobalOptions & { data?: string; silent: boolean; + tag?: string; } & SyncOptions, filePath: string ) { @@ -1647,6 +1650,7 @@ async function preview( path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"), args: input, language: language, + tag: opts.tag, kind: isTar ? "tarbundle" : "bundle", format: codebase?.format ?? "cjs", temp_script_refs: tempScriptRefs, @@ -1716,6 +1720,7 @@ async function preview( path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"), args: input, language: language as any, + tag: opts.tag, modules: modules ?? undefined, temp_script_refs: tempScriptRefs, }, @@ -1842,6 +1847,10 @@ const command = new Command() "-s --silent", "Do not output anything other then the final output. Useful for scripting." ) + .option( + "--tag ", + "Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the script's default tag)." + ) .action(run as any) .command( "preview", @@ -1856,6 +1865,10 @@ const command = new Command() "-s --silent", "Do not output anything other than the final output. Useful for scripting." ) + .option( + "--tag ", + "Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag)." + ) .action(preview as any) .command("new", "create a new script") .arguments(" ") diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index a103f9ccc5..90b13b350d 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6700,11 +6700,13 @@ flow related commands - \`flow run \` - run a flow by path. - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting. + - \`--tag \` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). - \`flow preview \` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. - \`--step \` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. + - \`--tag \` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). - \`flow new \` - create a new empty flow - \`--summary \` - flow summary - \`--description \` - flow description @@ -7096,9 +7098,11 @@ script related commands - \`script run \` - run a script by path - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. + - \`--tag \` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the script's default tag). - \`script preview \` - preview a local script without deploying it. Supports both regular and codebase scripts. - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other than the final output. Useful for scripting. + - \`--tag \` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). - \`script new \` - create a new script - \`--summary \` - script summary - \`--description \` - script description diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 3d5d6429d4..67b5ec70df 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -158,11 +158,13 @@ flow related commands - `flow run ` - run a flow by path. - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting. + - `--tag ` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). - `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not output anything other then the final output. Useful for scripting. - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. - `--step ` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. + - `--tag ` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). - `flow new ` - create a new empty flow - `--summary ` - flow summary - `--description ` - flow description @@ -554,9 +556,11 @@ script related commands - `script run ` - run a script by path - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not output anything other then the final output. Useful for scripting. + - `--tag ` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the script's default tag). - `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts. - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not output anything other than the final output. Useful for scripting. + - `--tag ` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). - `script new ` - create a new script - `--summary ` - script summary - `--description ` - script description diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 41281390f6..a72b209e11 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2862,11 +2862,13 @@ flow related commands - \`flow run \` - run a flow by path. - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting. + - \`--tag \` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). - \`flow preview \` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. - \`--step \` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. + - \`--tag \` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). - \`flow new \` - create a new empty flow - \`--summary \` - flow summary - \`--description \` - flow description @@ -3258,9 +3260,11 @@ script related commands - \`script run \` - run a script by path - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. + - \`--tag \` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the script's default tag). - \`script preview \` - preview a local script without deploying it. Supports both regular and codebase scripts. - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other than the final output. Useful for scripting. + - \`--tag \` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). - \`script new \` - create a new script - \`--summary \` - script summary - \`--description \` - script description diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 3a61a67c12..8dc4a3d0f3 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -163,11 +163,13 @@ flow related commands - `flow run ` - run a flow by path. - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting. + - `--tag ` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). - `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not output anything other then the final output. Useful for scripting. - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files. - `--step ` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. + - `--tag ` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the flow's default tag). - `flow new ` - create a new empty flow - `--summary ` - flow summary - `--description ` - flow description @@ -559,9 +561,11 @@ script related commands - `script run ` - run a script by path - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not output anything other then the final output. Useful for scripting. + - `--tag ` - Override the worker tag the run is dispatched to (e.g. to route it to dev workers instead of the script's default tag). - `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts. - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-. - `-s --silent` - Do not output anything other than the final output. Useful for scripting. + - `--tag ` - Override the worker tag the preview is dispatched to (e.g. to route it to dev workers instead of the script's default tag). - `script new ` - create a new script - `--summary ` - script summary - `--description ` - script description From cfc3f292ad2fdc6067c558e42ef0754eca9469a9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 14 Jul 2026 17:19:42 +0200 Subject: [PATCH 51/52] fix(apps): allow setting sandbox isolation and public access before first deploy (#10085) * fix(apps): allow enabling sandbox isolation before first deploy Co-Authored-By: Claude Opus 4.8 (1M context) * fix(apps): allow setting public access mode before first deploy Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../apps/editor/AppEditorHeaderDeploy.svelte | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index f31339df70..880c24810d 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -299,7 +299,13 @@ checked={policy.sandbox == true} on:change={(e) => { policy.sandbox = e.detail || undefined - setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled') + // A not-yet-deployed app has no row to PATCH — `setPublishState` (POST + // /apps/update) would 404. The flag rides along in the `policy` the first + // deploy sends (createApp), so here we only mutate it locally. Persist + // incrementally once the app exists. + if (savedApp && !newApp) { + setPublishState(e.detail ? 'Sandbox isolation enabled' : 'Sandbox isolation disabled') + } }} disabled={!savedApp} /> @@ -310,8 +316,8 @@ on every surface (public URL and in-workspace). Leave it off if the app needs full browser features (IndexedDB, third-party auth/SDKs, OAuth redirects). - {#if !savedApp} -
Save the app once to change this setting.
+ {#if newApp} +
Takes effect when you first deploy this app.
{/if} {#if policy.sandbox == true}
@@ -343,9 +349,14 @@ checked={policy.execution_mode == 'anonymous'} on:change={(e) => { policy.execution_mode = e.detail ? 'anonymous' : 'publisher' - setPublishState() + // Same as sandbox: a not-yet-deployed app has no row to PATCH, so + // `setPublishState` would 404. The mode is carried by the first + // deploy's policy; persist incrementally only once the app exists. + if (savedApp && !newApp) { + setPublishState() + } }} - disabled={!savedApp || newApp || (!canSetAnonymous && policy.execution_mode != 'anonymous')} + disabled={!savedApp || (!canSetAnonymous && policy.execution_mode != 'anonymous')} />
{#if !savedApp || newApp} From 2c702efec026bebdd9c1b8cdca4808e394b2fd09 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 14 Jul 2026 19:02:22 +0200 Subject: [PATCH 52/52] fix(frontend): stop spurious raw-app reload that 404s on "Start without AI" (#10099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(frontend): stop spurious raw-app reload that 404s on "Start without AI" Creating a new raw app and clicking "Start without AI" surfaced an "App not found" toast. The page's load effect re-ran loadApp() mid-bootstrap and fetched the draft via getAppByPath before the first autosave POST had landed → 404. Root cause: the effect used the legacy run() from svelte/legacy without untrack, so loadApp()'s synchronous reactive read of the draft-hint SvelteMap (getLocalDraftHint via shouldSeedNewDraft, added in #10044) subscribed the effect. The first autosave optimistically flips that hint (#9351) before its debounced POST, re-firing the effect → spurious loadApp() → getAppByPath on a not-yet-persisted draft. Convert the block to $effect + untrack so it depends only on page.params.path / $workspaceStore, matching the sibling apps/edit and flows/edit routes. Autosave and draft persistence are unchanged; only the phantom reload is removed. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(frontend): tighten untrack invariant comment to ≤4 lines Per AGENTS.md comment policy (Codex review nit). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../apps_raw/edit/[...path]/+page.svelte | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 99d9137783..704f4a99eb 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 @@ -1,6 +1,5 @@