From f2b9c6d33c57f03fa3ecbdd7a405d3456535a216 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 15:09:45 +0200 Subject: [PATCH 01/30] count in-process preview pages in the ai evals active-preview resolver (#11725) * fix: count in-process preview pages in the ai evals active-preview resolver Co-Authored-By: Claude Opus 5.5 * refactor: share the page-slot predicate between the session and eval resolvers Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../adapters/frontend/core/global/evalPreviewTabs.ts | 6 +++--- .../src/lib/components/sessions/previewRouter.ts | 12 ++++++++++++ .../lib/components/sessions/sessionRuntime.svelte.ts | 11 ++--------- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts index f32bf1e22e..b6af04e6ec 100644 --- a/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts +++ b/ai_evals/adapters/frontend/core/global/evalPreviewTabs.ts @@ -17,6 +17,7 @@ import { previewLocationLabel, promptSafe, resolvePreviewTab, + slotShowsPage, } from "../../../../../frontend/src/lib/components/sessions/previewRouter"; import type { ArtifactVersionTarget } from "../../../../../frontend/src/lib/components/sessions/previewRouter"; import type { SessionTarget } from "../../../../../frontend/src/lib/components/sessions/sessionState.svelte"; @@ -136,9 +137,8 @@ export function createEvalPreviewPanel(input: { activePreview: () => { const tab = owner.displayedTab; if (!tab) return undefined; - // Artifact and editor tabs are not iframes: they carry no page location, and an - // artifact's pinned version reaches the chat only through get_preview_status. - if (resolvePreviewTab(tab.url).kind !== "iframe") return undefined; + // An artifact's pinned version reaches the chat only through get_preview_status. + if (!slotShowsPage(resolvePreviewTab(tab.url).kind)) return undefined; return previewLocationContext(whereIs(tab)); }, dispose: () => { diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index 637d6a6052..4d9a678859 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -613,6 +613,18 @@ export type PreviewSlot = | { kind: 'run'; jobId: string; workspace?: string } | { kind: 'iframe' } +/** Whether a tab on this slot shows a page, framed or mounted in process, and so has a + * location to stamp as the chat's ACTIVE PREVIEW. Editors register as the ACTIVE EDITOR. */ +export function slotShowsPage(kind: PreviewSlot['kind']): boolean { + return ( + kind === 'iframe' || + kind === 'pageitem' || + kind === 'pagelist' || + kind === 'run' || + kind === 'viewer' + ) +} + export function resolvePreviewTab(url: string): PreviewSlot { const artifact = parseArtifactRoute(url) if (artifact) return { kind: 'artifact', id: artifact.id, version: artifact.version } diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 8d242912dc..b97073ba9c 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -72,6 +72,7 @@ import { promptSafe, parseRunFormRoute, resolvePreviewTab, + slotShowsPage, type PreviewSlot } from './previewRouter' import { normalizePipelineFolder } from '$lib/utils/pipelineFolder' @@ -466,15 +467,7 @@ function createRuntime(session: Session): SessionRuntime { // point those at a page the user cannot see. const tab = owner?.displayedTab if (!tab) return undefined - const slotKind = resolvePreviewTab(tab.url).kind - if ( - slotKind !== 'iframe' && - slotKind !== 'pageitem' && - slotKind !== 'pagelist' && - slotKind !== 'run' && - slotKind !== 'viewer' - ) - return undefined + if (!slotShowsPage(resolvePreviewTab(tab.url).kind)) return undefined return previewLocationContext(whereIs(tab)) } // Pre-flight: materialise the (still-transient) session, then commit From 20a33f11410ceae473e3b5fb24b934ba9ad6d7ce Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 15:22:58 +0200 Subject: [PATCH 02/30] stop caching app permit refusals and expire grants after 60s (#11726) * fix: recheck app read access on every publisher-mode component run Co-Authored-By: Claude Opus 5.5 * test: pin that app read grants and revocations apply to the next run Co-Authored-By: Claude Opus 5.5 * fix: cache only app permit grants, for 60s, instead of rechecking every run Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/tests/app_publisher_permit.rs | 40 +++++++++++++ .../tests/fixtures/app_publisher_permit.sql | 10 ++++ backend/windmill-api/src/apps.rs | 58 ++++++++++++------- backend/windmill-api/src/jobs.rs | 2 +- 4 files changed, 87 insertions(+), 23 deletions(-) create mode 100644 backend/tests/app_publisher_permit.rs create mode 100644 backend/tests/fixtures/app_publisher_permit.sql diff --git a/backend/tests/app_publisher_permit.rs b/backend/tests/app_publisher_permit.rs new file mode 100644 index 0000000000..e590efaeb2 --- /dev/null +++ b/backend/tests/app_publisher_permit.rs @@ -0,0 +1,40 @@ +//! A refused publisher-mode app run is not remembered: once the caller is granted +//! read access to the app, the very next run in the same process is allowed. + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +#[sqlx::test(fixtures("base", "app_publisher_permit"))] +async fn test_publisher_app_refusal_is_not_cached(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = format!( + "http://localhost:{port}/api/w/test-workspace/apps_u/execute_component/u/test-user/papp" + ); + + let run = || async { + let resp = reqwest::Client::new() + .post(&url) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&json!({ "component": "c", "path": "script/u/test-user/hello", "args": {} })) + .send() + .await?; + let status = resp.status().as_u16(); + anyhow::Ok((status, resp.text().await?)) + }; + + let (status, body) = run().await?; + assert_eq!(status, 401, "unshared app must be refused: {body}"); + assert!(body.contains("Missing read permissions"), "{body}"); + + sqlx::query("UPDATE app SET extra_perms = '{\"u/test-user-2\": false}' WHERE id = 999101") + .execute(&db) + .await?; + let (status, body) = run().await?; + assert_eq!(status, 200, "a grant must apply to the next run: {body}"); + + Ok(()) +} diff --git a/backend/tests/fixtures/app_publisher_permit.sql b/backend/tests/fixtures/app_publisher_permit.sql new file mode 100644 index 0000000000..c169e53ec5 --- /dev/null +++ b/backend/tests/fixtures/app_publisher_permit.sql @@ -0,0 +1,10 @@ +-- A publisher-mode app owned by `test-user`, not shared with anyone, whose one +-- runnable is a deployed script. + +INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES + ('test-workspace', 'test-user', 'export async function main() { return "ok" }', '{}', '', '', 'u/test-user/hello', 7788101, 'deno', ''); + +INSERT INTO app (id, workspace_id, path, summary, policy, versions) VALUES + (999101, 'test-workspace', 'u/test-user/papp', 'publisher app', + '{"execution_mode": "publisher", "on_behalf_of": "u/test-user", "on_behalf_of_email": "test@windmill.dev", + "triggerables_v2": {"c:script/u/test-user/hello": {"static_inputs": {}, "one_of_inputs": {}}}}'::jsonb, '{}'); diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 2c2179d830..db8a38f2dd 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -4734,34 +4734,48 @@ async fn execute_component( if let (ExecutionMode::Publisher, Some(authed)) = (policy.execution_mode(), opt_authed.as_ref()) { lazy_static! { - /// Cache for the permit to execute an app component. - static ref PERMIT_CACHE: cache::Cache<[u8; 32], bool> = cache::Cache::new(1000); + /// When each (caller, app) was last granted the permit to execute. + static ref PERMIT_CACHE: cache::Cache<[u8; 32], std::time::Instant> = + cache::Cache::new(1000); } + // The outcome follows the app's `extra_perms` and the caller's groups and + // folders, all mutable and absent from the key. Only grants are cached, and + // briefly: a refusal is rechecked every time so a new grant applies at once, + // and a revocation takes effect within the TTL. + const PERMIT_TTL: std::time::Duration = std::time::Duration::from_secs(60); - // Avoid allocation for the permit key using a sha256 hash of: - // - the user email, - // - the application path, - // - the workspace id. - let permit_key: [u8; 32] = [authed.email.as_bytes(), path.as_bytes(), &w_id.as_bytes()] + let permit_key: [u8; 32] = [authed.email.as_bytes(), path.as_bytes(), w_id.as_bytes()] .iter() - .fold(Sha256::new(), |hasher, bytes| hasher.chain_update(bytes)) + .fold(Sha256::new(), |hasher, bytes| { + hasher + .chain_update((bytes.len() as u32).to_be_bytes()) + .chain_update(bytes) + }) .finalize() .into(); - let permit_fut = PERMIT_CACHE.get_or_insert_async(&permit_key, async { - let mut tx = user_db.clone().begin(authed).await?; - // Permissions are checked by the database; just fetch a row from app using `user_db`: - let row = sqlx::query_scalar!( - "SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2 LIMIT 1", - path, - &w_id, - ) - .fetch_optional(&mut *tx) - .await?; - tx.commit().await?; - Result::Ok(row.is_some_and(|x| x.is_some())) - }); - if !permit_fut.await? { + let granted = match PERMIT_CACHE.get(&permit_key) { + Some(at) if at.elapsed() < PERMIT_TTL => true, + _ => { + let mut tx = user_db.clone().begin(authed).await?; + // Permissions are checked by the database; just fetch a row from app using `user_db`: + let row = sqlx::query_scalar!( + "SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2 LIMIT 1", + path, + &w_id, + ) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + let granted = row.is_some_and(|x| x.is_some()); + if granted { + PERMIT_CACHE.insert(permit_key, std::time::Instant::now()); + } + granted + } + }; + + if !granted { return Err(Error::NotAuthorized(format!( "Missing read permissions on the `{}` app to execute `{}` runnable", path, payload.component diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 2b93c6daeb..778f504c54 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -1908,7 +1908,7 @@ async fn require_job_access( // are immutable after creation, and every mutable caller-side input // (is_admin / username / username_override / groups / folders) is folded into // the key — so a permission change yields a new key rather than a stale hit, and - // no TTL is needed (size-bounded LRU; mirrors apps' PERMIT_CACHE). + // no TTL is needed (size-bounded LRU). let cache_key = job_read_access_cache_key(authed, w_id, job_id); let visible = if let Some(visible) = JOB_READ_ACCESS_CACHE.get(&cache_key) { visible From 133613c1add5b6fcdbb0937de6479976b716c90a Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:23:20 +0200 Subject: [PATCH 03/30] feat: give app builder operators app tools in AI sessions (#11716) * feat: give app builder operators app tools in AI sessions Co-Authored-By: Claude Opus 5.5 * fix: start builders' session apps from the builder starter Co-Authored-By: Claude Opus 5.5 * docs: note the session app editor conforms builder app policies Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- docs/operator-builder-rights.md | 4 +- .../apps/editor/InWorkspaceAppViewer.svelte | 9 +- .../components/copilot/chat/global/core.ts | 219 ++++++++++++------ .../copilot/chat/global/sessionAccess.test.ts | 20 ++ .../copilot/chat/global/sessionAccess.ts | 10 +- .../chat/global/sessionToolset.test.ts | 43 +++- .../copilot/chat/global/sessionToolset.ts | 10 +- .../copilot/chat/sessionCapabilities.ts | 11 +- .../components/raw_apps/RawAppEditor.svelte | 8 + .../sessions/RawAppEditorView.svelte | 6 +- .../sessions/sessionDeployModel.svelte.ts | 21 +- .../sessions/sessionPermissions.svelte.ts | 4 +- .../sessions/sessionRuntime.svelte.ts | 14 +- .../apps_raw/edit/[...path]/+page.svelte | 7 - 14 files changed, 278 insertions(+), 108 deletions(-) diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md index 4f844beebf..8ace42a1e5 100644 --- a/docs/operator-builder-rights.md +++ b/docs/operator-builder-rights.md @@ -111,8 +111,8 @@ rule added for deploys must hold for drafts too. Where the two differ, `write` d fills in an omitted sandbox or mode, since it stores the policy it checked, while a draft skips `triggerables_v2` and must not name another user's run identity. Every frontend writer of a builder draft must conform the policy first with `conformBuilderAppPolicy`, since a policy loaded -from someone else's app breaks these rules: the editor and the AI chat do. The AI Sessions raw-app -editor, open to operators only behind a per-browser opt-in, does not, so its autosaves are refused. +from someone else's app breaks these rules: the raw-app editor component does, which covers both +the standalone editor and the AI Sessions one, and so does the AI chat. The raw-app editor previews an app isolated only when its policy has `sandbox`, and otherwise runs the bundle same-origin with the session of whoever opens it. A builder's draft is therefore diff --git a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte index 2e45a3f668..3384ad3e87 100644 --- a/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte +++ b/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte @@ -67,15 +67,16 @@ let appPerms = $state<{ path: string; extraPerms: Record } | undefined>( undefined ) - const editRights = useEditRights() - const canWriteApp = $derived( - !!appPerms && editRights.canEditItem('app', appPerms.path, appPerms.extraPerms) - ) /** Raw vs low-code, read from the app itself rather than from the route: * both kinds render here and either route serves either kind (links to a raw * app point at /apps/get all over the app), so only the app can say which * editor the Edit button must open. */ let isRawApp = $state(false) + const editRights = useEditRights() + const canWriteApp = $derived( + !!appPerms && + editRights.canEditItem(isRawApp ? 'raw_app' : 'app', appPerms.path, appPerms.extraPerms) + ) /** The app's execution mode, from the same lite read as the permissions and for the same * reason: a sandboxed low-code app loads inside the opaque iframe, so `app` here stays * undefined and cannot say whether the app answers at a public url. `policy.execution_mode` is diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index c935e1bc02..abc599c03d 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -55,6 +55,7 @@ import { evalValue } from '$lib/components/flows/utils.svelte' import { quickjsReady } from '$lib/utils/quickjsEval.svelte' import { updateRawAppPolicy } from '$lib/components/raw_apps/rawAppPolicy' import { + BUILDER_FRAMEWORK_TEMPLATES, FRAMEWORK_TEMPLATES, STARTER_RUNNABLES, type FrameworkKey @@ -221,6 +222,7 @@ import { NONE, RUN_FLOW_PREVIEW, RUN_PREVIEW, + WRITE_APP_DRAFT, WRITE_DRAFT, WRITE_FLOW_DRAFT, type SessionAccess, @@ -1206,16 +1208,20 @@ const openPreviewViewOnlySchema = openPreviewSchema.extend({ ) }) -/** A builder operator: the flow editor, and the deployed page for everything else. */ -const openPreviewFlowEditorSchema = openPreviewSchema.extend({ - kind: openPreviewNoPipelineKind, - mode: z - .enum(['edit', 'view']) - .optional() - .describe( - 'Which side to show. "edit" opens the flow editor and works for flows only: the only editor you can open in this workspace. Pass "view" for a script or app, the deployed page with its run form, triggers and past runs.' - ) -}) +/** A builder operator: the editors of the kinds they compose, and the deployed page for + * everything else. */ +const openPreviewBuilderEditorSchema = (flows: boolean, apps: boolean) => + openPreviewSchema.extend({ + kind: openPreviewNoPipelineKind, + mode: z + .enum(['edit', 'view']) + .optional() + .describe( + flows && apps + ? 'Which side to show. "edit" opens the flow or app editor and works for flows and raw apps only: the only editors you can open in this workspace. Pass "view" for a script, the deployed page with its run form, triggers and past runs.' + : `Which side to show. "edit" opens the ${flows ? 'flow' : 'app'} editor and works for ${flows ? 'flows' : 'raw apps'} only: the only editor you can open in this workspace. Pass "view" for a script or ${flows ? 'app' : 'flow'}, the deployed page with its run form, triggers and past runs.` + ) + }) const OPEN_PREVIEW_DESCRIPTION = 'Open the live preview / editor for a workspace item in the side panel next to the chat. ONLY works inside an AI session — call this after writing or editing a script, flow, or raw app to let the user see and interact with it. The path you pass is the path of the item; for code-based apps use kind="raw_app" (legacy drag-and-drop apps are not previewable). Returns an error if there is no active session.' @@ -1339,6 +1345,8 @@ const initAppSchema = z.object({ ) }) +const INIT_APP_DESCRIPTION = 'Initialize a draft raw app from a framework template.' + // Mirrors the backend VALID_FOLDER_NAME check so an invalid name fails before the // network round-trip (the server enforces the same rule). const VALID_FOLDER_NAME = /^[a-zA-Z_0-9-]+$/ @@ -1427,10 +1435,16 @@ const buildGlobalSystemPrompt = ( // rule cannot outlive the tool it describes. An unresolved profile keeps every block. const canWriteDraft = !access || access.has('write_draft') const canWriteFlowDraft = !access || access.has('write_flow_draft') - // A builder operator: flow drafts and nothing else. Blocks about drafting in general - // follow `canWriteAnyDraft`; blocks naming scripts, apps or the other kinds keep `canWriteDraft`. - const flowsOnly = canWriteFlowDraft && !canWriteDraft - const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft + const canWriteAppDraft = !access || access.has('write_app_draft') + // A builder operator: drafts of the kinds the workspace lets them compose, and nothing else. + // Blocks about drafting in general follow `canWriteAnyDraft`; blocks naming scripts or the + // other kinds keep `canWriteDraft`. + const buildsFlows = canWriteFlowDraft && !canWriteDraft + const buildsApps = canWriteAppDraft && !canWriteDraft + const canWriteAnyDraft = canWriteDraft || canWriteFlowDraft || canWriteAppDraft + const refusedCode = ['scripts', !buildsFlows && 'flows', !buildsApps && 'apps'].filter( + (k): k is string => !!k + ) const canRunPreview = !access || access.has('run_preview') const canManageSchedules = !access || access.has('manage_schedules') const canManageTriggers = !access || access.has('manage_triggers') @@ -1484,9 +1498,12 @@ The current user's workspace username is "${username}".${instanceLine} ${ canWriteDraft ? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.' - : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${flowsOnly ? '' : 'flows, '}scripts, apps, schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${flowsOnly ? 'scripts and apps' : 'scripts, flows and apps'} outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor. Resources and variables they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when( - flowsOnly, + : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${buildsFlows ? '' : 'flows, '}scripts, ${buildsApps ? '' : 'apps, '}schedules, triggers, resources or variables here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${refusedCode.length > 1 ? `${refusedCode.slice(0, -1).join(', ')} and ${refusedCode.at(-1)}` : refusedCode[0]} outside this chat too, so for those suggest asking a workspace admin rather than creating them in the editor. Resources and variables they can still edit themselves on their pages, so when open_page lists that page, open it for them instead.${when( + buildsFlows, '\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script.' + )}${when( + buildsApps, + `\n\nRaw apps are ${buildsFlows ? 'an exception too' : 'the exception'}: this workspace lets them build raw apps whose backend calls scripts and flows that are already deployed. Call get_instructions with subject "app" first, then write app drafts with init_app and the app file and runnable tools, making every backend runnable a path runnable to a deployed script or flow. The server refuses inline and hub runnables, so never write one; when the app needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An app this user deploys always runs sandboxed, and runs its runnables as the user who deployed it: turning the sandbox off or running them as the viewer is refused. If asked, say operators cannot, because the app's code would then run with the permissions of whoever opens it.` )}` }${when( canWriteAnyDraft, @@ -1549,7 +1566,7 @@ ${pipelineBullet}` - After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. - Do the same for a raw app: run test_run_app_runnable on each backend runnable you wrote or changed before saying the app works. A bundle that compiles proves nothing about whether the runnables run. An inline runnable executes the app's draft code; a path runnable executes the DEPLOYED script/flow it names, so a path runnable aimed at something you have not deployed fails here — that failure is the point: report it and offer to deploy that one target. The app itself does not need deploying to be tested.` )}${when( - flowsOnly && (!access || access.has('run_flow_preview')), + buildsFlows && (!access || access.has('run_flow_preview')), ` - After creating or editing a flow draft, run test_run_flow with representative args before reporting that it works. It prefers drafts, so testing does not require deployment.` )} @@ -1577,9 +1594,11 @@ ${pipelineBullet}` !canWriteDraft, ` - open_preview(kind, path) shows a deployed script / flow / app in the side panel next to the chat — its run form, triggers and past runs, which the user can act on there. Use it when you surface an item they will want to run or inspect. ${ - flowsOnly - ? 'The flow editor is the only editor you can open here: after writing or editing a flow draft, show it with open_preview(kind="flow", path, mode="edit").' - : 'You cannot open item editors in this workspace, so it only ever opens the deployed page.' + buildsFlows && buildsApps + ? 'The flow and app editors are the only editors you can open here: after writing or editing a flow or app draft, show it with open_preview(kind="flow" or "raw_app", path, mode="edit").' + : buildsFlows || buildsApps + ? `The ${buildsFlows ? 'flow' : 'app'} editor is the only editor you can open here: after writing or editing ${buildsFlows ? 'a flow' : 'an app'} draft, show it with open_preview(kind="${buildsFlows ? 'flow' : 'raw_app'}", path, mode="edit").` + : 'You cannot open item editors in this workspace, so it only ever opens the deployed page.' }` )}${when( canWriteDraft, @@ -1620,12 +1639,16 @@ Flows: Raw apps: - The app tools below only work on raw (code) apps. \`rawApp\` says which: false is a drag-and-drop app: you can list it and read its metadata, but not read its contents, edit it or deploy it. Check it before offering to change an app. - read_workspace_item returns app metadata only. Use read_app_file for file and inline runnable contents.${when( - canWriteDraft, + canWriteAppDraft, ` - A draft app is reachable by nobody; deploying is what exposes its backend runnables. deploy_workspace_item says so when the deploy widens who may open the app: anonymous means anyone with the URL, without logging in; guest means anyone the instance's identity provider authenticates, member of this workspace or not. Relay that in plain words and carry on. This is disclosure, not a gate: do not stop and ask for permission, and do not refuse the deploy. You cannot change who may open an app from chat; it is set on the app's deploy settings. - Use write_app_file, patch_app_file, and delete_app_file for frontend files. - Use write_app_runnable and delete_app_runnable for backend runnables. -- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app's data. +- Use init_app for a new app, with framework react19 unless the user named one or points at an app to follow: do not ask about the framework, and write the summary and pick the path (path conventions above) yourself. ${ + buildsApps + ? 'An app this user builds reads and stores data only through the deployed runnables it calls: do not ask about a data setup or pass init_app a data config.' + : 'When the app has to store data and the user did not say where, settle the data setup with askUserQuestion BEFORE init_app or any SQL (get_instructions subject "app" lists the questions, one askUserQuestion each), then pass the outcome as init_app\'s data.' + } - Use deploy_workspace_item after explicit user deploy intent; raw app deploy bundles JS/CSS before saving.` )} @@ -2441,7 +2464,10 @@ function getScriptInstructions(language: ScriptLang | undefined): string { ${getScriptPrompt(selected)}` } -async function getFlowInstructions(workspace: string | undefined): Promise { +async function getFlowInstructions( + workspace: string | undefined, + composeOnly: boolean +): Promise { const aiAgentProviders = formatAiAgentProvidersPrompt( await getAiAgentProviderCatalog(workspace), { @@ -2457,15 +2483,22 @@ async function getFlowInstructions(workspace: string | undefined): Promise"\` 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: @@ -2476,15 +2509,16 @@ async function getFlowInstructions(workspace: string | undefined): Promise"\` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected. -${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''} +` +}${aiAgentProviders ? `\n${aiAgentProviders}\n` : ''} # Windmill flow authoring reference -${getFlowPrompt()}` +${composeOnly ? 'This reference is written for every author. For this user, skip what it says about inline code (rawscript steps, inline AI agent tools) and hub scripts: the server refuses them. Compose only path steps that reference deployed scripts and flows.\n\n' : ''}${getFlowPrompt()}` } type InstructionSubject = (typeof ALL_INSTRUCTION_SUBJECTS)[number] -function getAppInstructions(language?: ScriptLang): string { +function getAppInstructions(language: ScriptLang | undefined, composeOnly: boolean): string { // getRawAppPrompt swaps in the Python SDK for python3, so the pointer has to swap with // it: telling the model to re-fetch a reference it is already holding wastes a turn, and // naming the wrong SDK contradicts the text right below the sentence. @@ -2496,21 +2530,40 @@ function getAppInstructions(language?: ScriptLang): string { - Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy. - App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to \`u//\` for bare names; only use \`f//\` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate. -- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`) and, for an app that stores data, its \`data\` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with \`askUserQuestion\`, one question per call with the suggested answer first, before \`init_app\` and before any SQL. \`init_app\` only records \`data\`: a new schema is yours to create with the datatable SQL tool. \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing. -- \`init_app\` seeds a starter inline runnable named \`a\` (bun, \`main(x: string) => string\`) so the React/Svelte demo button works on first render. Replace or remove it once you start building real backend runnables. +- To create a new app, use \`init_app\` with a path, optional summary, a framework (\`react19\` / \`react18\` / \`svelte5\`)${ + composeOnly + ? '. "Starting a new app" below decides the framework. No data setup: the app reads and stores data only through the deployed runnables it calls.' + : ' and, for an app that stores data, its `data` setup. "Starting a new app" below decides the framework and when the data setup is asked. Ask each of its questions with `askUserQuestion`, one question per call with the suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool.' + } \`init_app\` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing. +${ + composeOnly + ? '- `init_app` starts from a static page with no backend runnables.' + : `- \`init_app\` seeds the starter inline runnables ${Object.keys(STARTER_RUNNABLES) + .map((k) => `\`${k}\``) + .join( + ', ' + )} so the demo tour works on first render. Replace or remove them once you start building real backend runnables.` +} - Frontend file paths start with \`/\` (e.g. \`/index.tsx\`, \`/App.tsx\`, \`/styles.css\`). Use \`write_app_file\` / \`patch_app_file\` / \`delete_app_file\`. -- Backend inline runnables are addressed as \`backend//main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`). -- \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly. -- Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item. +${ + composeOnly + ? '- Every backend runnable is a path runnable (`script` / `flow`) referencing an item already deployed in the workspace, created with `write_app_runnable` and removed with `delete_app_runnable`. Inline and hub runnables are refused for this user.' + : `- Backend inline runnables are addressed as \`backend//main.{ts|py}\` from the file tools, but you create or update them via \`write_app_runnable\` / \`delete_app_runnable\` (which take the runnable shape directly: \`{ name, type, inlineScript?, path?, staticInputs? }\`).` +} +- \`/wmill.d.ts\` (or \`wmill.ts\`) is generated automatically from the backend runnables — never write it directly.${ + composeOnly + ? '' + : ` +- Inline runnables only support \`bun\` or \`python3\` in chat. Path runnables (\`script\`/\`flow\`/\`hubscript\`) reference an existing item.` + } - When a path runnable points at a draft (see "Draft code vs deployed code" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page. -${sdkLine} -- Use \`deploy_workspace_item\` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app. +${composeOnly ? '' : `${sdkLine}\n`}- Use \`deploy_workspace_item\` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app. - Use \`read_workspace_item\` with \`type: 'app'\` for a metadata summary (file paths and runnable list, no contents). Use \`read_app_file\` to read an individual file; large files are truncated to a head slice, so pass \`offset\`/\`limit\` to page through the rest rather than re-reading the whole file. - To find where a symbol or string lives across the app, call \`search_app\` (greps every frontend file and inline runnable, returns matching \`file:line\` rows) instead of reading files one by one — then \`read_app_file\` only the ranges you need. The loop is list (\`read_workspace_item\`) → locate (\`search_app\`) → inspect (\`read_app_file\` with \`offset\`/\`limit\`). # Windmill raw app authoring reference -${getRawAppPrompt(language)}` +${composeOnly ? 'This reference is written for every author. For this user, skip what it says about inline runnables, hub scripts and the data setup: the server refuses inline and hub runnables. Every runnable is a path runnable referencing a deployed script or flow.\n\n' : ''}${getRawAppPrompt(language)}` } function getResourceInstructions(): string { @@ -2552,17 +2605,18 @@ function getPipelineInstructions(): string { function getInstructions( subject: InstructionSubject, language: ScriptLang | undefined, - workspace: string | undefined + workspace: string | undefined, + composeOnly: boolean ): string | Promise { switch (subject) { case 'script': return getScriptInstructions(language) case 'flow': - return getFlowInstructions(workspace) + return getFlowInstructions(workspace, composeOnly) case 'resource': return getResourceInstructions() case 'app': - return getAppInstructions(language) + return getAppInstructions(language, composeOnly) case 'datatable': return getDatatableInstructions(language) case 'pipeline': @@ -2607,7 +2661,11 @@ export function getSessionContextPromptSection( // Concatenated onto an already capability-gated prompt, so it has to honour the same // profile rather than assume the gating happened upstream. const canDeploy = !access || access.has('deploy') - const canWriteDraft = !access || access.has('write_draft') || access.has('write_flow_draft') + const canWriteDraft = + !access || + access.has('write_draft') || + access.has('write_flow_draft') || + access.has('write_app_draft') const canRunPreview = !access || access.has('run_preview') || access.has('run_flow_preview') // The kinds no deploy rule reaches, unless the workspace withdrew them from this user. const unruledKinds = [ @@ -3505,10 +3563,15 @@ export const globalTools: SessionTool<{}>[] = [ const canAuthor = !access || access.has('write_draft') || - (parsed.subject === 'flow' && access.has('write_flow_draft')) + (parsed.subject === 'flow' && access.has('write_flow_draft')) || + (parsed.subject === 'app' && access.has('write_app_draft')) if (!canAuthor) { - const message = access?.has('write_flow_draft') - ? 'This session can only compose flows, so flow is the one subject with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.' + const composed = [ + access?.has('write_flow_draft') && 'flow', + access?.has('write_app_draft') && 'app' + ].filter((k): k is string => !!k) + const message = composed.length + ? `This session can only compose ${composed.map((k) => `${k}s`).join(' and ')}, so ${composed.join(' and ')} ${composed.length > 1 ? 'are the subjects' : 'is the one subject'} with authoring guidance here. Tell the user plainly that you cannot create or edit this kind of item.` : 'This session cannot create or edit workspace items, so there is no authoring guidance to give. Tell the user plainly rather than describing how it would be done.' toolCallbacks.setToolStatus(toolId, { content: 'No authoring guidance' }) return message @@ -3518,7 +3581,10 @@ export const globalTools: SessionTool<{}>[] = [ ? `${parsed.subject} (${parsed.language})` : parsed.subject toolCallbacks.setToolStatus(toolId, { content: `Loaded ${label} instructions` }) - return getInstructions(parsed.subject, parsed.language, ctx.workspace) + // Past the gate without `write_draft` means a builder: guidance for inline code would + // steer the model at writes the server refuses and tools it was not given. + const composeOnly = !!access && !access.has('write_draft') + return getInstructions(parsed.subject, parsed.language, ctx.workspace, composeOnly) } }, createSearchHubScriptsTool(false), @@ -4221,12 +4287,12 @@ export const globalTools: SessionTool<{}>[] = [ // turns on the kind. Each kind's handler, reached through `deployDraft`'s switch: requires: NONE, kindRequires: { - // scripts.rs `create_script_internal` and apps.rs `create_app_raw`/`update_app_raw` - // refuse operators, flows.rs `create_flow`/`update_flow` those without builder - // rights; all run the rules. + // scripts.rs `create_script_internal` refuses operators, flows.rs `create_flow`/ + // `update_flow` and apps.rs `create_app_raw`/`update_app_raw` those without the + // matching builder right; all run the rules. script: ['deploy', 'manage_code'], flow: ['deploy', 'manage_flows'], - app: ['deploy', 'manage_code'], + app: ['deploy', 'manage_apps'], resource: DEPLOY, variable: DEPLOY, // No deploy rules here, but `gate_operator_writes` on their routers. @@ -4254,7 +4320,7 @@ export const globalTools: SessionTool<{}>[] = [ kindRequires: { script: WRITE_DRAFT, flow: WRITE_FLOW_DRAFT, - app: WRITE_DRAFT, + app: WRITE_APP_DRAFT, resource: WRITE_DRAFT, variable: WRITE_DRAFT, schedule: WRITE_DRAFT, @@ -4304,10 +4370,10 @@ export const globalTools: SessionTool<{}>[] = [ kindRequires: { // scripts.rs `delete_script_by_path` calls `require_admin`; a non-admin archives. script: ['admin'], - // apps.rs `delete_app` refuses operators, flows.rs `delete_flow_by_path` those - // without builder rights. + // flows.rs `delete_flow_by_path` and apps.rs `delete_app` refuse operators without + // the matching builder right. flow: ['deploy', 'manage_flows'], - app: ['deploy', 'manage_code'], + app: ['deploy', 'manage_apps'], resource: DEPLOY, variable: DEPLOY, schedule: ['manage_schedules'], @@ -4452,13 +4518,15 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, - def: createToolDef( - initAppSchema, - 'init_app', - 'Initialize a draft raw app from a framework template.', - { strict: false } - ), + requires: WRITE_APP_DRAFT, + def: createToolDef(initAppSchema, 'init_app', INIT_APP_DESCRIPTION, { strict: false }), + // A builder has no data setup: it needs SQL and inline runnables. + schemaFor: async (helpers) => { + const access = (helpers as GlobalToolHelpers | undefined)?.access + const schema = + access && !access.has('write_draft') ? initAppSchema.omit({ data: true }) : initAppSchema + return createToolDef(schema, 'init_app', INIT_APP_DESCRIPTION, { strict: false }) + }, showDetails: true, showFade: true, fn: async (ctx) => { @@ -4493,7 +4561,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( writeAppFileSchema, 'write_app_file', @@ -4508,7 +4576,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( deleteAppFileSchema, 'delete_app_file', @@ -4520,7 +4588,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( patchAppFileSchema, 'patch_app_file', @@ -4535,7 +4603,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( writeAppRunnableSchema, 'write_app_runnable', @@ -4551,7 +4619,7 @@ export const globalTools: SessionTool<{}>[] = [ } }, { - requires: WRITE_DRAFT, + requires: WRITE_APP_DRAFT, def: createToolDef( deleteAppRunnableSchema, 'delete_app_runnable', @@ -4589,8 +4657,14 @@ export const globalTools: SessionTool<{}>[] = [ if (canOpenEditor(access, 'script')) { return createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION) } - return canOpenEditor(access, 'flow') - ? createToolDef(openPreviewFlowEditorSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION) + const flows = canOpenEditor(access, 'flow') + const apps = canOpenEditor(access, 'raw_app') + return flows || apps + ? createToolDef( + openPreviewBuilderEditorSchema(flows, apps), + 'open_preview', + OPEN_PREVIEW_DESCRIPTION + ) : createToolDef( openPreviewViewOnlySchema, 'open_preview', @@ -4947,7 +5021,7 @@ async function openSessionPreview( // a model asking outside the enum can't act outside it either. if (mode === 'edit' && !canOpenEditor(access, args.kind)) { const opened = await openPreviewHandler({ ...args, mode: 'view', sessionId }) - return `${opened}\nOpened the deployed page instead: you cannot open ${args.kind === 'flow' ? 'flow' : 'item'} editors in this workspace.` + return `${opened}\nOpened the deployed page instead: you cannot open ${args.kind === 'raw_app' ? 'app' : args.kind} editors in this workspace.` } // The draft capability is workspace-wide and says nothing about this path, so an edit // that survived it is still checked against the item's own permissions. @@ -6707,11 +6781,12 @@ async function initApp( content: `Saving app "${path}" draft (${framework} template)…` }) - const template = FRAMEWORK_TEMPLATES[framework] + // A builder can save no inline runnable, so they start from a static page instead of the tour. + const builder = operatorBuilderAppsIn(workspace) const value: AppDraftValue = { summary, - files: { ...template }, - runnables: structuredClone(STARTER_RUNNABLES), + files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES : FRAMEWORK_TEMPLATES)[framework] }, + runnables: builder ? {} : structuredClone(STARTER_RUNNABLES), ...(data && { data: { tables: (data.tables ?? []).map((ref) => appTableRef(data.datatable, data.schema, ref)), @@ -6724,7 +6799,7 @@ async function initApp( const result = await saveAppDraft(workspace, path, value) return finishAppDraftWrite(result, ctx, () => ({ content: `Saved app "${path}" draft (${framework})`, - message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable). Use write_app_file / write_app_runnable to evolve it.${data?.schema ? ` Its data config names schema "${data.schema}" of data table "${data.datatable}" without creating it: if it does not exist yet, create it before the tables that go in it.` : ''}` + message: `Initialized a per-user draft app "${path}" from the ${framework} template (saved server-side, not a deployed workspace item). ${builder ? 'The template is a static page with no runnables: replace its UI, and add only deployed scripts and flows, by path.' : `The template is a demo tour with starter runnables ${Object.keys(STARTER_RUNNABLES).join(', ')}: replace its UI and delete the runnables the app does not need (delete_app_runnable).`} Use write_app_file / write_app_runnable to evolve it.${data?.schema ? ` Its data config names schema "${data.schema}" of data table "${data.datatable}" without creating it: if it does not exist yet, create it before the tables that go in it.` : ''}` })) } diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts index 2e28375cee..eca9f96bf9 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -47,12 +47,14 @@ describe('resolveSessionAccess', () => { const caps = await capabilitiesFor({}) expect([...caps].sort()).toEqual([ 'deploy', + 'manage_apps', 'manage_code', 'manage_flows', 'manage_schedules', 'manage_triggers', 'run_flow_preview', 'run_preview', + 'write_app_draft', 'write_draft', 'write_flow_draft' ]) @@ -82,6 +84,7 @@ describe('resolveSessionAccess', () => { 'deploy', 'manage_schedules', 'manage_triggers', + 'write_app_draft', 'write_draft', 'write_flow_draft' ]) @@ -118,6 +121,23 @@ describe('resolveSessionAccess', () => { ]) }) + // apps.rs and drafts.rs let an app builder through; nothing runs an app preview server-side. + it('gives an app builder operator the app capabilities alone', () => { + const caps = capabilitiesForRole({ + isAdmin: false, + operator: true, + deployRulesPass: true, + operatorSettings: { builder_apps: true } + }) + expect([...caps].sort()).toEqual([ + 'deploy', + 'manage_apps', + 'manage_schedules', + 'manage_triggers', + 'write_app_draft' + ]) + }) + // A withdrawable right: only `false` withdraws it, as `gate_operator_writes` reads it. it('withholds schedule and trigger writes only where the workspace set them to false', () => { const caps = capabilitiesForRole({ diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index 2f1e00c09b..6e65d833d5 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -8,17 +8,19 @@ import type { SessionAccess, SessionCapability } from '../sessionCapabilities' type OperatorRights = Pick< NonNullable, - 'builder_flows' | 'manage_schedules' | 'manage_triggers' + 'builder_flows' | 'builder_apps' | 'manage_schedules' | 'manage_triggers' > const ALL_CAPABILITIES: SessionCapability[] = [ 'write_draft', 'write_flow_draft', + 'write_app_draft', 'run_preview', 'run_flow_preview', 'deploy', 'manage_code', 'manage_flows', + 'manage_apps', 'manage_schedules', 'manage_triggers', 'admin' @@ -54,6 +56,9 @@ export function capabilitiesForRole(role: { if (roleCanDraft('flow', user, role.operatorSettings)) { capabilities.add('write_flow_draft') } + if (roleCanDraft('raw_app', user, role.operatorSettings)) { + capabilities.add('write_app_draft') + } if (authorsCode) { capabilities.add('run_preview') capabilities.add('manage_code') @@ -62,6 +67,9 @@ export function capabilitiesForRole(role: { capabilities.add('run_flow_preview') capabilities.add('manage_flows') } + if (roleCanAuthor('raw_app', user, role.operatorSettings)) { + capabilities.add('manage_apps') + } if (roleCanAuthor('schedule', user, role.operatorSettings)) { capabilities.add('manage_schedules') } diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts index 0c0b8d8a7d..b1da8b3903 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -80,6 +80,8 @@ const REACHABLE_PROFILES = [ [ undefined, { builder_flows: true }, + { builder_apps: true }, + { builder_flows: true, builder_apps: true }, { manage_schedules: false, manage_triggers: false } ].map((operatorSettings) => { const access = capabilitiesForRole({ @@ -137,10 +139,12 @@ describe('session tool policies', () => { const developerCaps: SessionCapability[] = [ 'write_draft', 'write_flow_draft', + 'write_app_draft', 'run_preview', 'run_flow_preview', 'manage_code', 'manage_flows', + 'manage_apps', 'manage_schedules', 'manage_triggers' ] @@ -167,6 +171,17 @@ describe('session tool policies', () => { const builder = accessWith(['deploy', 'manage_flows', 'manage_schedules', 'manage_triggers']) expect(kindsOf('deploy_workspace_item', builder)).toContain('flow') expect(kindsOf('deploy_workspace_item', builder)).not.toContain('script') + expect(kindsOf('deploy_workspace_item', builder)).not.toContain('app') + + // An app builder gets the app kind alone, and rebases its own stale app drafts. + const appBuilder = accessWith(['deploy', 'manage_apps', 'write_app_draft']) + expect(kindsOf('deploy_workspace_item', appBuilder)).toContain('app') + expect(kindsOf('deploy_workspace_item', appBuilder)).not.toContain('flow') + expect(kindsOf('rebase_draft', appBuilder)).toEqual(['app']) + // A session left with no kind must not be offered an empty enum. + expect(shippedSessionTools(operator).map((t) => t.def.function.name)).not.toContain( + 'rebase_draft' + ) // Deleting a script is admin-only, where deploying one is not. const developer = accessWith([...developerCaps, 'deploy']) @@ -216,9 +231,9 @@ describe('session tool policies', () => { // ships to every profile with guidance written around the draft tools by name. it('does not hand authoring guidance naming withheld tools to a session that cannot draft', async () => { const tool = globalTools.find((t) => t.def.function.name === 'get_instructions')! - const call = (access: SessionAccess) => + const call = (access: SessionAccess, subject = 'script') => tool.fn({ - args: { subject: 'script', language: 'bun' }, + args: { subject, language: 'bun' }, workspace: 'ws', helpers: { access }, toolId: 't1', @@ -231,6 +246,30 @@ describe('session tool policies', () => { expect(withheld.filter((n) => restricted.includes(n))).toEqual([]) // The gate is the whole test, so pin that it is not simply refusing everyone. expect(await call(fullSessionAccess())).toContain('write_script') + + // A builder gets the guide for their kind, rewritten without the inline-code tools. + for (const [capability, subject, used] of [ + ['write_flow_draft', 'flow', 'patch_flow_json'], + ['write_app_draft', 'app', 'write_app_runnable'] + ] as const) { + const builder = accessWith(['deploy', capability]) + const guide = await call(builder, subject) + expect(guide).toContain(used) + // The shared reference below still teaches inline code, so the guide must say to skip it. + expect(guide).toContain('skip what it says') + expect(withheldNames(builder).filter((n) => guide.includes(n))).toEqual([]) + } + }) + + // A data setup needs SQL and inline runnables, neither of which a builder has. + it('offers a builder no init_app data setup', async () => { + const tool = globalTools.find((t) => t.def.function.name === 'init_app')! + const properties = async (access: SessionAccess) => + ((await tool.schemaFor!({ access })).function.parameters as any).properties + const builder = await properties(accessWith(['deploy', 'write_app_draft'])) + const full = await properties(fullSessionAccess()) + expect(builder.data).toBeUndefined() + expect(full.data).toBeDefined() }) // A full-access profile must gate nothing at all: the text has to match the ungated diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts index 4789967a6d..0d8f8753a2 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts @@ -24,13 +24,16 @@ export function filterSessionTools( access: SessionAccess | undefined ): T[] { if (!access) return tools - return tools.filter((t) => sessionToolAllowed(t, access)).map((t) => narrowKinds(t, access)) + return tools + .filter((t) => sessionToolAllowed(t, access)) + .flatMap((t) => narrowKinds(t, access) ?? []) } /** Returns the tool itself when every kind is allowed, so full access ships the same bytes: * tool defs are part of every iteration's cached prefix. Builds new objects otherwise — the - * defs are module singletons shared by every session. */ -function narrowKinds(tool: T, access: SessionAccess): T { + * defs are module singletons shared by every session. `undefined` when no kind is left: an + * empty enum would offer a tool no call can satisfy. */ +function narrowKinds(tool: T, access: SessionAccess): T | undefined { const kindRequires = tool.kindRequires if (!kindRequires) return tool const parameters = tool.def.function.parameters as { @@ -40,6 +43,7 @@ function narrowKinds(tool: T, access: SessionAccess): // A kind with no entry is withheld, as a tool with no `requires` is. const allowed = kinds.filter((k) => kindRequires[k] && satisfies(kindRequires[k], access)) if (allowed.length === kinds.length) return tool + if (allowed.length === 0) return undefined const def = structuredClone(tool.def) ;(def.function.parameters as typeof parameters).properties.type.enum = allowed return { ...tool, def } diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts index f96e89dddc..ba5f4facff 100644 --- a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -15,6 +15,8 @@ export type SessionCapability = /** Passes `require_can_write_path` for a flow draft, which an operator also passes where the * workspace grants `builder_flows`. The server then refuses a draft carrying inline code. */ | 'write_flow_draft' + /** The same for a raw app draft, under `builder_apps`. */ + | 'write_app_draft' /** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */ | 'run_preview' /** Passes `run_preview_flow`'s `check_operator_can_build_flows`: composed flows only, for an @@ -22,12 +24,14 @@ export type SessionCapability = | 'run_flow_preview' /** Passes `check_deploy_rules`. */ | 'deploy' - /** Passes the operator refusal the script and app handlers make before creating or + /** Passes the operator refusal the script handlers make before creating or * deleting one. The same condition as `run_preview` today, but another handler family's * check, so each follows its own gate if the two ever diverge. */ | 'manage_code' /** Passes the flow handlers' `check_operator_can_build_flows`. */ | 'manage_flows' + /** Passes the raw app handlers' `check_operator_can_build`, read from `builder_apps`. */ + | 'manage_apps' /** Passes `gate_operator_writes` on the schedule router: withheld only from an operator whose * workspace set `operator_settings.manage_schedules` to false. */ | 'manage_schedules' @@ -50,6 +54,7 @@ export type SessionToolPolicy = readonly SessionCapability[] export const NONE: SessionToolPolicy = [] export const WRITE_DRAFT: SessionToolPolicy = ['write_draft'] export const WRITE_FLOW_DRAFT: SessionToolPolicy = ['write_flow_draft'] +export const WRITE_APP_DRAFT: SessionToolPolicy = ['write_app_draft'] export const RUN_PREVIEW: SessionToolPolicy = ['run_preview'] export const RUN_FLOW_PREVIEW: SessionToolPolicy = ['run_flow_preview'] export const DEPLOY: SessionToolPolicy = ['deploy'] @@ -62,7 +67,9 @@ export type EditorKind = 'script' | 'flow' | 'raw_app' | 'pipeline' * resolved yet, or not a session) answers true, as the toolset filter does. */ export function canOpenEditor(access: SessionAccess | undefined, kind: EditorKind): boolean { if (!access) return true - return access.has(kind === 'flow' ? 'write_flow_draft' : 'write_draft') + return access.has( + kind === 'flow' ? 'write_flow_draft' : kind === 'raw_app' ? 'write_app_draft' : 'write_draft' + ) } /** diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index ca23505ff1..b26e5df15f 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -88,8 +88,11 @@ import { randomSecret, randomUUID } from '$lib/utils/uuid' import { editorFontSize } from '$lib/editorFontSize.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { useOperatorBuilderApps } from '$lib/operatorWriteRights' + import { conformBuilderAppPolicy } from './builderAppPolicy' const operatingWorkspace = useOperatingWorkspace() + const operatorBuilderApps = useOperatorBuilderApps() interface Props { files?: Record @@ -413,6 +416,11 @@ if (url) URL.revokeObjectURL(url) } }) + // The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user + // deployed carries, so the editor conforms the policy as soon as it opens. + $effect(() => { + if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore) + }) // An app with sandbox isolation previews the way it runs once deployed: in the // served, opaque-origin shell, framed with the deployed wrapper's flags. The nonce // in its URL authenticates its `windmill:ready`, as in `RawAppPreview`. diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 6b079c130d..b944612f91 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -23,6 +23,7 @@ import SessionEditorTarget from './SessionEditorTarget.svelte' import { runResetToDeployed } from '$lib/userDraftToast' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' + import { operatorBuilderAppsIn } from '$lib/operatorWriteRights' import type { RawAppRuntimeLogRequester, RawAppRunsProvider, @@ -97,7 +98,10 @@ const setUp = { ...val, files: { ...result.files }, - runnables: { ...result.runnables, ...structuredClone(STARTER_RUNNABLES) }, + runnables: { + ...result.runnables, + ...(operatorBuilderAppsIn(workspaceId) ? {} : structuredClone(STARTER_RUNNABLES)) + }, data: result.data, policy: result.policy } diff --git a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts index f22ffdd0b7..4ade5552bf 100644 --- a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts @@ -1,6 +1,7 @@ import { getDraftItems, type DraftItem } from '$lib/workspaceDrafts.svelte' import { get } from 'svelte/store' import { userWorkspaces } from '$lib/stores' +import { roleCanAuthor } from '$lib/editRights' import { checkDeployPermission, checkDeployRules, @@ -231,9 +232,9 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { // button disables with a reason instead of failing on click. `ok` defaults // true while resolving (fail-open). let deployPerm = $state({ ok: true }) - // `checkDeployPermission` refuses every operator, but flows.rs lets one with the - // workspace's `builder_flows` right deploy flows, under the rules alone. - let builderFlowPerm = $state(undefined) + // `checkDeployPermission` refuses every operator, but flows.rs and apps.rs let one with the + // workspace's matching builder right deploy that kind, under the rules alone. + let builder = $state<{ perm: DeployPermission; kinds: Kind[] } | undefined>(undefined) let deployPermFetchedFor = '' $effect(() => { const ws = getArgs().workspaceId @@ -243,22 +244,24 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { // Reset to fail-open for the new workspace and drop a stale resolution // (a slower fetch for the previous workspace must not gate this one). deployPerm = { ok: true } - builderFlowPerm = undefined + builder = undefined void checkDeployPermission(ws).then(async (perm) => { if (deployPermFetchedFor !== ws) return deployPerm = perm - const builder = - get(userWorkspaces).find((w) => w.id === ws)?.operator_settings?.builder_flows === true - if (perm.refusedBy === 'operator' && builder) { + const settings = get(userWorkspaces).find((w) => w.id === ws)?.operator_settings + const kinds = (['flow', 'raw_app'] as const).filter((k) => + roleCanAuthor(k, { operator: true }, settings) + ) + if (perm.refusedBy === 'operator' && kinds.length) { const rules = await checkDeployRules(ws) - if (deployPermFetchedFor === ws) builderFlowPerm = rules + if (deployPermFetchedFor === ws) builder = { perm: rules, kinds } } }) }) }) function permissionFor(kind: Kind): DeployPermission { - if (kind === 'flow' && builderFlowPerm) return deployPermissionForKind(builderFlowPerm, kind) + if (builder?.kinds.includes(kind)) return deployPermissionForKind(builder.perm, kind) return deployPermissionForKind(deployPerm, kind) } diff --git a/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts index aedb59fbf6..caceb2e623 100644 --- a/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts @@ -23,8 +23,8 @@ export function useSessionPermissions(workspace: () => string | undefined) { /** Whether this user can save the drafts the `kind` editor writes. */ canOpenEditor: (kind: string) => rights.roleCanDraft(kind), /** The same, and this item's own permissions let them write it. */ - canEditItem: (item: Pick) => - rights.canDraftItem(item.kind, item.path, item.extraPerms), + canEditItem: (item: Pick) => + rights.canDraftItem(item.raw_app ? 'raw_app' : item.kind, item.path, item.extraPerms), /** Whether the page loads for this user rather than refusing them. */ pageAllowed: (page: PreviewPage) => previewPageAllowed(page, user.current, entry) } diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index b97073ba9c..b84bd74cbe 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -30,7 +30,12 @@ type SavedFlow = Omit & { draft?: Flow } import type { HiddenRunnable } from '$lib/components/apps/types' import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' -import { react19Template, STARTER_RUNNABLES } from '$lib/components/raw_apps/templates' +import { + BUILDER_FRAMEWORK_TEMPLATES, + react19Template, + STARTER_RUNNABLES +} from '$lib/components/raw_apps/templates' +import { operatorBuilderAppsIn, workspaceListLoaded } from '$lib/operatorWriteRights' import { random_adj } from '$lib/components/random_positive_adjetive' import { copilotWorkspace } from '$lib/aiStore' import { loadCopilot } from '$lib/components/copilot/loadCopilot' @@ -1046,10 +1051,13 @@ function createRuntime(session: Session): SessionRuntime { } catch (err) { if (seedIfMissing && !deployedOnly && err instanceof ApiError && err.status === 404) { const user = get(userStore) + // A builder can save no inline runnable, so the regular starter could never be saved. + await workspaceListLoaded() + const builder = operatorBuilderAppsIn(workspace) saved.val = undefined store.val = { - files: { ...react19Template }, - runnables: structuredClone(STARTER_RUNNABLES), + files: { ...(builder ? BUILDER_FRAMEWORK_TEMPLATES.react19 : react19Template) }, + runnables: builder ? {} : structuredClone(STARTER_RUNNABLES), data: { ...DEFAULT_DATA }, policy: { on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`, 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 ee2bb8fdf7..ef1265c37a 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 @@ -5,7 +5,6 @@ import { AppService } from '$lib/gen' import { userStore, workspaceStore } from '$lib/stores' import { useOperatorBuilderApps, workspaceListLoaded } from '$lib/operatorWriteRights' - import { conformBuilderAppPolicy } from '$lib/components/raw_apps/builderAppPolicy' import { readFieldsRecursively } from '$lib/utils' import { goto } from '$lib/navigation' import { sendUserToast } from '$lib/toast' @@ -538,12 +537,6 @@ return $operatorBuilderApps ? {} : structuredClone(STARTER_RUNNABLES) } - // The deploy panel cannot switch Viewer mode off, nor drop the identity an app another user - // deployed carries, so the editor conforms the policy as soon as it opens. - $effect(() => { - if ($operatorBuilderApps && policy) conformBuilderAppPolicy(policy, $userStore) - }) - async function onTemplatePickerStart(result: RawAppTemplatePickerResult, mode: RawAppBuildMode) { const picked = { files: { ...result.files }, From e443eb37079734469a78af8ff4bc0f4289b28d0a Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 13 Oct 2026 15:23:43 +0200 Subject: [PATCH 04/30] fix: hide the sidebar triggers header when no trigger entry is visible (#11715) Co-authored-by: Claude Opus 5.5 (1M context) --- frontend/src/lib/components/sidebar/SidebarContent.svelte | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/sidebar/SidebarContent.svelte b/frontend/src/lib/components/sidebar/SidebarContent.svelte index 3c38494145..624fdff527 100644 --- a/frontend/src/lib/components/sidebar/SidebarContent.svelte +++ b/frontend/src/lib/components/sidebar/SidebarContent.svelte @@ -367,6 +367,10 @@ return !page.url.pathname.includes(link.href) && !$usedTriggerKinds.includes(link.kind) }) ) + let showAddTriggerMenu = $derived(extraTriggerLinks.length > 0 && allows('triggers')) + let showTriggersGroup = $derived( + showAddTriggerMenu || triggerMenuLinks.some((link) => !link.disabled) + ) // Admins, superadmins, and fork creators reach workspace settings (the latter // for the fork members screen; see isForkOwner / backend authorize_fork_owner_add_user). const canManageWorkspace = $derived( @@ -502,6 +506,8 @@ {/each} + {/if} + {#if showMain && showTriggersGroup}
{#if isCollapsed}
@@ -529,7 +535,7 @@ {#each triggerMenuLinks as menuLink (menuLink.href ?? menuLink.label)} {/each} - {#if extraTriggerLinks.length > 0 && allows('triggers')} + {#if showAddTriggerMenu} {#snippet triggr({ trigger })} Date: Tue, 13 Oct 2026 15:36:41 +0200 Subject: [PATCH 05/30] fix: pin the floating sidebar and let page clicks through it (#11711) * fix: let clicks through the floating sidebar's outside layer The undocked sidebar's outside-click layer swallowed the click that dismissed it, so a page button under it needed two clicks. Dismiss on a page pointerdown instead and let the press reach its target; close on Esc; make the header handle toggle. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: leave the floating sidebar open on an Esc an overlay claims Esc now waits for a drawer or menu to claim the key, skips while an overlay is on the viewport stack, ignores hidden menus, and returns focus to the handle. The hover-then-click grace on the handle is now time-bound, so a stale hover never swallows a later toggle. Co-Authored-By: Claude Opus 5.5 (1M context) * fix: pin the floating sidebar from its handle A click on the handle pins the card, so it no longer slides away when the pointer leaves; the handle shows as selected while pinned. Another click on the handle, a press on the page, Esc or a navigation closes it. Hover still previews it unpinned. This replaces the one-second window that ignored a click right after a hover. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/lib/components/PageHeaderBar.svelte | 7 +- .../sidebar/navHandlePlacement.svelte.ts | 35 +++++++- .../src/routes/(root)/(logged)/+layout.svelte | 85 +++++++++++++++---- 3 files changed, 107 insertions(+), 20 deletions(-) diff --git a/frontend/src/lib/components/PageHeaderBar.svelte b/frontend/src/lib/components/PageHeaderBar.svelte index 0cc47e58ca..f30a77cba5 100644 --- a/frontend/src/lib/components/PageHeaderBar.svelte +++ b/frontend/src/lib/components/PageHeaderBar.svelte @@ -106,7 +106,8 @@ The row's height matches the sidebar's own header row, so the two read as one ba {#if (navDetached.val || panelled) && !navHidden} @@ -123,7 +124,9 @@ The row's height matches the sidebar's own header row, so the two read as one ba iconOnly startIcon={{ icon: PanelLeft }} aria-label="Show sidebar" - onclick={() => navHandleSlot.open()} + selected={navHandleSlot.pinned} + aria-pressed={navHandleSlot.pinned} + onclick={() => navHandleSlot.toggle()} />
{/if} diff --git a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts index 5fb7b43860..8648961ef6 100644 --- a/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts +++ b/frontend/src/lib/components/sidebar/navHandlePlacement.svelte.ts @@ -6,6 +6,13 @@ let opener: (() => void) | undefined // Closing it once the pointer is off both the handle and the card, which the layout owns because // the card is its markup. let hoverClose: { schedule: () => void; cancel: () => void } | undefined +type CardState = { + isOpen: () => boolean + isPinned: () => boolean + pin: () => void + close: () => void +} +let card: CardState | undefined // Where the card's top edge goes: the header's bottom edge, so the two meet in a line rather than // the card starting at whatever height the control that opened it happens to have. Measured at open @@ -21,10 +28,34 @@ export const navHandleSlot = { get cardTop(): number { return cardTop }, - /** The root layout owns the card, so it registers how to open and hover-close it. */ - setOpener(open: () => void, close?: { schedule: () => void; cancel: () => void }) { + /** The card is open and pinned, so the handle shows as selected. Reactive. */ + get pinned(): boolean { + return !!card?.isOpen() && card.isPinned() + }, + /** The root layout owns the card, so it registers how to open, pin, close and hover-close it. */ + setOpener( + open: () => void, + close?: { schedule: () => void; cancel: () => void }, + state?: CardState + ) { opener = open hoverClose = close + card = state + }, + /** + * A click on the handle pins the card, opening it first if it is closed: a pinned card stays + * when the pointer leaves, until a press on the page, Esc, or the next click here closes it. A + * card the pointer opened is not pinned yet, so the click that follows the hover pins it — and + * a touch tap, which sends that mouseenter just before its click, pins it too. + */ + toggle(anchor?: Element | null) { + if (card?.isOpen() && card.isPinned()) { + card.close() + return + } + if (!card?.isOpen()) this.open(anchor) + hoverClose?.cancel() + card?.pin() }, /** Pointer left the handle: close, unless it lands on the card before the delay is up. */ scheduleClose() { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 868d78c73b..2dcdeab5dd 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -86,6 +86,7 @@ import { twMerge } from 'tailwind-merge' import { navDetached } from '$lib/components/sidebar/navDetached.svelte' import { navHandleSlot } from '$lib/components/sidebar/navHandlePlacement.svelte' + import { overlayStack } from '$lib/components/common/overlayHost.svelte' import PageHeaderBar from '$lib/components/PageHeaderBar.svelte' import { BAR_HEIGHT_PX, pageHeader } from '$lib/components/pageHeaderRegistry.svelte' import { sidebarPageAllowed } from '$lib/components/sidebar/operatorRoutes' @@ -514,8 +515,11 @@ function pointerInMenu(): boolean { return !!document.querySelector('[role="menu"]:hover') } + // A click on the handle pins the card: it then outlives the pointer leaving. Only meaningful + // while open — every open from closed starts unpinned. + let menuPinned = $state(false) const { debounced: schedulePeekClose, clearDebounce: cancelPeekClose } = debounce(() => { - if (!peeksOnHover) return + if (!peeksOnHover || menuPinned) return if (pointerInMenu()) { schedulePeekClose() return @@ -528,10 +532,56 @@ const floatBand = $derived(pageHeader.content?.barRightInset != null) // The handle and the edge band both open the card this layout owns. - navHandleSlot.setOpener(() => (menuOpen = true), { - schedule: schedulePeekClose, - cancel: cancelPeekClose - }) + navHandleSlot.setOpener( + () => { + if (!menuOpen) menuPinned = false + menuOpen = true + }, + { schedule: schedulePeekClose, cancel: cancelPeekClose }, + { + isOpen: () => menuOpen, + isPinned: () => menuPinned, + pin: () => (menuPinned = true), + close: () => (menuOpen = false) + } + ) + + // The open card is dismissed by any press on the page, and that press still reaches its target: + // a layer that caught it would cost every dismissal a second click. Only presses inside the page + // column count — the card itself and the menus it portals to `body` sit outside it — and the + // handle toggles the card itself. Capture phase, so a target that stops propagation still closes it. + let pageColumn: HTMLElement | undefined = $state() + function closeCardOnPagePress(e: PointerEvent) { + if (!menuOpen || !useDrawer) return + const target = e.target + if (!(target instanceof Element) || !pageColumn?.contains(target)) return + if (target.closest('[data-nav-handle]')) return + menuOpen = false + } + // A press inside an iframe (a session's preview) never reaches this window; focus moving into one does. + function closeCardOnFrameFocus() { + if (!menuOpen || !useDrawer) return + setTimeout(() => { + if (document.activeElement instanceof HTMLIFrameElement) menuOpen = false + }) + } + + const viewportOverlays = overlayStack() + function onWindowKeydown(e: KeyboardEvent) { + onSearchShortcutBeforeLoad(e) + if (e.key !== 'Escape' || !menuOpen || !useDrawer) return + // Read now: the overlay this Esc closes is off the stack by the time the timeout runs. + if (viewportOverlays.val.length > 0) return + // This listener is attached before any drawer's or menu's, which mount later: wait for + // them, so an Esc that closes one of those leaves the card open. + setTimeout(() => { + if (e.defaultPrevented || document.querySelector('[role="menu"]:not([hidden])')) return + const focusInCard = !!document.activeElement?.closest('[data-nav-card]') + menuOpen = false + // The closed card is inert and would drop focus to `body`. + if (focusInCard) document.querySelector('[data-nav-handle] button')?.focus() + }) + } // Set once the user docks or detaches, so the rail that mounts from then on skips its // app-entry animation (`wm-sidebar-in`) and only plays `railMorph`. @@ -1041,7 +1091,12 @@ }) - + - +
{ - if (e.target === e.currentTarget) menuOpen = false - }} >
@@ -1416,7 +1469,7 @@
{/if} {/if} -
+
From c7df0093d0d9a38362222b7350bf387c31af9392 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Tue, 13 Oct 2026 15:36:59 +0200 Subject: [PATCH 06/30] fix: stop marking run form fields as disabled for assistive tech (#11714) Co-authored-by: Claude Opus 5.5 (1M context) --- frontend/src/lib/components/SchemaForm.svelte | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index d05c3cce72..d761101d76 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -300,7 +300,14 @@
From a3653593f2b064b762a19a1d4294988c27baaec0 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:37:15 +0200 Subject: [PATCH 07/30] fix: keep the resource type search current between daily fills, and serve it without an index (#11712) Claude-Session: https://claude.ai/code/session_015YR13nJWziUSWktZwhC7dx Co-authored-by: Claude Opus 5.5 --- backend/windmill-api-embeddings/src/lib.rs | 390 ++++++++++++++---- .../tests/resources.rs | 59 +++ 2 files changed, 371 insertions(+), 78 deletions(-) diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 6e4d6a1c71..70528491e8 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -9,16 +9,11 @@ use windmill_common::DEFAULT_HUB_BASE_URL; #[cfg(feature = "embedding")] use windmill_common::HUB_BASE_URL; -use axum::Router; - -#[cfg(feature = "embedding")] use axum::{ extract::{Path, Query}, - Json, + routing::get, + Extension, Json, Router, }; - -#[cfg(feature = "embedding")] -use axum::routing::get; #[cfg(feature = "embedding")] use candle_core::{Device, Tensor}; #[cfg(feature = "embedding")] @@ -27,10 +22,7 @@ use candle_nn::VarBuilder; use candle_transformers::models::bert::{BertModel, Config, DTYPE}; #[cfg(feature = "embedding")] use hf_hub::api::tokio::Api; -#[cfg(feature = "embedding")] -use serde::Deserialize; -#[cfg(feature = "embedding")] -use serde::Serialize; +use serde::{Deserialize, Serialize}; #[cfg(feature = "embedding")] use sqlx::{Pool, Postgres}; #[cfg(feature = "embedding")] @@ -44,9 +36,7 @@ use tokenizers::Tokenizer; use tokio::sync::RwLock; #[cfg(feature = "embedding")] use windmill_common::utils::http_get_from_hub; - -#[cfg(feature = "embedding")] -use windmill_common::error::JsonResult; +use windmill_common::{db::DB, error::JsonResult}; #[cfg(feature = "embedding")] use windmill_store::resources::ResourceType; @@ -60,6 +50,7 @@ lazy_static::lazy_static! { // full pulling interval, so a transient startup error doesn't leave the // embeddings DB uninitialized for a whole day. pub static ref HUB_EMBEDDINGS_RETRY_INTERVAL_SECS: u64 = std::env::var("HUB_EMBEDDINGS_RETRY_INTERVAL_SECS").ok().map(|x| x.parse::().ok()).flatten().unwrap_or(60); + pub static ref RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS: u64 = std::env::var("RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS").ok().map(|x| x.parse::().ok()).flatten().unwrap_or(60); } #[cfg(feature = "embedding")] @@ -104,14 +95,12 @@ async fn query_hub_scripts( } } -#[cfg(feature = "embedding")] #[derive(Deserialize)] struct ResourceTypesQuery { text: String, limit: Option, } -#[cfg(feature = "embedding")] #[derive(Serialize)] pub struct ResourceTypeResult { name: String, @@ -138,24 +127,89 @@ fn trim_to_top_score( .take_while(|r| (top_score - score(r)) / top_score <= max_relative_drop) .collect() } -#[cfg(feature = "embedding")] async fn query_resource_types( + Extension(db): Extension, Query(query): Query, Path(w_id): Path, ) -> JsonResult> { - let embeddings_db = EMBEDDINGS_DB.read().await; - - if let Some(embeddings_db) = embeddings_db.as_ref() { - let results = embeddings_db - .query_resource_types(w_id, &query.text, query.limit) - .await?; - - Ok(Json(results)) - } else { - Err(windmill_common::error::Error::internal_err( - "Embeddings db not initialized".to_string(), - )) + #[cfg(feature = "embedding")] + if let Some(embeddings_db) = EMBEDDINGS_DB.read().await.as_ref() { + return Ok(Json( + embeddings_db + .query_resource_types(w_id, &query.text, query.limit) + .await?, + )); } + Ok(Json( + match_resource_types_by_text(&db, &w_id, &query.text, query.limit).await?, + )) +} + +/// Serves the search when there is no embeddings index: a build without the `embedding` +/// feature, or one whose index never loaded (air-gapped, `DISABLE_EMBEDDING`). The AI chat +/// has no other way to read the instance's resource types. +async fn match_resource_types_by_text( + db: &DB, + w_id: &str, + text: &str, + limit: Option, +) -> windmill_common::error::Result> { + let words: Vec = text + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_string) + .collect(); + let rows: Vec<(String, Option, Option)> = sqlx::query_as( + "SELECT name, description, schema FROM resource_type WHERE workspace_id = $1 OR workspace_id = 'admins'", + ) + .bind(w_id) + .fetch_all(db) + .await?; + + let mut results: Vec = rows + .into_iter() + .map(|(name, description, schema)| ResourceTypeResult { + score: text_match_score(&words, &name, description.as_deref().unwrap_or_default()), + name, + schema, + }) + .filter(|rt| rt.score > 0.0) + .collect(); + results.sort_by(|a, b| { + b.score + .total_cmp(&a.score) + .then_with(|| a.name.cmp(&b.name)) + }); + results.truncate(limit.unwrap_or(10).max(0) as usize); + Ok(results) +} + +/// A word found in the name counts twice a word found in the description: names are what +/// a query for a service spells out ("postgres" in `postgresql`, "sheets" in `gsheets`). +/// A word under 3 characters ("s3", "ai") would sit inside too many names, so it counts only +/// as a whole name or one of its `_`-separated parts. +fn text_match_score(words: &[String], name: &str, description: &str) -> f32 { + let name = name.to_lowercase(); + let description = description.to_lowercase(); + words + .iter() + .map(|word| { + let short = word.len() < 3; + let in_name = if short { + name.split('_').any(|part| part == word) + } else { + name.contains(word.as_str()) + }; + if in_name { + 2.0 + } else if !short && description.contains(word.as_str()) { + 1.0 + } else { + 0.0 + } + }) + .sum() } #[cfg(feature = "embedding")] @@ -288,17 +342,60 @@ impl ModelInstance { pub struct EmbeddingsDb { db: Db, model_instance: Arc, + /// Kept from the last full fill, so the resource types can be re-indexed between fills + /// without downloading the hub's embeddings again. + hub_resource_type_embeddings: Arc>>, + /// The `resource_types_fingerprint` the resource types collection was built from. + resource_types_fingerprint: Option, + /// See `ResourceTypeIndex::custom_vectors`. + custom_resource_type_vectors: Arc>>, +} + +/// A built resource types collection, with what a later refresh needs to tell it is stale and +/// to reuse its vectors. +#[cfg(feature = "embedding")] +struct ResourceTypeIndex { + fingerprint: Option, + embeddings: Vec, + /// Vectors of the types the hub has none for, by the `name;description` they embed: a + /// refresh runs the model only on types that are new or changed, not on every custom type. + custom_vectors: Arc>>, } #[cfg(feature = "embedding")] impl EmbeddingsDb { + fn empty(model_instance: Arc) -> Self { + Self { + db: Db::new(), + model_instance, + hub_resource_type_embeddings: Arc::new(HashMap::new()), + resource_types_fingerprint: None, + custom_resource_type_vectors: Arc::new(HashMap::new()), + } + } + pub async fn new(pg_db: &Pool, model_instance: Arc) -> Result { - let db = Db::new(); - - let mut embeddings_db = Self { db, model_instance: model_instance.clone() }; - + let mut embeddings_db = Self::empty(model_instance); embeddings_db.fill_db(pg_db).await?; + Ok(embeddings_db) + } + /// The resource types alone, all embedded locally, for when the full fill cannot download + /// the hub's embeddings: an air-gapped instance, or a private hub serving none. Hub script + /// search keeps failing, since it has no collection to search. + async fn resource_types_only( + pg_db: &Pool, + model_instance: Arc, + ) -> Result { + let mut embeddings_db = Self::empty(model_instance); + let index = resource_type_index( + pg_db, + &embeddings_db.model_instance, + &embeddings_db.hub_resource_type_embeddings, + &embeddings_db.custom_resource_type_vectors, + ) + .await?; + embeddings_db.replace_resource_types(index)?; Ok(embeddings_db) } @@ -310,13 +407,6 @@ impl EmbeddingsDb { self.db .create_collection("scripts".to_string(), 384, Distance::Cosine)?; - if self.db.get_collection("resource_types").is_some() { - self.db.delete_collection("resource_types")?; - } - - self.db - .create_collection("resource_types".to_string(), 384, Distance::Cosine)?; - let hub_base_url = (**HUB_BASE_URL.load()).clone(); let response = match hub_base_url.as_str() { @@ -425,44 +515,35 @@ impl EmbeddingsDb { )); } let hub_resource_types = response.json::>().await?; + self.hub_resource_type_embeddings = Arc::new( + hub_resource_types + .into_iter() + .map(|rt| (rt.name, rt.embedding)) + .collect(), + ); - let resource_types: Vec = - sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",) - .fetch_all(pg_db) - .await?; - - for rt in resource_types { - let mut hm = HashMap::new(); - hm.insert("name".to_string(), rt.name.clone()); - if let Some(schema) = rt.schema.clone() { - hm.insert("schema".to_string(), serde_json::to_string(&schema)?); - } - hm.insert("workspace".to_string(), rt.workspace_id.clone()); - let hub_rt = hub_resource_types.iter().find(|hrt| hrt.name == rt.name); - - let vector = if let Some(hub_rt) = hub_rt { - hub_rt.embedding.clone() - } else { - self.model_instance - .clone() - .create_embedding(&format!( - "{};{}", - rt.name, - rt.description.unwrap_or_default() - )) - .await? - }; - - let embedding = Embedding { - id: format!("{}_{}", rt.workspace_id, rt.name), - vector, - metadata: Some(hm), - }; + let index = resource_type_index( + pg_db, + &self.model_instance, + &self.hub_resource_type_embeddings, + &self.custom_resource_type_vectors, + ) + .await?; + self.replace_resource_types(index) + } + fn replace_resource_types(&mut self, index: ResourceTypeIndex) -> Result<()> { + if self.db.get_collection("resource_types").is_some() { + self.db.delete_collection("resource_types")?; + } + self.db + .create_collection("resource_types".to_string(), 384, Distance::Cosine)?; + for embedding in index.embeddings { self.db .insert_into_collection("resource_types", embedding)?; } - + self.resource_types_fingerprint = index.fingerprint; + self.custom_resource_type_vectors = index.custom_vectors; Ok(()) } @@ -617,6 +698,95 @@ impl EmbeddingsDb { } } +/// Covers everything an indexed resource type is built from, whichever path wrote it: the +/// create and update routes, a hub sync's direct upserts, a delete. +/// +/// A sum of per-row hashes needs no sort. Don't go back to an ORDER BY aggregate: sorting +/// every schema spills to disk once they exceed work_mem, on every check of every server. +#[cfg(feature = "embedding")] +async fn resource_types_fingerprint(pg_db: &Pool) -> Result> { + Ok(sqlx::query_scalar( + "SELECT count(*)::text || ':' || coalesce(sum(('x' || left(md5(workspace_id || '/' || name || '/' || coalesce(description, '') || '/' || coalesce(schema::text, '')), 15))::bit(60)::bigint), 0)::text FROM resource_type", + ) + .fetch_one(pg_db) + .await?) +} + +/// The fingerprint is read before the rows: a write landing in between leaves it stale, so +/// the next check indexes again rather than keeping an index that misses the write. +#[cfg(feature = "embedding")] +async fn resource_type_index( + pg_db: &Pool, + model_instance: &Arc, + hub_embeddings: &HashMap>, + known_custom_vectors: &HashMap>, +) -> Result { + let fingerprint = resource_types_fingerprint(pg_db).await?; + let resource_types: Vec = + sqlx::query_as!(ResourceType, "SELECT workspace_id, name, schema, description, created_by, edited_at, format_extension, is_fileset, display_name from resource_type ORDER BY name",) + .fetch_all(pg_db) + .await?; + + let mut embeddings = Vec::with_capacity(resource_types.len()); + let mut custom_vectors = HashMap::new(); + for rt in resource_types { + let mut hm = HashMap::new(); + hm.insert("name".to_string(), rt.name.clone()); + if let Some(schema) = rt.schema.clone() { + hm.insert("schema".to_string(), serde_json::to_string(&schema)?); + } + hm.insert("workspace".to_string(), rt.workspace_id.clone()); + + let vector = match hub_embeddings.get(&rt.name) { + Some(vector) => vector.clone(), + None => { + let text = format!("{};{}", rt.name, rt.description.unwrap_or_default()); + let vector = match known_custom_vectors + .get(&text) + .or_else(|| custom_vectors.get(&text)) + { + Some(vector) => vector.clone(), + None => model_instance.clone().create_embedding(&text).await?, + }; + custom_vectors.insert(text, vector.clone()); + vector + } + }; + + embeddings.push(Embedding { + id: format!("{}_{}", rt.workspace_id, rt.name), + vector, + metadata: Some(hm), + }); + } + Ok(ResourceTypeIndex { fingerprint, embeddings, custom_vectors: Arc::new(custom_vectors) }) +} + +/// The full fill runs once a day, so a resource type created or synced in between would stay +/// unsearchable until then; this re-indexes the resource types alone when the table changed. +#[cfg(feature = "embedding")] +pub async fn refresh_resource_type_embeddings(pg_db: &Pool) -> Result<()> { + if EMBEDDINGS_DB.read().await.is_none() { + return Ok(()); + } + let fingerprint = resource_types_fingerprint(pg_db).await?; + let (model_instance, hub_embeddings, custom_vectors) = match EMBEDDINGS_DB.read().await.as_ref() + { + Some(db) if db.resource_types_fingerprint != fingerprint => ( + db.model_instance.clone(), + db.hub_resource_type_embeddings.clone(), + db.custom_resource_type_vectors.clone(), + ), + _ => return Ok(()), + }; + let index = + resource_type_index(pg_db, &model_instance, &hub_embeddings, &custom_vectors).await?; + if let Some(db) = EMBEDDINGS_DB.write().await.as_mut() { + db.replace_resource_types(index)?; + } + Ok(()) +} + #[cfg(feature = "embedding")] fn normalize_l2(v: &Tensor) -> Result { Ok(v.broadcast_div(&v.sqr()?.sum_keepdim(1)?.sqrt()?)?) @@ -630,6 +800,19 @@ pub fn load_embeddings_db(db: &Pool) -> () { .unwrap_or(false); if !disable_embedding { + let resource_types_db = db.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs( + *RESOURCE_TYPE_EMBEDDINGS_CHECK_INTERVAL_SECS, + )) + .await; + if let Err(e) = refresh_resource_type_embeddings(&resource_types_db).await { + tracing::warn!("Failed to refresh resource type embeddings: {e:#}"); + } + } + }); + let db_clone = db.clone(); tokio::spawn(async move { // Keep retrying model init: a transient failure here must not @@ -664,6 +847,9 @@ pub fn load_embeddings_db(db: &Pool) -> () { backoff_secs = *HUB_EMBEDDINGS_RETRY_INTERVAL_SECS; *HUB_EMBEDDINGS_PULLING_INTERVAL_SECS } else { + if let Err(e) = index_resource_types_alone(&db_clone).await { + tracing::warn!("Failed to index the resource types alone: {e:#}"); + } let secs = backoff_secs; backoff_secs = backoff_secs .saturating_mul(2) @@ -696,7 +882,29 @@ pub async fn update_embeddings_db(db: &Pool) -> bool { } } +/// After a failed full fill, so a fill still downloading at startup is not raced. A full fill +/// that succeeds later replaces this index. #[cfg(feature = "embedding")] +async fn index_resource_types_alone(pg_db: &Pool) -> Result<()> { + if EMBEDDINGS_DB.read().await.is_some() { + return Ok(()); + } + let Some(model_instance) = MODEL_INSTANCE.read().await.clone() else { + return Ok(()); + }; + let started = std::time::Instant::now(); + let embeddings_db = EmbeddingsDb::resource_types_only(pg_db, model_instance).await?; + let mut current = EMBEDDINGS_DB.write().await; + if current.is_none() { + *current = Some(embeddings_db); + tracing::info!( + "Indexed the resource types alone in {:?}, without the hub's embeddings", + started.elapsed() + ); + } + Ok(()) +} + pub fn workspaced_service() -> Router { Router::new().route("/query_resource_types", get(query_resource_types)) } @@ -706,11 +914,6 @@ pub fn global_service() -> Router { Router::new().route("/query_hub_scripts", get(query_hub_scripts)) } -#[cfg(not(feature = "embedding"))] -pub fn workspaced_service() -> Router { - Router::new() -} - #[cfg(not(feature = "embedding"))] pub fn global_service() -> Router { Router::new() @@ -768,3 +971,34 @@ mod tests { assert_eq!(trim_to_top_score(vec![0.42f32], 0.05, |s| *s), vec![0.42]); } } + +#[cfg(test)] +mod text_match_tests { + use super::text_match_score; + + // A query names the service, which a type's name only contains ("postgres" in + // `postgresql`): an exact comparison finds nothing for "postgres database". + #[test] + fn ranks_a_name_match_above_a_description_match() { + let words = vec!["postgres".to_string(), "database".to_string()]; + assert_eq!( + text_match_score(&words, "PostgreSQL", "PostgreSQL connection"), + 2.0 + ); + assert_eq!( + text_match_score(&words, "mysql", "MySQL database credentials"), + 1.0 + ); + assert_eq!(text_match_score(&words, "slack", "Slack bot token"), 0.0); + } + + // Many services go by a short name; matching it inside other names would rank `ms365` + // with `s3`. + #[test] + fn matches_a_short_word_only_as_a_whole_name_part() { + let words = vec!["s3".to_string()]; + assert_eq!(text_match_score(&words, "s3", "Amazon S3 bucket"), 2.0); + assert_eq!(text_match_score(&words, "aws_s3", ""), 2.0); + assert_eq!(text_match_score(&words, "ms365", "mentions s3"), 0.0); + } +} diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 9c167ef70c..823c415818 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -751,6 +751,65 @@ async fn test_resource_cache_handles_job_context(db: Pool) -> anyhow:: Ok(()) } +/// Without an embeddings index the resource type search reads the table, so it has to apply the +/// same scope as the index: the workspace's own types and the global ones in `admins`, never +/// another workspace's. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_resource_type_search_without_embeddings(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other-workspace', 'test-user')") + .execute(&db) + .await?; + for (workspace, name, description) in [ + ( + "test-workspace", + "c_acme_billing", + "Acme internal billing API", + ), + ( + "other-workspace", + "c_other_billing", + "Another workspace's billing API", + ), + ] { + sqlx::query( + "INSERT INTO resource_type (workspace_id, name, schema, description, created_by) VALUES ($1, $2, '{}'::jsonb, $3, 'test-user')", + ) + .bind(workspace) + .bind(name) + .bind(description) + .execute(&db) + .await?; + } + + let search = |text: &str| { + let url = format!( + "http://localhost:{port}/api/w/test-workspace/embeddings/query_resource_types?text={text}" + ); + async move { + let resp = authed(client().get(url)).send().await.unwrap(); + assert_eq!(resp.status(), 200); + resp.json::>() + .await + .unwrap() + .into_iter() + .map(|rt| rt["name"].as_str().unwrap().to_string()) + .collect::>() + } + }; + + let billing = search("billing").await; + assert_eq!(billing.first().map(String::as_str), Some("c_acme_billing")); + assert!(!billing.contains(&"c_other_billing".to_string())); + // `ai_skill` is seeded in `admins` by the migrations. + assert!(search("skill").await.contains(&"ai_skill".to_string())); + + Ok(()) +} + #[cfg(feature = "mcp")] #[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))] async fn test_mcp_tools(db: Pool) -> anyhow::Result<()> { From 3e43e6f06fce520a2a219cd29bb2c3c6083d7dd9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:37:32 +0200 Subject: [PATCH 08/30] oauth: add hubspot provider (#11695) * oauth: add hubspot provider Registry entry (authorize + token URL, body credentials, least-privilege default scopes, labelled scope_options) and the admin OAuth tile. Co-Authored-By: Claude Opus 5.5 * oauth: send hubspot non-default scopes as optional_scope Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/oauth_connect.json | 54 +++++++++++++++++++ .../src/lib/components/AuthSettings.svelte | 3 +- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 3c4de3b082..ceabe29145 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -445,5 +445,59 @@ "mailboxes.read" ], "req_body_auth": true + }, + "hubspot": { + "auth_url": "https://app.hubspot.com/oauth/authorize", + "token_url": "https://api.hubapi.com/oauth/v1/token", + "scopes": [ + "oauth", + "crm.objects.contacts.read", + "crm.objects.contacts.write", + "crm.objects.companies.read", + "crm.objects.companies.write", + "crm.objects.deals.read", + "crm.objects.deals.write", + "crm.objects.owners.read" + ], + "scope_options": [ + { "value": "oauth", "label": "Basic OAuth access (always required by HubSpot)" }, + { "value": "crm.objects.contacts.read", "label": "Read contacts, and notes, tasks, meetings and calls" }, + { "value": "crm.objects.contacts.write", "label": "Create and edit contacts, and notes, tasks, meetings and calls" }, + { "value": "crm.objects.companies.read", "label": "Read companies" }, + { "value": "crm.objects.companies.write", "label": "Create and edit companies" }, + { "value": "crm.objects.deals.read", "label": "Read deals and deal pipelines" }, + { "value": "crm.objects.deals.write", "label": "Create and edit deals" }, + { "value": "crm.objects.owners.read", "label": "Read owners (users assignable to records)" }, + { "value": "tickets", "label": "Read and edit tickets and ticket pipelines" }, + { "value": "crm.objects.line_items.read", "label": "Read line items" }, + { "value": "crm.objects.line_items.write", "label": "Create and edit line items" }, + { "value": "e-commerce", "label": "Read and edit products and line items" }, + { "value": "crm.objects.quotes.read", "label": "Read quotes" }, + { "value": "crm.objects.quotes.write", "label": "Create and edit quotes" }, + { "value": "crm.objects.leads.read", "label": "Read leads" }, + { "value": "crm.objects.leads.write", "label": "Create and edit leads" }, + { "value": "crm.objects.custom.read", "label": "Read custom object records" }, + { "value": "crm.objects.custom.write", "label": "Create and edit custom object records" }, + { "value": "crm.schemas.custom.read", "label": "Read custom object schemas" }, + { "value": "crm.lists.read", "label": "Read lists (segments)" }, + { "value": "crm.lists.write", "label": "Edit lists and their members" }, + { "value": "sales-email-read", "label": "Read the content of logged one-to-one emails" }, + { "value": "forms", "label": "Read and edit forms and their submissions" }, + { "value": "files", "label": "Read and upload files in the file manager" }, + { "value": "content", "label": "Read and edit marketing emails, blog posts, pages and templates" }, + { "value": "crm.objects.marketing_events.read", "label": "Read marketing events" }, + { "value": "marketing.campaigns.read", "label": "Read marketing campaigns (Marketing Hub Professional)" }, + { "value": "communication_preferences.read_write", "label": "Read and change email subscription preferences" }, + { "value": "automation", "label": "Read, create and enroll into workflows (Professional)" }, + { "value": "automation.sequences.read", "label": "Read sequences and sequence enrollments (Sales Hub Professional)" }, + { "value": "automation.sequences.enrollments.write", "label": "Enroll contacts in sequences (Sales Hub Professional)" }, + { "value": "conversations.read", "label": "Read inboxes, threads and messages" }, + { "value": "conversations.write", "label": "Send messages and update threads" }, + { "value": "scheduler.meetings.meeting-link.read", "label": "Read meeting links and their availability" } + ], + "extra_params": { + "optional_scope": "tickets crm.objects.line_items.read crm.objects.line_items.write e-commerce crm.objects.quotes.read crm.objects.quotes.write crm.objects.leads.read crm.objects.leads.write crm.objects.custom.read crm.objects.custom.write crm.schemas.custom.read crm.lists.read crm.lists.write sales-email-read forms files content crm.objects.marketing_events.read marketing.campaigns.read communication_preferences.read_write automation automation.sequences.read automation.sequences.enrollments.write conversations.read conversations.write scheduler.meetings.meeting-link.read" + }, + "req_body_auth": true } } diff --git a/frontend/src/lib/components/AuthSettings.svelte b/frontend/src/lib/components/AuthSettings.svelte index f211650df2..d1dd5fa08b 100644 --- a/frontend/src/lib/components/AuthSettings.svelte +++ b/frontend/src/lib/components/AuthSettings.svelte @@ -97,7 +97,8 @@ 'docusign', 'salesforce', 'outreach', - 'notion' + 'notion', + 'hubspot' ] // Providers whose registry entry (`backend/oauth_connect.json`) carries a // `sandbox` URL block. Each one gets a sibling `_sandbox` dropdown From d8ac4f59c8d570777e43a7aa7ffbc97598897cc2 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:40:24 +0200 Subject: [PATCH 09/30] fix: surface oauth provider errors returned with http 200 (#11713) * fix: bump git sync hub scripts to cli 1.830.0 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011BzU9zd53JsTShCsbKzYzB * fix: surface oauth provider errors returned with http 200 Co-Authored-By: Claude Opus 5.5 * chore: update ee repo ref Co-Authored-By: Claude Opus 5.5 * fix: show the unavailable sign-in option in the oauth resource form Co-Authored-By: Claude Opus 5.5 * fix: name the oauth grant in both authentication card descriptions Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 0a7c28718872bb5cd624d77e32ad7d75c6ce42de This commit updates the EE repository reference after PR #874 was merged in windmill-ee-private. Previous ee-repo-ref: 11425053010c3cd4670bc06f3606fa18cccc2091 New ee-repo-ref: 0a7c28718872bb5cd624d77e32ad7d75c6ce42de Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/windmill-oauth/src/lib.rs | 32 ++++++++++++++++++- .../src/lib/components/AppConnectInner.svelte | 32 ++++++++++++------- .../common/radioCard/RadioCard.svelte | 6 ++-- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 695f6d248b..d68805fb8a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -40ab35596e6fcb82a4dfc56e186befb807f8e2af +0a7c28718872bb5cd624d77e32ad7d75c6ce42de diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 15f5ac6b9e..9768911813 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -790,7 +790,26 @@ pub async fn exchange_code( .with_client(http_client) .execute::() .await - .map_err(|e| error::Error::InternalErr(format!("{:?}", e))) + .map_err(|e| error::Error::InternalErr(token_error_message(&e))) +} + +/// Message for a failed token request. Some providers (Zoho) answer an OAuth error with +/// HTTP 200, which the client reports as an unparseable token response, not an error one. +pub fn token_error_message(e: &ExecuteError) -> String { + match e { + ExecuteError::ErrorResponse { error, .. } => error.to_string(), + ExecuteError::BadResponse { status, body, .. } => { + serde_json::from_slice::(body) + .map(|r| r.to_string()) + .unwrap_or_else(|_| { + format!( + "unexpected response ({status}): {}", + String::from_utf8_lossy(body) + ) + }) + } + e => format!("{e:?}"), + } } /// Internal token exchange implementation @@ -1212,6 +1231,17 @@ mod tests { assert!(verifier.verify("123", "body", "wrong_sig").is_err()); } + #[test] + fn token_error_message_reads_an_error_sent_with_http_200() { + let body: &[u8] = br#"{"error":"invalid_client"}"#; + let e = ExecuteError::BadResponse { + status: reqwest::StatusCode::OK, + body: body.into(), + error: serde_json::from_slice::(body).unwrap_err(), + }; + assert_eq!(token_error_message(&e), "invalid_client"); + } + // Sage Intacct's token endpoint rejects HTTP Basic client authentication on the // refresh_token grant (`invalid_client`), so its credentials must go in the form body. #[test] diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 56bff89d8f..18a28ec95d 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -322,6 +322,15 @@ (registryCcCapable() && connectClient != '' && !(connects?.includes(connectClient) ?? false)) ) + /** The provider also has a browser sign-in, unavailable here only because the + * instance holds no OAuth client for it */ + let signInNeedsInstanceClient = $derived.by( + () => + (registryEntry()?.grant_types?.includes('authorization_code') ?? false) && + connectClient != '' && + !(connects?.includes(connectClient) ?? false) + ) + /** Clear CC inputs and scopes so a previous selection never leaks into a new one */ function resetClientCredentialsState() { supportsClientCredentials = false @@ -1399,11 +1408,7 @@
{#if apiTokenApps[resourceType].img}
- connect + connect
{/if} {/if} @@ -1514,14 +1519,14 @@ {#if supportsClientCredentials}

Authentication

- {#if ccOnly || ccBringYourOwn} + {#if (ccOnly || ccBringYourOwn) && !signInNeedsInstanceClient}
{#if useSharedInstanceCreds} {resourceType} connects server-to-server using the credentials configured for this - instance. The token is acquired and refreshed automatically. + instance. {:else} - {resourceType} connects server-to-server. Enter a client ID and secret; the token is - acquired and refreshed automatically. + {resourceType} connects server-to-server with the client credentials grant. Enter a + client ID and secret. {/if}
{:else} @@ -1534,17 +1539,20 @@ > enableClientCredentials()} /> diff --git a/frontend/src/lib/components/common/radioCard/RadioCard.svelte b/frontend/src/lib/components/common/radioCard/RadioCard.svelte index f9b7f675a4..e152324fce 100644 --- a/frontend/src/lib/components/common/radioCard/RadioCard.svelte +++ b/frontend/src/lib/components/common/radioCard/RadioCard.svelte @@ -51,8 +51,10 @@ // (FileExplorer, TriggersTable, RunnableRow, ArtifactVersionPicker). selected ? 'border-border-selected bg-surface-accent-selected' - : 'border-border-light hover:bg-surface-hover', - disabled && 'opacity-50 cursor-not-allowed', + : 'border-border-light enabled:hover:bg-surface-hover', + // `disabled:` variants, not plain utilities: the global `button:disabled` + // rule sets opacity and cursor and outranks a single class. + 'disabled:opacity-50 disabled:cursor-not-allowed', className )} > From f629df33829fa65992284585c3f93a1c0422737c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:52:01 +0200 Subject: [PATCH 10/30] oauth: add outlook (Microsoft Graph) provider (#11694) * oauth: add outlook (Microsoft Graph) provider Co-Authored-By: Claude Opus 5.5 * oauth: outlook defaults cover mail, calendar and contacts Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- backend/oauth_connect.json | 31 ++++++ .../lib/components/icons/OutlookIcon.svelte | 94 +++++++++++++++++++ frontend/src/lib/components/icons/index.ts | 3 + 3 files changed, 128 insertions(+) create mode 100644 frontend/src/lib/components/icons/OutlookIcon.svelte diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index ceabe29145..b5ebd80e2a 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -446,6 +446,37 @@ ], "req_body_auth": true }, + "outlook": { + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"], + "scope_options": [ + { "value": "offline_access", "label": "Stay connected (refresh token)" }, + { "value": "User.Read", "label": "Read your profile" }, + { "value": "Mail.Send", "label": "Send mail" }, + { "value": "Mail.Read", "label": "Read and search mail" }, + { "value": "Mail.ReadWrite", "label": "Read, draft, move and modify mail" }, + { "value": "MailboxSettings.Read", "label": "Read mailbox settings and categories" }, + { "value": "Contacts.Read", "label": "Read contacts" }, + { "value": "Contacts.ReadWrite", "label": "Read, create and edit contacts" }, + { "value": "People.Read", "label": "Search relevant people" }, + { "value": "Calendars.Read", "label": "Read calendars and events" }, + { "value": "Calendars.ReadWrite", "label": "Read, create, edit and delete events" }, + { "value": "Calendars.Read.Shared", "label": "Read shared calendars and find meeting times" }, + { "value": "Mail.Send.Shared", "label": "Send mail from shared mailboxes" }, + { "value": "Mail.Read.Shared", "label": "Read mail in shared mailboxes" }, + { "value": "Mail.ReadWrite.Shared", "label": "Read and modify mail in shared mailboxes" } + ], + "connect_config_template": { + "display_name": "Outlook", + "label": "Microsoft Entra tenant", + "placeholder": "common, organizations, or your tenant id", + "help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints", + "auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token", + "req_body_auth": true, + "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"] + } + }, "hubspot": { "auth_url": "https://app.hubspot.com/oauth/authorize", "token_url": "https://api.hubapi.com/oauth/v1/token", diff --git a/frontend/src/lib/components/icons/OutlookIcon.svelte b/frontend/src/lib/components/icons/OutlookIcon.svelte new file mode 100644 index 0000000000..a9cec0cfa6 --- /dev/null +++ b/frontend/src/lib/components/icons/OutlookIcon.svelte @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index bbefc16469..2b63491c6c 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -90,6 +90,7 @@ import ResendIcon from './ResendIcon.svelte' import PushoverIcon from './PushoverIcon.svelte' import QuickbooksIcon from './QuickbooksIcon.svelte' import MsTeamsIcon from './MSTeamsIcon.svelte' +import OutlookIcon from './OutlookIcon.svelte' import MailgunIcon from './MailgunIcon.svelte' import IpinfoIcon from './IpinfoIcon.svelte' import GoogleCloudIcon from './GoogleCloudIcon.svelte' @@ -445,6 +446,7 @@ export const APP_TO_ICON_COMPONENT = { quickbooks: QuickbooksIcon, ms_teams_webhook: MsTeamsIcon, teams: MsTeamsIcon, + outlook: OutlookIcon, mailgun: MailgunIcon, ipinfo: IpinfoIcon, gworkspace: GoogleIcon, @@ -776,6 +778,7 @@ export { PushoverIcon, QuickbooksIcon, MsTeamsIcon, + OutlookIcon, MailgunIcon, IpinfoIcon, GoogleCloudIcon, From 4bc2394322f957e5cf212fa1913b2b39c1b9ca06 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:52:55 +0200 Subject: [PATCH 11/30] fix: resolve variables in the url and headers of an mcp resource (#11680) * fix(cli): count superadmins as able to preserve ownership on push Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_011BzU9zd53JsTShCsbKzYzB * fix: resolve variables in the url and headers of an mcp resource Co-Authored-By: Claude Opus 5.5 * fix: keep resolved mcp secrets out of header warnings and userinfo urls Co-Authored-By: Claude Opus 5.5 * fix: drop the url from mcp client errors before rmcp logs them Co-Authored-By: Claude Opus 5.5 * fix: run rmcp's reqwest client without a log subscriber Co-Authored-By: Claude Opus 5.5 * fix: scrub the request target from mcp error bodies Co-Authored-By: Claude Opus 5.5 * fix: drop server error bodies from mcp transport errors Co-Authored-By: Claude Opus 5.5 * fix: fail closed when an mcp error message has no status prefix Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 (1M context) --- backend/Cargo.lock | 1 + backend/tests/fixtures/mcp_resource_authz.sql | 16 ++ backend/tests/mcp_resource_authz.rs | 43 ++++- backend/windmill-api/src/mcp_tools.rs | 6 +- backend/windmill-mcp/Cargo.toml | 1 + backend/windmill-mcp/src/client/mod.rs | 5 +- backend/windmill-mcp/src/client/redacting.rs | 151 ++++++++++++++++++ backend/windmill-worker/src/ai/utils.rs | 11 ++ 8 files changed, 228 insertions(+), 6 deletions(-) create mode 100644 backend/windmill-mcp/src/client/redacting.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 1bd38513a7..86b5850e80 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16523,6 +16523,7 @@ dependencies = [ "serde", "serde_json", "sqlx", + "sse-stream", "tokio", "tokio-util", "tracing", diff --git a/backend/tests/fixtures/mcp_resource_authz.sql b/backend/tests/fixtures/mcp_resource_authz.sql index ecf3f01dc3..17fcc553b9 100644 --- a/backend/tests/fixtures/mcp_resource_authz.sql +++ b/backend/tests/fixtures/mcp_resource_authz.sql @@ -23,3 +23,19 @@ VALUES ( '{}', 'test-user' ); + +-- MCP resource whose URL lives in a variable. The host only appears in the +-- variable, so an error naming it proves the reference was resolved. +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'u/test-user/mcp_url', 'https://mcp-from-var.invalid.windmill.test/key', false, '', '{}'); + +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ( + 'test-workspace', + 'u/test-user/var_mcp', + '{"name": "var_mcp", "url": "$var:u/test-user/mcp_url"}', + 'MCP resource with its URL in a variable', + 'mcp', + '{}', + 'test-user' +); diff --git a/backend/tests/mcp_resource_authz.rs b/backend/tests/mcp_resource_authz.rs index 0fa35933f4..6ccf5f4f2d 100644 --- a/backend/tests/mcp_resource_authz.rs +++ b/backend/tests/mcp_resource_authz.rs @@ -23,9 +23,9 @@ use windmill_common::client::AuthedClient; use windmill_test_utils::*; use windmill_worker::{load_mcp_tools, McpResourceConfig}; -fn config() -> Vec { +fn config(resource_path: &str) -> Vec { vec![McpResourceConfig { - resource_path: "$res:f/private/admin_mcp".to_string(), + resource_path: resource_path.to_string(), include_tools: None, exclude_tools: None, }] @@ -34,7 +34,16 @@ fn config() -> Vec { // The Ok variant ((HashMap<_, Arc>, Vec)) does not implement // Debug, so `expect_err` is unavailable — extract the error explicitly. async fn load_err(db: &Pool, client: &AuthedClient, ctx: &str) -> String { - match load_mcp_tools(db, "test-workspace", config(), client).await { + load_err_for(db, client, "$res:f/private/admin_mcp", ctx).await +} + +async fn load_err_for( + db: &Pool, + client: &AuthedClient, + resource_path: &str, + ctx: &str, +) -> String { + match load_mcp_tools(db, "test-workspace", config(resource_path), client).await { Ok(_) => panic!("{ctx}"), Err(e) => e.to_string(), } @@ -100,3 +109,31 @@ async fn test_mcp_resource_not_loaded_without_authorization( Ok(()) } + +/// A `$var:` in the resource's `url` is resolved before connecting, rather than +/// being handed to the client as a literal (and rejected as a relative URL). +#[sqlx::test(fixtures("base", "mcp_resource_authz"))] +async fn test_mcp_resource_url_variable_is_resolved(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let client = AuthedClient::new( + format!("http://localhost:{}", server.addr.port()), + "test-workspace".to_string(), + "SECRET_TOKEN".to_string(), + None, + ); + let msg = load_err_for( + &db, + &client, + "$res:u/test-user/var_mcp", + "the variable's URL is non-resolvable, so the connect step must fail", + ) + .await; + assert!( + msg.contains("mcp-from-var.invalid.windmill.test"), + "the URL should come from the variable: {msg}" + ); + + Ok(()) +} diff --git a/backend/windmill-api/src/mcp_tools.rs b/backend/windmill-api/src/mcp_tools.rs index 1e07f374de..a9049951a3 100644 --- a/backend/windmill-api/src/mcp_tools.rs +++ b/backend/windmill-api/src/mcp_tools.rs @@ -125,8 +125,10 @@ async fn connect_mcp_client( None }; - // Header secrets go through the same permissioned path as the token. - for value in mcp_resource.headers.iter_mut().flatten().map(|(_, v)| v) { + // The URL and header secrets go through the same permissioned path as the token. + for value in std::iter::once(&mut mcp_resource.url) + .chain(mcp_resource.headers.iter_mut().flatten().map(|(_, v)| v)) + { if let Some(var_path) = value.strip_prefix("$var:") { check_scopes(authed, || format!("variables:read:{}", var_path))?; let db_authed = DbWithOptAuthed::from_authed(authed, db.clone(), Some(user_db.clone())); diff --git a/backend/windmill-mcp/Cargo.toml b/backend/windmill-mcp/Cargo.toml index 8ac23e885d..2c07b92d44 100644 --- a/backend/windmill-mcp/Cargo.toml +++ b/backend/windmill-mcp/Cargo.toml @@ -22,6 +22,7 @@ serde.workspace = true serde_json.workspace = true tracing.workspace = true rmcp.workspace = true +sse-stream = "0.2" sqlx = { workspace = true, optional = true } chrono = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } diff --git a/backend/windmill-mcp/src/client/mod.rs b/backend/windmill-mcp/src/client/mod.rs index 5d13a40314..6f3928e112 100644 --- a/backend/windmill-mcp/src/client/mod.rs +++ b/backend/windmill-mcp/src/client/mod.rs @@ -3,11 +3,13 @@ //! This module provides functionality for connecting to external MCP servers //! and executing tools on them. +mod redacting; mod types; pub use types::{McpResource, McpToolSource}; use anyhow::{Context, Result}; +use redacting::UrlRedactingClient; use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; use rmcp::{ model::{ @@ -97,7 +99,8 @@ impl McpClient { // Create the HTTP transport with custom client let config = StreamableHttpClientTransportConfig::with_uri(resource.url.as_str()); - let transport = StreamableHttpClientTransport::with_client(reqwest_client, config); + let transport = + StreamableHttpClientTransport::with_client(UrlRedactingClient(reqwest_client), config); // Set up client info let client_info = ClientInfo::new( diff --git a/backend/windmill-mcp/src/client/redacting.rs b/backend/windmill-mcp/src/client/redacting.rs new file mode 100644 index 0000000000..6abcc50f50 --- /dev/null +++ b/backend/windmill-mcp/src/client/redacting.rs @@ -0,0 +1,151 @@ +use std::{borrow::Cow, collections::HashMap, future::Future, sync::Arc}; + +use futures::stream::BoxStream; +use reqwest::header::{HeaderName, HeaderValue}; +use rmcp::{ + model::ClientJsonRpcMessage, + transport::streamable_http_client::{ + SseError, StreamableHttpClient, StreamableHttpError, StreamableHttpPostResponse, + }, +}; +use sse_stream::Sse; +use tracing::{instrument::WithSubscriber, Dispatch}; + +type Error = StreamableHttpError; + +/// reqwest names the URL it failed on in its errors, and an MCP server URL can +/// carry the server's key. rmcp logs those errors as well as returning them, so +/// the URL is dropped before the transport sees one, and rmcp's reqwest client, +/// which logs the errors it swallows, runs without a subscriber. +#[derive(Clone)] +pub(super) struct UrlRedactingClient(pub(super) reqwest::Client); + +async fn redacted(request: impl Future>) -> Result { + match request.with_subscriber(Dispatch::none()).await { + Err(StreamableHttpError::Client(e)) => Err(StreamableHttpError::Client(e.without_url())), + // An owned message is `HTTP : `, and the body is the server's + // to write: a common one echoes the request target (`Cannot POST /mcp/`). + Err(StreamableHttpError::UnexpectedServerResponse(Cow::Owned(message))) => { + let status = message.split_once(": ").map_or("", |(status, _)| status); + let status = status.to_string(); + Err(StreamableHttpError::UnexpectedServerResponse(status.into())) + } + r => r, + } +} + +impl StreamableHttpClient for UrlRedactingClient { + type Error = reqwest::Error; + + async fn post_message( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + ) -> Result { + redacted( + self.0 + .post_message(uri, message, session_id, auth_header, custom_headers), + ) + .await + } + + async fn post_message_with_max_sse_event_size( + &self, + uri: Arc, + message: ClientJsonRpcMessage, + session_id: Option>, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result { + redacted(self.0.post_message_with_max_sse_event_size( + uri, + message, + session_id, + auth_header, + custom_headers, + max_sse_event_size, + )) + .await + } + + async fn delete_session( + &self, + uri: Arc, + session_id: Arc, + auth_header: Option, + custom_headers: HashMap, + ) -> Result<(), Error> { + redacted( + self.0 + .delete_session(uri, session_id, auth_header, custom_headers), + ) + .await + } + + async fn get_stream( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + ) -> Result>, Error> { + redacted( + self.0 + .get_stream(uri, session_id, last_event_id, auth_header, custom_headers), + ) + .await + } + + async fn get_stream_with_max_sse_event_size( + &self, + uri: Arc, + session_id: Option>, + last_event_id: Option, + auth_header: Option, + custom_headers: HashMap, + max_sse_event_size: usize, + ) -> Result>, Error> { + redacted(self.0.get_stream_with_max_sse_event_size( + uri, + session_id, + last_event_id, + auth_header, + custom_headers, + max_sse_event_size, + )) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn server_error_body_is_dropped() { + for (message, expected) in [ + ( + "HTTP 404 Not Found: Cannot POST /mcp/KEY", + "HTTP 404 Not Found", + ), + ("Cannot POST /mcp/KEY", ""), + ] { + let err = redacted(async { + Err::<(), Error>(StreamableHttpError::UnexpectedServerResponse( + message.to_string().into(), + )) + }) + .await + .unwrap_err(); + let StreamableHttpError::UnexpectedServerResponse(got) = err else { + panic!("unexpected variant: {err}"); + }; + assert_eq!(got, expected); + } + } +} diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index f0a0b5c20f..44513c806e 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -615,6 +615,17 @@ pub async fn load_mcp_tools( None }; + // A URL can carry the server's key, so it goes through the same + // permissioned path as the token. + if let Some(var_path) = mcp_resource.url.strip_prefix("$var:") { + mcp_resource.url = client.get_variable_value(var_path).await.map_err(|e| { + Error::internal_err(format!( + "Failed to resolve variable {} for the url of MCP resource {}: {}", + var_path, resource_name, e + )) + })?; + } + // Header secrets go through the same permissioned path as the token. for (name, value) in mcp_resource.headers.iter_mut().flatten() { if let Some(var_path) = value.strip_prefix("$var:") { From 71caac7aec49bafe8f691e8a82b3e6df84f7e4bc Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 15:53:51 +0200 Subject: [PATCH 12/30] fix: keep the resource type index whole when a refresh fails (#11727) Claude-Session: https://claude.ai/code/session_015YR13nJWziUSWktZwhC7dx Co-authored-by: Claude Opus 5.5 Co-authored-by: Ruben Fiszel --- backend/windmill-api-embeddings/src/lib.rs | 73 +++++++++++++++------- 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/backend/windmill-api-embeddings/src/lib.rs b/backend/windmill-api-embeddings/src/lib.rs index 70528491e8..a470648cbb 100644 --- a/backend/windmill-api-embeddings/src/lib.rs +++ b/backend/windmill-api-embeddings/src/lib.rs @@ -160,29 +160,47 @@ async fn match_resource_types_by_text( .filter(|word| !word.is_empty()) .map(str::to_string) .collect(); - let rows: Vec<(String, Option, Option)> = sqlx::query_as( - "SELECT name, description, schema FROM resource_type WHERE workspace_id = $1 OR workspace_id = 'admins'", + let rows: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT workspace_id, name, description FROM resource_type WHERE workspace_id = $1 OR workspace_id = 'admins'", ) .bind(w_id) .fetch_all(db) .await?; - let mut results: Vec = rows + let mut ranked: Vec<(f32, String, String)> = rows .into_iter() - .map(|(name, description, schema)| ResourceTypeResult { - score: text_match_score(&words, &name, description.as_deref().unwrap_or_default()), - name, - schema, + .map(|(workspace_id, name, description)| { + let score = text_match_score(&words, &name, description.as_deref().unwrap_or_default()); + (score, workspace_id, name) }) - .filter(|rt| rt.score > 0.0) + .filter(|(score, _, _)| *score > 0.0) .collect(); - results.sort_by(|a, b| { - b.score - .total_cmp(&a.score) - .then_with(|| a.name.cmp(&b.name)) - }); - results.truncate(limit.unwrap_or(10).max(0) as usize); - Ok(results) + ranked.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.2.cmp(&b.2))); + ranked.truncate(limit.unwrap_or(10).max(0) as usize); + + // Schemas are read for the kept rows only: a synced instance holds megabytes of them. + let (workspaces, names): (Vec<&str>, Vec<&str>) = ranked + .iter() + .map(|(_, workspace_id, name)| (workspace_id.as_str(), name.as_str())) + .unzip(); + let mut schemas: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT workspace_id, name, schema FROM resource_type WHERE (workspace_id, name) IN (SELECT * FROM unnest($1::text[], $2::text[]))", + ) + .bind(&workspaces) + .bind(&names) + .fetch_all(db) + .await?; + + Ok(ranked + .into_iter() + .map(|(score, workspace_id, name)| { + let schema = schemas + .iter_mut() + .find(|(w, n, _)| *w == workspace_id && *n == name) + .and_then(|(_, _, schema)| schema.take()); + ResourceTypeResult { score, name, schema } + }) + .collect()) } /// A word found in the name counts twice a word found in the description: names are what @@ -533,14 +551,19 @@ impl EmbeddingsDb { } fn replace_resource_types(&mut self, index: ResourceTypeIndex) -> Result<()> { - if self.db.get_collection("resource_types").is_some() { - self.db.delete_collection("resource_types")?; - } + // Built aside and swapped in whole: an insert the collection refuses must not leave + // searches on a half-filled index. + const NEXT: &str = "resource_types_next"; + self.db.collections.remove(NEXT); self.db - .create_collection("resource_types".to_string(), 384, Distance::Cosine)?; + .create_collection(NEXT.to_string(), 384, Distance::Cosine)?; for embedding in index.embeddings { + self.db.insert_into_collection(NEXT, embedding)?; + } + if let Some(next) = self.db.collections.remove(NEXT) { self.db - .insert_into_collection("resource_types", embedding)?; + .collections + .insert("resource_types".to_string(), next); } self.resource_types_fingerprint = index.fingerprint; self.custom_resource_type_vectors = index.custom_vectors; @@ -754,7 +777,9 @@ async fn resource_type_index( }; embeddings.push(Embedding { - id: format!("{}_{}", rt.workspace_id, rt.name), + // Neither part can hold a `/`, so two types never share an id; with `_` between + // them, `acme_c` + `c_x` and `acme` + `c_c_x` would. + id: format!("{}/{}", rt.workspace_id, rt.name), vector, metadata: Some(hm), }); @@ -782,7 +807,11 @@ pub async fn refresh_resource_type_embeddings(pg_db: &Pool) -> Result< let index = resource_type_index(pg_db, &model_instance, &hub_embeddings, &custom_vectors).await?; if let Some(db) = EMBEDDINGS_DB.write().await.as_mut() { - db.replace_resource_types(index)?; + // A full fill that landed meanwhile indexed the types from newer hub vectors than + // the ones this index was built from. + if Arc::ptr_eq(&db.hub_resource_type_embeddings, &hub_embeddings) { + db.replace_resource_types(index)?; + } } Ok(()) } From 07bf7e6c1e82d9df82d31025b39eb68ceb911120 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 16:01:19 +0200 Subject: [PATCH 13/30] chore(main): release 1.831.0 (#11664) * chore(main): release 1.831.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 47 +++ backend/Cargo.lock | 346 +++++++++--------- 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 +- chat-sdk/package-lock.json | 4 +- chat-sdk/package.json | 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 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 22 files changed, 271 insertions(+), 216 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fdce702331..0b517c26f7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.830.0" + ".": "1.831.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 80939b1b47..98686e7917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,52 @@ # Changelog +## [1.831.0](https://github.com/windmill-labs/windmill/compare/v1.830.0...v1.831.0) (2026-10-13) + + +### Features + +* add python 3.14 and 3.15 to the supported instance versions ([#11654](https://github.com/windmill-labs/windmill/issues/11654)) ([74c9b0c](https://github.com/windmill-labs/windmill/commit/74c9b0cb2eebc16dd5e23f23be89b127bcbe6ec2)) +* add the viewer's roles to unnamed data table connections ([#11709](https://github.com/windmill-labs/windmill/issues/11709)) ([a298ed3](https://github.com/windmill-labs/windmill/commit/a298ed3fc19a1b7fc4f8e75a95c8d38652b74b5b)) +* audit declarative instance config writes ([#11684](https://github.com/windmill-labs/windmill/issues/11684)) ([afbbc0b](https://github.com/windmill-labs/windmill/commit/afbbc0b1b2da1e468b10d4cd11e9f3ac7478a34d)) +* **cli:** declare app sandbox and frontend api scopes in app yaml ([#11703](https://github.com/windmill-labs/windmill/issues/11703)) ([26afeec](https://github.com/windmill-labs/windmill/commit/26afeecbd5b0a11c084813182e92805a15d21bbb)) +* data table migration tools in ai sessions ([#11723](https://github.com/windmill-labs/windmill/issues/11723)) ([3d74f6e](https://github.com/windmill-labs/windmill/commit/3d74f6ea2a44e85d056411c293087741f6f1e12a)) +* encrypt workspace secrets with AES-256-GCM ([#11678](https://github.com/windmill-labs/windmill/issues/11678)) ([4942149](https://github.com/windmill-labs/windmill/commit/4942149b05c9384b39402f0e3cb7771565888f45)) +* external signer for OIDC tokens and a log line per mint ([#11614](https://github.com/windmill-labs/windmill/issues/11614)) ([157183a](https://github.com/windmill-labs/windmill/commit/157183ae9cad4b6b2d38f1f4d915f32464792453)) +* give app builder operators app tools in AI sessions ([#11716](https://github.com/windmill-labs/windmill/issues/11716)) ([133613c](https://github.com/windmill-labs/windmill/commit/133613c1add5b6fcdbb0937de6479976b716c90a)) +* redesign the s3 explorer and show data files as tables ([#11642](https://github.com/windmill-labs/windmill/issues/11642)) ([9fa9b77](https://github.com/windmill-labs/windmill/commit/9fa9b7792676fbedcbc4dc37ebb883f5ff5abb35)) +* support array arguments in snowflake scripts ([#11702](https://github.com/windmill-labs/windmill/issues/11702)) ([1451988](https://github.com/windmill-labs/windmill/commit/14519884e4c9aa4d0a006cbc019a776f9a038967)) + + +### Bug Fixes + +* bump git sync hub scripts to cli 1.830.0 ([#11681](https://github.com/windmill-labs/windmill/issues/11681)) ([7bbe032](https://github.com/windmill-labs/windmill/commit/7bbe03227d56688893aafda305a13d65b2bb84f4)) +* end other sessions when a user changes their password ([#11710](https://github.com/windmill-labs/windmill/issues/11710)) ([add042e](https://github.com/windmill-labs/windmill/commit/add042ef2f05828a8ea5f6d0a089204bae2322d5)) +* fill app ctx inputs from the viewer's session, not the request ([#11698](https://github.com/windmill-labs/windmill/issues/11698)) ([7627c1b](https://github.com/windmill-labs/windmill/commit/7627c1be8d82c298173ad4a7002483d1caee6662)) +* **groups:** recompute member roles when an instance group role is cleared ([#11708](https://github.com/windmill-labs/windmill/issues/11708)) ([4be67fe](https://github.com/windmill-labs/windmill/commit/4be67fe3ac09f722f31ef85e6fc757bb0a2fabe4)) +* hide the sidebar triggers header when no trigger entry is visible ([#11715](https://github.com/windmill-labs/windmill/issues/11715)) ([e443eb3](https://github.com/windmill-labs/windmill/commit/e443eb37079734469a78af8ff4bc0f4289b28d0a)) +* keep a custom error handler's slack argument on save ([#11696](https://github.com/windmill-labs/windmill/issues/11696)) ([c6692ac](https://github.com/windmill-labs/windmill/commit/c6692ace156f5295196af07015ad82aee6391116)) +* keep ai agent schema property order stable across jobs ([#11687](https://github.com/windmill-labs/windmill/issues/11687)) ([a8e2f1f](https://github.com/windmill-labs/windmill/commit/a8e2f1f1ca28e3352aff8a450188e3ff75a9637c)) +* keep preserve_step_tags on saved branch and loop flow nodes ([#11660](https://github.com/windmill-labs/windmill/issues/11660)) ([dedd775](https://github.com/windmill-labs/windmill/commit/dedd77587f3234f3e480388b00340394794ac5bb)) +* keep the app editor's parent_version out of deployed apps ([#11719](https://github.com/windmill-labs/windmill/issues/11719)) ([2c6de9d](https://github.com/windmill-labs/windmill/commit/2c6de9d896ef37993da974418133a88798afa2bb)) +* keep the resource type index whole when a refresh fails ([#11727](https://github.com/windmill-labs/windmill/issues/11727)) ([71caac7](https://github.com/windmill-labs/windmill/commit/71caac7aec49bafe8f691e8a82b3e6df84f7e4bc)) +* keep the resource type search current between daily fills, and serve it without an index ([#11712](https://github.com/windmill-labs/windmill/issues/11712)) ([a365359](https://github.com/windmill-labs/windmill/commit/a3653593f2b064b762a19a1d4294988c27baaec0)) +* load the s3 explorer table lazily to keep monaco out of the app shell ([#11701](https://github.com/windmill-labs/windmill/issues/11701)) ([a92b29d](https://github.com/windmill-labs/windmill/commit/a92b29dae6950d29c2b8970e07d055fa02ff979e)) +* parse python 3.12-3.15 syntax in the python parsers ([#11675](https://github.com/windmill-labs/windmill/issues/11675)) ([aae5f2e](https://github.com/windmill-labs/windmill/commit/aae5f2efaa772d5bc6d7d1e4537d7f6748e50e61)) +* persist hidden languages in edit default scripts drawer ([#11693](https://github.com/windmill-labs/windmill/issues/11693)) ([aeeeb04](https://github.com/windmill-labs/windmill/commit/aeeeb042f93df32214e00040d360f20e8ed8c98a)) +* pin the floating sidebar and let page clicks through it ([#11711](https://github.com/windmill-labs/windmill/issues/11711)) ([ce45ab5](https://github.com/windmill-labs/windmill/commit/ce45ab507370662266728bf57240cc873ef28eb8)) +* resolve $var: references in mcp resource headers ([#11679](https://github.com/windmill-labs/windmill/issues/11679)) ([478eef7](https://github.com/windmill-labs/windmill/commit/478eef735222e60de8ed6086f6b036e2346f1de4)) +* resolve a new item's chosen name in session preview tools ([#11688](https://github.com/windmill-labs/windmill/issues/11688)) ([cd502a5](https://github.com/windmill-labs/windmill/commit/cd502a5270e787aeb1e5cb14e5a0e0aad8bb97b3)) +* resolve variables in the url and headers of an mcp resource ([#11680](https://github.com/windmill-labs/windmill/issues/11680)) ([4bc2394](https://github.com/windmill-labs/windmill/commit/4bc2394322f957e5cf212fa1913b2b39c1b9ca06)) +* run a workflow-as-code task called from inside a task in place ([#11699](https://github.com/windmill-labs/windmill/issues/11699)) ([72d1092](https://github.com/windmill-labs/windmill/commit/72d1092de97d7449ff03c3a44835c9d23cfe5e02)) +* stop marking run form fields as disabled for assistive tech ([#11714](https://github.com/windmill-labs/windmill/issues/11714)) ([c7df009](https://github.com/windmill-labs/windmill/commit/c7df0093d0d9a38362222b7350bf387c31af9392)) +* stop session dom tools reading a raw app preview that is not current ([#11700](https://github.com/windmill-labs/windmill/issues/11700)) ([05c7b68](https://github.com/windmill-labs/windmill/commit/05c7b688e5e963c6c313cb5da8e777cee11cd122)) +* surface oauth provider errors returned with http 200 ([#11713](https://github.com/windmill-labs/windmill/issues/11713)) ([d8ac4f5](https://github.com/windmill-labs/windmill/commit/d8ac4f59c8d570777e43a7aa7ffbc97598897cc2)) + + +### Performance Improvements + +* run shellcheck directly from the lsp launcher ([#11707](https://github.com/windmill-labs/windmill/issues/11707)) ([8cdb42a](https://github.com/windmill-labs/windmill/commit/8cdb42aaae764977008f8ac4598709c7ae09af3f)) + ## [1.830.0](https://github.com/windmill-labs/windmill/compare/v1.829.0...v1.830.0) (2026-10-09) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 86b5850e80..9ee5635c37 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -864,7 +864,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -903,7 +903,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -960,7 +960,7 @@ dependencies = [ "manyhow 0.14.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -976,7 +976,7 @@ dependencies = [ "proc-macro2", "quote", "quote-use", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1029,11 +1029,11 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ - "aws-lc-sys 0.45.0", + "aws-lc-sys 0.46.0", "untrusted 0.7.1", "zeroize", ] @@ -1052,9 +1052,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -1336,9 +1336,9 @@ dependencies = [ [[package]] name = "aws-smithy-async" -version = "1.2.12" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cba48474f1d6807384d06fec085b909f5807e16653c5af5c45dfe89539f0b70" +checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316" dependencies = [ "futures-util", "pin-project-lite", @@ -1421,7 +1421,7 @@ dependencies = [ "pin-project-lite", "rustls 0.21.12", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "tokio", "tokio-rustls 0.26.6", @@ -2010,7 +2010,7 @@ dependencies = [ "prettyplease 0.3.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2034,7 +2034,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2145,7 +2145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c719d56f7e96194cfc53460976d3ba51c85719747c9c62ed99981847b551152b" dependencies = [ "rust_decimal", - "schemars 1.2.2", + "schemars 1.2.3", "serde", "utf8-width", ] @@ -2189,7 +2189,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2366,9 +2366,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", @@ -2521,7 +2521,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2793,6 +2793,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -3214,7 +3220,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -3269,7 +3275,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core 0.24.1", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4575,9 +4581,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ "serde_core", ] @@ -4766,7 +4772,7 @@ dependencies = [ "diplomat_core", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4786,7 +4792,7 @@ dependencies = [ "serde", "smallvec", "strck", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -4880,7 +4886,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5181,7 +5187,7 @@ checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5679,7 +5685,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -5889,7 +5895,7 @@ checksum = "28f0daa179069133df60523e0f59d0a6bd2d3db9ccae01b630896d269c4a802e" dependencies = [ "attribute-derive", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6735,7 +6741,7 @@ dependencies = [ "hyper-util", "log", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "tokio", "tokio-rustls 0.26.6", "tower-service", @@ -7801,9 +7807,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.29" +version = "1.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" +checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" dependencies = [ "cc", "libc", @@ -8037,7 +8043,7 @@ dependencies = [ "manyhow-macros 0.14.0", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -9972,7 +9978,7 @@ dependencies = [ "md-5 0.11.0", "memchr", "rand 0.10.3", - "sha2 0.11.0", + "sha2 0.11.1", "stringprep", ] @@ -10041,7 +10047,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10160,14 +10166,15 @@ dependencies = [ [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha 0.9.0", + "rand 0.10.3", "rand_xorshift", "regex-syntax 0.8.11", "unarray", @@ -10417,7 +10424,7 @@ dependencies = [ "proc-macro-utils", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10576,11 +10583,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -10773,7 +10780,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -10863,7 +10870,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "serde", "serde_json", @@ -11078,7 +11085,7 @@ dependencies = [ "rand 0.10.3", "reqwest 0.13.5", "rmcp-macros", - "schemars 1.2.2", + "schemars 1.2.3", "serde", "serde_json", "sse-stream", @@ -11102,7 +11109,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11498,9 +11505,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", @@ -11548,7 +11555,7 @@ dependencies = [ "log", "once_cell", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-platform-verifier-android", "rustls-webpki 0.103.15", "security-framework 3.7.0", @@ -11735,14 +11742,14 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive 1.2.2", + "schemars_derive 1.2.3", "serde", "serde_json", ] @@ -11761,14 +11768,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", "serde_derive_internals 0.30.0", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11966,7 +11973,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -11988,15 +11995,16 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", "indexmap 2.14.2", "itoa", "memchr", @@ -12042,7 +12050,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -12094,7 +12102,7 @@ dependencies = [ "indexmap 2.14.2", "jiff", "schemars 0.9.0", - "schemars 1.2.2", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -12110,7 +12118,7 @@ dependencies = [ "darling 0.24.1", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -12223,9 +12231,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -12414,9 +12422,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] @@ -13345,9 +13353,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -13405,7 +13413,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -13809,7 +13817,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14070,7 +14078,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14261,7 +14269,7 @@ dependencies = [ "httparse", "rand 0.8.5", "ring 0.17.14", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pki-types", "tokio", "tokio-rustls 0.26.6", @@ -14383,7 +14391,7 @@ dependencies = [ "percent-encoding", "pin-project", "prost 0.13.5", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "socket2 0.5.10", "tokio", "tokio-rustls 0.26.6", @@ -14664,9 +14672,9 @@ dependencies = [ [[package]] name = "triomphe" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +checksum = "0d15f4f5898bcfc5d8a8e45ed30b2f108d710c1a2e5489dbf80e078f707181bc" dependencies = [ "serde", "stable_deref_trait", @@ -14774,7 +14782,7 @@ checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -14862,9 +14870,9 @@ checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" @@ -15043,9 +15051,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -15224,7 +15232,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -15267,7 +15275,7 @@ checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -15478,7 +15486,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-nats", @@ -15568,7 +15576,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.830.0" +version = "1.831.0" dependencies = [ "async-stream", "async-trait", @@ -15603,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15616,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "argon2", @@ -15759,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15782,7 +15790,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15799,7 +15807,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -15825,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.830.0" +version = "1.831.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15835,7 +15843,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15852,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "base64 0.22.1", @@ -15874,7 +15882,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -15897,7 +15905,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15913,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15935,7 +15943,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15957,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -15971,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-nats", @@ -16007,7 +16015,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16033,7 +16041,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16061,7 +16069,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16083,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16103,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16145,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16174,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.830.0" +version = "1.831.0" dependencies = [ "lazy_static", "serde", @@ -16186,7 +16194,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.830.0" +version = "1.831.0" dependencies = [ "argon2", "axum", @@ -16210,7 +16218,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16224,7 +16232,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.830.0" +version = "1.831.0" dependencies = [ "axum", "chrono", @@ -16263,7 +16271,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.830.0" +version = "1.831.0" dependencies = [ "chrono", "lazy_static", @@ -16278,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "axum", @@ -16297,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.830.0" +version = "1.831.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16368,7 +16376,7 @@ dependencies = [ "reqwest-retry", "rsa", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "ryu-js", "schemars 0.8.22", "semver", @@ -16413,7 +16421,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.830.0" +version = "1.831.0" dependencies = [ "chrono", "futures", @@ -16433,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.830.0" +version = "1.831.0" dependencies = [ "regex", "serde", @@ -16450,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16477,7 +16485,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "futures", @@ -16494,7 +16502,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.830.0" +version = "1.831.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16510,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -16532,7 +16540,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -16563,7 +16571,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "arc-swap", @@ -16588,7 +16596,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-stream", @@ -16624,7 +16632,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "futures", @@ -16643,7 +16651,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.830.0" +version = "1.831.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16652,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16664,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -16676,7 +16684,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "gosyn", @@ -16688,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16700,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -16712,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "nu-parser", @@ -16723,7 +16731,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16734,7 +16742,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16746,7 +16754,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16757,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -16780,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -16792,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16806,7 +16814,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16823,7 +16831,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16836,7 +16844,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde", @@ -16848,7 +16856,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16866,7 +16874,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16882,7 +16890,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16899,7 +16907,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -16913,7 +16921,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -16952,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "const_format", @@ -16990,7 +16998,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.830.0" +version = "1.831.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17001,7 +17009,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -17036,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17060,7 +17068,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17093,7 +17101,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17120,7 +17128,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17153,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17173,7 +17181,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17207,7 +17215,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17244,7 +17252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17267,7 +17275,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17291,7 +17299,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-nats", @@ -17315,7 +17323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17350,7 +17358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17378,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-trait", @@ -17403,7 +17411,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17422,7 +17430,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-once-cell", @@ -17484,7 +17492,7 @@ dependencies = [ "rsa", "rust_decimal", "rustls 0.23.40", - "rustls-native-certs 0.8.4", + "rustls-native-certs 0.8.5", "rustls-pemfile 2.2.0", "serde", "serde_json", @@ -17541,7 +17549,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.830.0" +version = "1.831.0" dependencies = [ "bytes", "futures", @@ -18084,9 +18092,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "version_check", ] @@ -18261,7 +18269,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -18302,7 +18310,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure 0.14.0", ] @@ -18358,7 +18366,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index aaf5a2e8df..a72480cab2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.830.0" +version = "1.831.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.830.0" +version = "1.831.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 51878c6dfb..c0d6aa95d4 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.830.0" +version = "1.831.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.830.0" +version = "1.831.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.830.0" +version = "1.831.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.830.0" +version = "1.831.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 8aebf20597..d72119afd4 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.830.0" +version = "1.831.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 53996db1dc..5295c5765f 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.830.0 + version: 1.831.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 34e4da5cf3..63e9a8a5d4 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.830.0"; +export const VERSION = "v1.831.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 74f6914f37..91e9b9a081 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.830.0", + "version": "1.831.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.830.0", + "version": "1.831.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 26f5f785be..296be0e680 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@windmill-labs/chat-sdk", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.830.0", + "version": "1.831.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index 21aeb07f60..b6c029daa2 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.830.0"; +export const VERSION = "1.831.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6130364ff9..3b5ecd97be 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.830.0", + "version": "1.831.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.830.0", + "version": "1.831.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 7522af8675..33c24f43bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.830.0", + "version": "1.831.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 4aee9bdf9f..4b782c4cf0 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.830.0" +wmill = ">=1.831.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index e3759ee6a6..19ba842fff 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.830.0 + version: 1.831.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 8385126639..573be0aa76 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.830.0' + ModuleVersion = '1.831.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 411bc09cc8..dc227b7ccc 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.830.0" +version = "1.831.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 ffd7c2300a..3eaaece304 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.830.0", + "version": "1.831.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 9adbfcec62..810c0a7940 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.830.0", + "version": "1.831.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 54f09db472..74dfad0170 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.830.0 +1.831.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 95c5128a13..94b3c36b9a 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.830.0", + "version": "1.831.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.830.0", + "version": "1.831.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 6f2c2d7fed..77dbdfcecf 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.830.0", + "version": "1.831.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 9c44f5a917fc628e870073c126bf9480c98c7f73 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 16:20:53 +0200 Subject: [PATCH 14/30] fix: badge the unavailable sign-in option in the oauth resource form (#11728) Co-authored-by: Claude Opus 5.5 --- frontend/src/lib/components/AppConnectInner.svelte | 6 +++++- .../src/lib/components/common/radioCard/RadioCard.svelte | 8 +++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 18a28ec95d..4dc05972ca 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -25,7 +25,7 @@ import { registryEntryFor, registryCcCapableFor, stripSandboxSuffix } from './oauthRegistry' import { createEventDispatcher, onDestroy, tick } from 'svelte' import Path from './Path.svelte' - import { ListRow, RadioCard, Skeleton } from './common' + import { Badge, ListRow, RadioCard, Skeleton } from './common' import { useListHighlight } from './common/listRow/listHighlight.svelte' import ApiConnectForm from './ApiConnectForm.svelte' import SearchItems from './SearchItems.svelte' @@ -1545,7 +1545,11 @@ selected={!useClientCredentials} onSelect={selectAuthCodeGrant} disabled={signInNeedsInstanceClient} + badge={signInNeedsInstanceClient ? setupNeededBadge : undefined} /> + {#snippet setupNeededBadge()} + Instance setup needed + {/snippet} {@render icon()}
{/if}
-
{label}
+
+ {label} + {@render badge?.()} +
{#if describedBySnippet}
{@render (description as Snippet)()} From e76ebcb2db0668ff39b546a3bcfe188e974d73e3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 16:40:55 +0200 Subject: [PATCH 15/30] chore: first-time setup follow-up (#11730) * chore: update ee ref Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 118785eaa78675089b9a676d24e5d0a2e03f3d9a This commit updates the EE repository reference after PR #875 was merged in windmill-ee-private. Previous ee-repo-ref: e8df378c5f8e5e07af8a2bad4c685c8ad6fe1491 New ee-repo-ref: 118785eaa78675089b9a676d24e5d0a2e03f3d9a Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index d68805fb8a..408bf517b1 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -0a7c28718872bb5cd624d77e32ad7d75c6ce42de +118785eaa78675089b9a676d24e5d0a2e03f3d9a From 41b9779ae335146e66e48324fb0380f121946cc0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 16:59:23 +0200 Subject: [PATCH 16/30] chore: pass --system-certs to uv instead of the deprecated --native-tls (#11666) * chore: pass --system-certs to uv instead of the deprecated --native-tls Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 06610ecc950ed64a2709d7f1aa8c7b49a95fdbae This commit updates the EE repository reference after PR #869 was merged in windmill-ee-private. Previous ee-repo-ref: 599eae0034b34c5e0d3d42df37cf18bbe01f9b49 New ee-repo-ref: 06610ecc950ed64a2709d7f1aa8c7b49a95fdbae Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- backend/windmill-worker/src/prepare_deps.rs | 2 +- backend/windmill-worker/src/python_executor.rs | 6 +++--- backend/windmill-worker/src/python_versions.rs | 4 ++-- backend/windmill-worker/src/worker.rs | 2 +- debugger/README.md | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/windmill-worker/src/prepare_deps.rs b/backend/windmill-worker/src/prepare_deps.rs index 9c8a5179e3..e0d5f39568 100644 --- a/backend/windmill-worker/src/prepare_deps.rs +++ b/backend/windmill-worker/src/prepare_deps.rs @@ -355,7 +355,7 @@ fn uv_registry_args(registry: &RegistryConfig) -> Vec { } } if *NATIVE_CERT { - args.push("--native-tls".to_string()); + args.push("--system-certs".to_string()); } args } diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 7f2291099a..4ab3d50df8 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -367,7 +367,7 @@ pub async fn uv_pip_compile( args.extend(["--cert", cert_path]); } if *NATIVE_CERT { - args.extend(["--native-tls"]); + args.extend(["--system-certs"]); } if let Some(exclude_newer) = uv_exclude_newer.as_deref() { args.extend(["--exclude-newer", exclude_newer]); @@ -2247,7 +2247,7 @@ async fn spawn_uv_install( vars.push(("TRUSTED_HOST", host)); } if *NATIVE_CERT { - vars.push(("UV_NATIVE_TLS", "true")); + vars.push(("UV_SYSTEM_CERTS", "true")); } if let Some(timeout) = UV_HTTP_TIMEOUT.as_ref() { vars.push(("UV_HTTP_TIMEOUT", timeout.as_str())); @@ -2371,7 +2371,7 @@ async fn spawn_uv_install( }); } if *NATIVE_CERT { - command_args.extend(["--native-tls"]); + command_args.extend(["--system-certs"]); } // TODO: // Track https://github.com/astral-sh/uv/issues/6715 diff --git a/backend/windmill-worker/src/python_versions.rs b/backend/windmill-worker/src/python_versions.rs index 11674efcd6..b52352915a 100644 --- a/backend/windmill-worker/src/python_versions.rs +++ b/backend/windmill-worker/src/python_versions.rs @@ -556,7 +556,7 @@ impl PyV { child_cmd.env("SSL_CERT_FILE", cert_path); } if *NATIVE_CERT { - child_cmd.env("UV_NATIVE_TLS", "true"); + child_cmd.env("UV_SYSTEM_CERTS", "true"); } if let Some(mirror) = UV_PYTHON_INSTALL_MIRROR.read().await.as_ref() { @@ -664,7 +664,7 @@ impl PyV { } if *NATIVE_CERT { - vars.push(("UV_NATIVE_TLS", "true")); + vars.push(("UV_SYSTEM_CERTS", "true")); } let output = child_cmd // .current_dir(job_dir) diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 3aa377c904..cb4b378547 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -711,7 +711,7 @@ lazy_static::lazy_static! { /// registry configuration. pub static ref TRUSTED_HOST: Option = non_empty_env("PY_TRUSTED_HOST").or_else(|| non_empty_env("PIP_TRUSTED_HOST")); pub static ref INDEX_CERT: Option = non_empty_env("PY_INDEX_CERT").or_else(|| non_empty_env("PIP_INDEX_CERT")); - pub static ref NATIVE_CERT: bool = non_empty_env("PY_NATIVE_CERT").or_else(|| non_empty_env("UV_NATIVE_TLS")).map(|flag| flag == "true").unwrap_or(false); + pub static ref NATIVE_CERT: bool = non_empty_env("PY_NATIVE_CERT").or_else(|| non_empty_env("UV_SYSTEM_CERTS")).or_else(|| non_empty_env("UV_NATIVE_TLS")).map(|flag| flag == "true").unwrap_or(false); /// uv's HTTP request timeout (seconds). The uv invocations use env_clear(), so a /// UV_HTTP_TIMEOUT set on the worker is dropped unless forwarded explicitly. /// Only forwarded when set; otherwise uv keeps its own default. Lets operators diff --git a/debugger/README.md b/debugger/README.md index 1dba49d977..040fb4ef67 100644 --- a/debugger/README.md +++ b/debugger/README.md @@ -139,7 +139,7 @@ the debug service. Where two names are listed the first wins; a worker reads the | `PY_TRUSTED_HOST` / `PIP_TRUSTED_HOST` | Hosts to trust, whitespace-separated (`--trusted-host`) | - | | `PY_INDEX_CERT` / `PIP_INDEX_CERT` | CA bundle for the index, passed to uv as `SSL_CERT_FILE`. Falls back to `SSL_CERT_FILE`, then `REQUESTS_CA_BUNDLE`, then `CURL_CA_BUNDLE`, so a host that configures its CA under any of those names is picked up. Whichever is used **replaces** uv's own roots rather than adding to them, so it has to be a complete bundle: one holding only a private CA leaves every public index untrusted. `bun install` gets the same bundle as `NODE_EXTRA_CA_CERTS`, the only spelling Bun reads | - | | `SSL_CERT_DIR` | Directory of certificates, forwarded to uv as-is. Replaces uv's roots the same way the bundle does, so a directory holding only a private CA leaves public indexes untrusted | - | -| `PY_NATIVE_CERT` / `UV_NATIVE_TLS` | `true` to also trust the platform certificate store (`--native-tls`) | false | +| `PY_NATIVE_CERT` / `UV_SYSTEM_CERTS` | `true` to also trust the platform certificate store (`--system-certs`) | false | | `UV_HTTP_TIMEOUT` | uv HTTP request timeout, in seconds | uv's own default | | `DAP_REGISTRY_CONFIG_TIMEOUT_MS` | How long to wait on the settings fetch before installing without it | 10000 | @@ -172,7 +172,7 @@ and the container still boots. `INIT_SCRIPT` remains the hook for anything more the CA update it aborts startup when it fails. Note what the system store does *not* cover, which is most of what a debug session installs with: -uv trusts its own bundled roots unless `PY_NATIVE_CERT`/`UV_NATIVE_TLS` is `true`, Bun and Node read +uv trusts its own bundled roots unless `PY_NATIVE_CERT`/`UV_SYSTEM_CERTS` is `true`, Bun and Node read only `NODE_EXTRA_CA_CERTS`, and `requests` carries certifi. Registering the CA fixes Python's stdlib `ssl`, `curl` and `git`; the rest still needs the variables above. From 7558ce27a9736bef1a9b9db9b0799150dccb11a5 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 17:22:07 +0200 Subject: [PATCH 17/30] feat: record each instance's python version and start new ones on 3.13 (#11656) * feat: pin the default python version of an instance at initialization Co-Authored-By: Claude Opus 5.5 * fix: detect a new instance from the schema it created, not from its age Co-Authored-By: Claude Opus 5.5 * fix: record the python baseline on the migration connection Co-Authored-By: Claude Opus 5.5 * refactor: record the instance python version in the setting itself Co-Authored-By: Claude Opus 5.5 * feat: start new instances on python 3.15 Co-Authored-By: Claude Opus 5.5 * fix: keep the forced python version ahead of the stored one when a worker reads it late Co-Authored-By: Claude Opus 5.5 * feat: start new instances on python 3.14 rather than 3.15 Co-Authored-By: Claude Opus 5.5 * feat: start new instances on python 3.13 Co-Authored-By: Claude Opus 5.5 * chore: update ee-repo-ref to 06610ecc950ed64a2709d7f1aa8c7b49a95fdbae This commit updates the EE repository reference after PR #869 was merged in windmill-ee-private. Previous ee-repo-ref: 599eae0034b34c5e0d3d42df37cf18bbe01f9b49 New ee-repo-ref: 06610ecc950ed64a2709d7f1aa8c7b49a95fdbae Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: windmill-internal-app[bot] --- Dockerfile | 10 ++-- backend/windmill-api/src/db.rs | 51 +++++++++++++++++++ .../windmill-common/src/instance_config.rs | 1 + backend/windmill-common/src/worker.rs | 13 +++++ .../windmill-worker/src/python_versions.rs | 29 +++++++++-- docker/DockerfileSlim | 2 + docker/DockerfileSlimEe | 2 + .../src/lib/components/instanceSettings.ts | 9 ---- 8 files changed, 99 insertions(+), 18 deletions(-) diff --git a/Dockerfile b/Dockerfile index c6a4ccc55f..4e022d4b53 100644 --- a/Dockerfile +++ b/Dockerfile @@ -152,11 +152,12 @@ ARG WITH_HELM=true ARG WITH_GIT=true ARG features="" -# To change latest stable version: -# 1. Change placeholder in instanceSettings.ts -# 2. Change LATEST_STABLE_PY in dockerfile -# 3. Change #[default] annotation for PyVersion in backend +# Instances keep the Python version they were initialized with, so both are preinstalled: +# LATEST_STABLE_PY is what instances initialized before the version was raised are on +# (PyVAlias::default() in the backend), NEW_INSTANCE_PY the one new instances start on +# (PyVAlias::NEW_INSTANCE). When raising the latter, keep installing every earlier value. ARG LATEST_STABLE_PY=3.12 +ARG NEW_INSTANCE_PY=3.13 ENV UV_PYTHON_INSTALL_DIR=/tmp/windmill/cache/py_runtime ENV UV_PYTHON_PREFERENCE=only-managed @@ -249,6 +250,7 @@ RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releas # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11 --compile-bytecode RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode RUN curl -sL https://deb.nodesource.com/setup_24.x | bash - diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index 9619e96ebc..21b738891e 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -17,7 +17,9 @@ use tokio::task::JoinHandle; pub use windmill_common::db::DB; use windmill_common::{ error::Error, + global_settings::INSTANCE_PYTHON_VERSION_SETTING, utils::{generate_lock_id, GIT_VERSION}, + worker::PyVAlias, }; #[allow(unused_imports)] @@ -298,6 +300,12 @@ pub async fn migrate( let migrator = db.acquire().await?; let mut custom_migrator = CustomMigrator { inner: migrator }; + let new_instance = + sqlx::query_scalar::<_, bool>("SELECT to_regclass('_sqlx_migrations') IS NULL") + .fetch_one(custom_migrator.connection()) + .await + .unwrap_or(false); + if let Err(err) = sqlx::query!( "DELETE FROM _sqlx_migrations WHERE version=20250131115248 OR version=20250902085503 OR version=20250201145630 OR @@ -372,12 +380,55 @@ pub async fn migrate( } crate::live_migrations::custom_migrations(&mut custom_migrator).await?; + if let Err(e) = seed_instance_python_version(custom_migrator.connection(), new_instance).await { + tracing::error!("Could not record the instance python version: {e:#}"); + } Ok(Some(crate::live_migrations::spawn_background_migrations( db.clone(), killpill_rx, ))) } +/// Gives `instance_python_version` a concrete value when the instance has none, so the version +/// scripts resolve around is recorded per instance instead of following whatever a release +/// compiles in. Only the process that created the schema knows the instance is new, so its value +/// replaces what a server that started alongside it wrote; every other start fills in the frozen +/// default, and only where no version was chosen (`default` is what the settings page stored +/// for that). +/// +/// Runs on the migration connection: a `DATABASE_CONNECTIONS=1` pool has no second one to give. +async fn seed_instance_python_version( + conn: &mut PgConnection, + new_instance: bool, +) -> Result<(), Error> { + let (version, on_conflict) = if new_instance { + ( + PyVAlias::NEW_INSTANCE, + "DO UPDATE SET value = EXCLUDED.value", + ) + } else { + ( + PyVAlias::default(), + "DO UPDATE SET value = EXCLUDED.value WHERE global_settings.value = '\"default\"'::jsonb", + ) + }; + // The stored setting outranks the `INSTANCE_PYTHON_VERSION` environment variable, so an + // instance configured through it would otherwise be moved off its version by this write. + let version = std::env::var("INSTANCE_PYTHON_VERSION") + .ok() + .filter(|v| !v.is_empty() && v.split('.').all(|part| part.parse::().is_ok())) + .unwrap_or_else(|| version.version_string()); + sqlx::query(&format!( + "INSERT INTO global_settings (name, value) VALUES ($1, to_jsonb($2::text)) + ON CONFLICT (name) {on_conflict}" + )) + .bind(INSTANCE_PYTHON_VERSION_SETTING) + .bind(version) + .execute(conn) + .await?; + Ok(()) +} + pub async fn wait_for_migrations( db: &DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>, diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 7c76fab2ee..d19de41dcc 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -1063,6 +1063,7 @@ pub const PROTECTED_SETTINGS: &[&str] = &[ "rsa_keys", "jwt_secret", "min_keep_alive_version", + "instance_python_version", // Deleting it would let a worker sign OIDC tokens with a local key again. "oidc_external_signer", ]; diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 58223c5d4a..30eb2403b1 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -2891,6 +2891,9 @@ pub fn lockfile_line_has_continuation(line: &str) -> bool { pub enum PyVAlias { Py310 = 10, Py311, + // Frozen: what a lock with no `# py:` line runs on, and what the server writes into + // `instance_python_version` for an instance that never chose one. Raise `NEW_INSTANCE` + // instead, which existing instances never pick up. #[default] Py312, Py313, @@ -2911,6 +2914,11 @@ impl Into for PyVAlias { } impl PyVAlias { + /// What the server writes into `instance_python_version` when it initializes an instance. + /// Raising it also means the images must keep preinstalling every earlier value, or + /// instances that started on one download their runtime on first use. + pub const NEW_INSTANCE: PyVAlias = PyVAlias::Py313; + pub fn all>() -> Vec { use PyVAlias::*; vec![ @@ -2922,6 +2930,11 @@ impl PyVAlias { Py315.into(), ] } + /// `MAJOR.MINOR`, the form the instance settings store. + pub fn version_string(self) -> String { + format!("{}.{}", self.major(), self as u32) + } + // Get MAJOR part of alias. (semver: MAJOR.MINOR.PATCH) fn major(&self) -> u32 { use PyVAlias::*; diff --git a/backend/windmill-worker/src/python_versions.rs b/backend/windmill-worker/src/python_versions.rs index b52352915a..3a15de17b5 100644 --- a/backend/windmill-worker/src/python_versions.rs +++ b/backend/windmill-worker/src/python_versions.rs @@ -14,6 +14,7 @@ use tokio::{fs::DirBuilder, process::Command, sync::RwLock}; use uuid::Uuid; use windmill_common::{ error::{self, Error}, + global_settings::{load_value_from_global_settings, INSTANCE_PYTHON_VERSION_SETTING}, worker::{try_parse_locked_python_version_from_requirements, Connection, PyVAlias}, }; @@ -49,10 +50,6 @@ impl From for PyVAlias { } } -// To change latest stable version: -// 1. Change placeholder in instanceSettings.ts -// 2. Change LATEST_STABLE_PY in dockerfile -// 3. Change #[default] annotation for PyVersion in backend #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct PyV(pub pep440_rs::Version); @@ -260,7 +257,29 @@ impl PyV { conn: Option, ) -> Self { let mut err = None; - let pyv = match INSTANCE_PYTHON_VERSION.read().await.clone() { + let mut setting = INSTANCE_PYTHON_VERSION.read().await.clone(); + // A worker that loaded its settings while the server was still initializing the instance + // read no version, and may have started listening for setting changes only after it was + // written. Jobs only exist once it is, so reading it here settles that worker for good. + if setting.is_none() { + if let Some(Connection::Sql(db)) = conn.as_ref() { + // Same precedence as the settings load: forced value, stored value, environment. + let env = |name: &str| std::env::var(name).ok().filter(|v| !v.is_empty()); + setting = match env("FORCE_INSTANCE_PYTHON_VERSION") { + Some(forced) => Some(forced), + None => load_value_from_global_settings(db, INSTANCE_PYTHON_VERSION_SETTING) + .await + .ok() + .flatten() + .and_then(|v| serde_json::from_value::(v).ok()) + .or_else(|| env("INSTANCE_PYTHON_VERSION")), + }; + if setting.is_some() { + *INSTANCE_PYTHON_VERSION.write().await = setting.clone(); + } + } + } + let pyv = match setting { Some(v) if &v == "default" => PyVAlias::default().into(), Some(v) => pep440_rs::Version::from_str(&v).unwrap_or_else(|_| { let v = PyVAlias::default().into(); diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index a37ced0eca..ef976f83d3 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -30,6 +30,7 @@ FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app ARG LATEST_STABLE_PY=3.12.13 +ARG NEW_INSTANCE_PY=3.13.16 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -65,6 +66,7 @@ RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releas # under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode # Copy to final location with world-writable permissions for arbitrary UID support RUN mkdir -p /tmp/windmill/cache && \ diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index 687b1506ce..67de36eac7 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -30,6 +30,7 @@ FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app ARG LATEST_STABLE_PY=3.12.13 +ARG NEW_INSTANCE_PY=3.13.16 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -65,6 +66,7 @@ RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releas # under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve # timestamps or Python's mtime-based .pyc invalidation discards these compiled files. RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode +RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode # Copy to final location with world-writable permissions for arbitrary UID support RUN mkdir -p /tmp/windmill/cache && \ diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index 27906dddee..9681e821e6 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -838,17 +838,8 @@ export const settings: Record = { description: 'Default python version for newly deployed scripts', key: 'instance_python_version', fieldType: 'select_python', - // To change latest stable version: - // 1. Change placeholder in instanceSettings.ts - // 2. Change LATEST_STABLE_PY in dockerfile - // 3. Change #[default] annotation for PyVersion in backend placeholder: '3.10,3.11,3.12,3.13,3.14,3.15', select_items: [ - { - label: 'Latest Stable', - value: 'default', - tooltip: 'python-3.12' - }, { label: '3.10' }, From e29a0dc5dfed4bb62b894e059c9506873d32a62c Mon Sep 17 00:00:00 2001 From: Alexander Petric Date: Tue, 13 Oct 2026 11:52:25 -0400 Subject: [PATCH 18/30] fix: serve s3 proxy objects with nosniff and a sandbox csp (#11731) * fix: serve s3 proxy objects with nosniff and a sandbox csp Co-Authored-By: Claude Opus 5.5 (1M context) * test: assert s3 proxy error responses carry nosniff and the sandbox csp Co-Authored-By: Claude Opus 5.5 (1M context) * chore: update ee-repo-ref to 196abc042c691ea16280ee10b89fb3b2fd527236 This commit updates the EE repository reference after PR #876 was merged in windmill-ee-private. Previous ee-repo-ref: 25feaf41aa84d47f8e4adb4def2576b443a66374 New ee-repo-ref: 196abc042c691ea16280ee10b89fb3b2fd527236 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- backend/tests/s3_proxy_headers.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 backend/tests/s3_proxy_headers.rs diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 408bf517b1..bbd2895dd5 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -118785eaa78675089b9a676d24e5d0a2e03f3d9a +196abc042c691ea16280ee10b89fb3b2fd527236 diff --git a/backend/tests/s3_proxy_headers.rs b/backend/tests/s3_proxy_headers.rs new file mode 100644 index 0000000000..8f43f8c761 --- /dev/null +++ b/backend/tests/s3_proxy_headers.rs @@ -0,0 +1,29 @@ +//! Every s3_proxy response carries `nosniff` and the sandbox CSP, error responses included, +//! since the presigned route serves workspace objects from the app origin without a login. +#![cfg(all(feature = "enterprise", feature = "private", feature = "parquet"))] + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +#[sqlx::test(fixtures("base"))] +async fn test_s3_proxy_sets_content_security_headers(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/s3_proxy/_default_/x.html?exp=9999999999&sig=00" + )) + .send() + .await?; + + assert!(resp.status().is_client_error(), "status: {}", resp.status()); + assert_eq!(resp.headers()["x-content-type-options"], "nosniff"); + assert_eq!( + resp.headers()["content-security-policy"], + "sandbox allow-scripts allow-forms allow-popups allow-modals allow-downloads" + ); + Ok(()) +} From 89e6bdbcf50e065f6906e1ab93b3c4109aba3ab6 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 18:04:43 +0200 Subject: [PATCH 19/30] fix: widen variable.value without breaking live servers' cached queries (#11737) * fix: widen variable.value only once every process can recover from it Co-Authored-By: Claude Opus 5.5 * fix: wait out the previous release's servers before widening variable.value Co-Authored-By: Claude Opus 5.5 * fix: write a v2 secret too long for the narrow column in cbc until it is widened Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/src/db_connect.rs | 3 + backend/src/monitor.rs | 8 +- backend/windmill-api/src/db.rs | 6 ++ backend/windmill-common/src/db.rs | 42 ++++++++++ backend/windmill-common/src/error.rs | 4 + backend/windmill-common/src/min_version.rs | 2 + backend/windmill-common/src/variables.rs | 77 ++++++++++++++++++- .../windmill-common/src/workspace_crypt.rs | 31 +++++++- 8 files changed, 166 insertions(+), 7 deletions(-) diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index d4dee19f82..ab12ae6f6e 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -153,6 +153,9 @@ pub async fn connect( pool_options = pool_options.idle_timeout(Duration::from_secs(60)); } pool_options + .before_acquire(|_, meta| { + Box::pin(async move { Ok(!windmill_common::db::connection_is_retired(meta.age)) }) + }) .after_connect(move |conn, _| { if worker_mode { Box::pin(async move { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 23ab2eaed1..4fae6f34ab 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -4469,12 +4469,14 @@ pub async fn monitor_db( // Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM. // Every ~5 min until one pass has gone through every workspace; spawned so a large instance - // does not hold up the join below. + // does not hold up the join below. The column widening it starts with is tried every + // iteration: a v2 value too long for the narrow column fails to save until it is done. let legacy_secret_upgrade_f = async { if server_mode && iteration.is_some() - && iteration.as_ref().unwrap().should_run(30) - && windmill_common::variables::legacy_secret_upgrade_pending() + && (windmill_common::variables::variable_value_widen_pending() + || (iteration.as_ref().unwrap().should_run(30) + && windmill_common::variables::legacy_secret_upgrade_pending())) { if let Some(db) = conn.as_sql() { let db = db.clone(); diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index 21b738891e..db99a07c92 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -113,6 +113,12 @@ lazy_static::lazy_static! { (20260909163047, include_str!( "../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql" ).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")), + // The widening fails every query the previous release's still-running + // servers prepared on the column; `variables::widen_variable_value` does it + // once they are gone. + (20261012213357, include_str!( + "../../migrations/20261012213357_widen_variable_value.up.sql" + ).replace("ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR(15040);", "")), ].into_iter().collect(); } diff --git a/backend/windmill-common/src/db.rs b/backend/windmill-common/src/db.rs index 0048abf438..38b89ad3b6 100644 --- a/backend/windmill-common/src/db.rs +++ b/backend/windmill-common/src/db.rs @@ -290,3 +290,45 @@ impl<'b> DbExecutor<'b> for &'b mut PgConnection { &mut **self } } + +lazy_static::lazy_static! { + static ref PROCESS_START: std::time::Instant = std::time::Instant::now(); +} + +/// Milliseconds after [`PROCESS_START`] before which every pooled connection is retired; +/// 0 retires none. +static RETIRE_CONNECTIONS_OPENED_BEFORE_MS: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +/// Postgres refuses a statement prepared before its result columns changed type (even a +/// varchar length) with this error, on every execution, for as long as the connection lives. +/// The routine is matched as well as the message, which `lc_messages` may translate. +pub fn is_stale_cached_plan(e: &sqlx::Error) -> bool { + let sqlx::Error::Database(d) = e else { + return false; + }; + d.code().as_deref() == Some("0A000") + && (d + .try_downcast_ref::() + .is_some_and(|pg| pg.routine() == Some("RevalidateCachedQuery")) + || d.message() + .contains("cached plan must not change result type")) +} + +/// Retire every pooled connection opened up to now, so a schema change that poisoned their +/// prepared statements costs one failed query instead of failing until they expire. +pub fn retire_connections_opened_before_now() { + let now = PROCESS_START.elapsed().as_millis() as u64 + 1; + RETIRE_CONNECTIONS_OPENED_BEFORE_MS.fetch_max(now, std::sync::atomic::Ordering::Relaxed); +} + +/// For the pool's `before_acquire`: whether a connection of this age must be closed. +pub fn connection_is_retired(age: std::time::Duration) -> bool { + let cutoff = RETIRE_CONNECTIONS_OPENED_BEFORE_MS.load(std::sync::atomic::Ordering::Relaxed); + cutoff != 0 && (PROCESS_START.elapsed().saturating_sub(age).as_millis() as u64) < cutoff +} + +/// Time since this process first used its database pool. +pub fn process_uptime() -> std::time::Duration { + PROCESS_START.elapsed() +} diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index c06defaab5..a54e9eb399 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -153,6 +153,10 @@ impl From for Error { impl From for Error { #[track_caller] fn from(e: sqlx::Error) -> Self { + if crate::db::is_stale_cached_plan(&e) { + tracing::warn!("a schema change invalidated cached statements, reopening the database connections: {e}"); + crate::db::retire_connections_opened_before_now(); + } Self::SqlErr { error: e, location: prettify_location(std::panic::Location::caller()) } } } diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index 05b664b23d..70878e3713 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -27,6 +27,8 @@ pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted // stored secret variables. Reading it ships one release earlier (1.831), so a rollback to // that release still reads every secret; below it, secrets written after the switch stay // unreadable until rolled forward. Only worker versions are seen here, not servers. +// Also gates widening `variable.value`, which needs every process to retire the connections +// the widening poisons (`db::retire_connections_opened_before_now`): that also ships in 1.832. pub const MIN_VERSION_SUPPORTS_AEAD_SECRETS: VC = vc(1, 832, 0, "AES-GCM secret encryption"); pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing"); pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage"); diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 58766cf25b..f357298243 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -400,7 +400,66 @@ pub fn legacy_secret_upgrade_pending() -> bool { && !LEGACY_SECRET_UPGRADE_DONE.load(Ordering::Relaxed) } -/// Re-encrypt the secret variables still in the legacy format in v2, workspace by workspace. +/// AES-256-GCM ciphertext is 32 bytes (magic, nonce, tag) longer than legacy CBC before base64, +/// so the longest CBC value that fits 15000 chars is exactly 15040 chars once re-encrypted. +const VARIABLE_VALUE_WIDTH: i32 = 15040; +static VARIABLE_VALUE_WIDENED: AtomicBool = AtomicBool::new(false); + +/// Whether this process has seen `variable.value` wide enough for every v2 value. Only servers +/// look, so it stays false in a worker. +pub fn variable_value_widened() -> bool { + VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed) +} + +/// Whether `variable.value` may still have to be widened to fit v2 values. +pub fn variable_value_widen_pending() -> bool { + CipherFormat::current() == CipherFormat::V2 && !VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed) +} + +/// Servers report no version, so the worker version gate alone cannot see a server of the +/// previous release; its rollout is assumed done once this server has been up this long. +const VARIABLE_VALUE_WIDEN_MIN_UPTIME: std::time::Duration = + std::time::Duration::from_secs(30 * 60); + +/// Widen `variable.value` to [`VARIABLE_VALUE_WIDTH`], or `false` when it is too early to. +/// Not a migration: changing a column's type, even its length, fails every statement a live +/// process prepared on it until its connection closes. It waits for v2 writes, gated on a +/// worker version that retires such connections on the first failure, so that no process too +/// old to recover is still running. +async fn widen_variable_value(db: &DB) -> error::Result { + if VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed) { + return Ok(true); + } + // varchar's typmod is its length + 4, -1 when unbounded. + let typmod: i32 = sqlx::query_scalar( + "SELECT atttypmod FROM pg_attribute WHERE attrelid = 'variable'::regclass AND attname = 'value'", + ) + .fetch_one(db) + .await?; + if typmod >= 0 && typmod - 4 < VARIABLE_VALUE_WIDTH { + if crate::db::process_uptime() < VARIABLE_VALUE_WIDEN_MIN_UPTIME { + return Ok(false); + } + tracing::info!("widening variable.value to VARCHAR({VARIABLE_VALUE_WIDTH})"); + let mut tx = db.begin().await?; + // Queued behind a long transaction, the ALTER would block every variable read meanwhile. + sqlx::query("SET LOCAL lock_timeout = '3s'") + .execute(&mut *tx) + .await?; + sqlx::query(&format!( + "ALTER TABLE variable ALTER COLUMN value TYPE VARCHAR({VARIABLE_VALUE_WIDTH})" + )) + .execute(&mut *tx) + .await?; + tx.commit().await?; + crate::db::retire_connections_opened_before_now(); + } + VARIABLE_VALUE_WIDENED.store(true, Ordering::Relaxed); + Ok(true) +} + +/// Widen `variable.value` for v2 values, then re-encrypt the secret variables still in the +/// legacy format in v2, workspace by workspace. /// Idempotent, and emits no git-sync event: the plaintexts do not change. pub async fn upgrade_legacy_secret_variables(db: &DB) { struct Running; @@ -409,14 +468,26 @@ pub async fn upgrade_legacy_secret_variables(db: &DB) { LEGACY_SECRET_UPGRADE_RUNNING.store(false, Ordering::Release); } } - if !legacy_secret_upgrade_pending() + if !(variable_value_widen_pending() || legacy_secret_upgrade_pending()) || LEGACY_SECRET_UPGRADE_RUNNING.swap(true, Ordering::AcqRel) { return; } // Cleared on drop, so a pass that panics does not stop every later one in this process. let _running = Running; - upgrade_legacy_secret_variables_pass(db).await; + match widen_variable_value(db).await { + Ok(true) => {} + Ok(false) => return, + Err(e) => { + tracing::warn!( + "secret encryption upgrade: widening variable.value failed, retrying later: {e:#}" + ); + return; + } + } + if legacy_secret_upgrade_pending() { + upgrade_legacy_secret_variables_pass(db).await; + } } async fn upgrade_legacy_secret_variables_pass(db: &DB) { diff --git a/backend/windmill-common/src/workspace_crypt.rs b/backend/windmill-common/src/workspace_crypt.rs index a561060e32..26f38e123b 100644 --- a/backend/windmill-common/src/workspace_crypt.rs +++ b/backend/windmill-common/src/workspace_crypt.rs @@ -30,6 +30,7 @@ pub const V2_MAGIC: [u8; 4] = *b"wmA2"; const NONCE_LEN: usize = 12; const TAG_LEN: usize = 16; const BLOCK: usize = 16; +const NARROW_VARIABLE_VALUE_WIDTH: usize = 15000; const AEAD_KEY_INFO: &[u8] = b"windmill workspace secret aes-256-gcm v1"; @@ -102,7 +103,23 @@ impl WorkspaceCrypt { } pub fn encrypt_str(&self, plaintext: &str) -> String { - self.encrypt_str_in(plaintext, CipherFormat::current()) + self.encrypt_str_fitting( + plaintext, + CipherFormat::current(), + crate::variables::variable_value_widened(), + ) + } + + /// A v2 value is up to 44 chars longer than CBC: until `variable.value` is known widened + /// for it, one past the narrow width is written in CBC, which fits. + fn encrypt_str_fitting(&self, plaintext: &str, format: CipherFormat, widened: bool) -> String { + if format == CipherFormat::V2 { + let v2 = self.encrypt_str_in(plaintext, CipherFormat::V2); + if widened || v2.len() <= NARROW_VARIABLE_VALUE_WIDTH { + return v2; + } + } + self.encrypt_str_in(plaintext, CipherFormat::LegacyCbc) } pub fn encrypt_str_in(&self, plaintext: &str, format: CipherFormat) -> String { @@ -242,6 +259,18 @@ mod tests { ); } + #[test] + fn v2_too_long_for_a_narrow_column_is_written_in_cbc() { + let wc = WorkspaceCrypt::new(KEY); + // The longest plaintext whose CBC value fits 15000 chars. + let long = "x".repeat(11231); + let narrow = wc.encrypt_str_fitting(&long, CipherFormat::V2, false); + assert!(!wc.is_v2(&narrow) && narrow.len() <= NARROW_VARIABLE_VALUE_WIDTH); + assert_eq!(wc.decrypt_str(&narrow).unwrap(), long); + assert!(wc.is_v2(&wc.encrypt_str_fitting(&long, CipherFormat::V2, true))); + assert!(wc.is_v2(&wc.encrypt_str_fitting("short", CipherFormat::V2, false))); + } + #[test] fn v2_passes_the_block_multiple_ciphertext_shape_check() { let wc = WorkspaceCrypt::new(KEY); From c523c4f6508c314bcc5dc912fe30594ceb7fd893 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 18:10:07 +0200 Subject: [PATCH 20/30] chore(main): release 1.832.0 (#11729) * chore(main): release 1.832.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 14 ++ backend/Cargo.lock | 158 +++++++++--------- 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 +- chat-sdk/package-lock.json | 4 +- chat-sdk/package.json | 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 +- windmill-yaml-validator/package-lock.json | 4 +- windmill-yaml-validator/package.json | 2 +- 22 files changed, 140 insertions(+), 126 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 0b517c26f7..a6316f6ef6 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.831.0" + ".": "1.832.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 98686e7917..67733a3aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.832.0](https://github.com/windmill-labs/windmill/compare/v1.831.0...v1.832.0) (2026-10-13) + + +### Features + +* record each instance's python version and start new ones on 3.13 ([#11656](https://github.com/windmill-labs/windmill/issues/11656)) ([7558ce2](https://github.com/windmill-labs/windmill/commit/7558ce27a9736bef1a9b9db9b0799150dccb11a5)) + + +### Bug Fixes + +* badge the unavailable sign-in option in the oauth resource form ([#11728](https://github.com/windmill-labs/windmill/issues/11728)) ([9c44f5a](https://github.com/windmill-labs/windmill/commit/9c44f5a917fc628e870073c126bf9480c98c7f73)) +* serve s3 proxy objects with nosniff and a sandbox csp ([#11731](https://github.com/windmill-labs/windmill/issues/11731)) ([e29a0dc](https://github.com/windmill-labs/windmill/commit/e29a0dc5dfed4bb62b894e059c9506873d32a62c)) +* widen variable.value without breaking live servers' cached queries ([#11737](https://github.com/windmill-labs/windmill/issues/11737)) ([89e6bdb](https://github.com/windmill-labs/windmill/commit/89e6bdbcf50e065f6906e1ab93b3c4109aba3ab6)) + ## [1.831.0](https://github.com/windmill-labs/windmill/compare/v1.830.0...v1.831.0) (2026-10-13) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 9ee5635c37..164a72d9ae 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -15486,7 +15486,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-nats", @@ -15576,7 +15576,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.831.0" +version = "1.832.0" dependencies = [ "async-stream", "async-trait", @@ -15611,7 +15611,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15624,7 +15624,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "argon2", @@ -15767,7 +15767,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15790,7 +15790,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15807,7 +15807,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -15833,7 +15833,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.831.0" +version = "1.832.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15843,7 +15843,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15860,7 +15860,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "base64 0.22.1", @@ -15882,7 +15882,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -15905,7 +15905,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15921,7 +15921,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15943,7 +15943,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15965,7 +15965,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -15979,7 +15979,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-nats", @@ -16015,7 +16015,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16041,7 +16041,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -16069,7 +16069,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16091,7 +16091,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -16111,7 +16111,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16153,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16182,7 +16182,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.831.0" +version = "1.832.0" dependencies = [ "lazy_static", "serde", @@ -16194,7 +16194,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.831.0" +version = "1.832.0" dependencies = [ "argon2", "axum", @@ -16218,7 +16218,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -16232,7 +16232,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.831.0" +version = "1.832.0" dependencies = [ "axum", "chrono", @@ -16271,7 +16271,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.831.0" +version = "1.832.0" dependencies = [ "chrono", "lazy_static", @@ -16286,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "axum", @@ -16305,7 +16305,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.831.0" +version = "1.832.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16421,7 +16421,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.831.0" +version = "1.832.0" dependencies = [ "chrono", "futures", @@ -16441,7 +16441,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.831.0" +version = "1.832.0" dependencies = [ "regex", "serde", @@ -16458,7 +16458,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16485,7 +16485,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "futures", @@ -16502,7 +16502,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.831.0" +version = "1.832.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16518,7 +16518,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -16540,7 +16540,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -16571,7 +16571,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "arc-swap", @@ -16596,7 +16596,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-stream", @@ -16632,7 +16632,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "futures", @@ -16651,7 +16651,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.831.0" +version = "1.832.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16660,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16672,7 +16672,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -16684,7 +16684,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "gosyn", @@ -16696,7 +16696,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16708,7 +16708,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -16720,7 +16720,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "nu-parser", @@ -16731,7 +16731,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16742,7 +16742,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16754,7 +16754,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16765,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-recursion", @@ -16788,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -16800,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16814,7 +16814,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16831,7 +16831,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16844,7 +16844,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde", @@ -16856,7 +16856,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16874,7 +16874,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16890,7 +16890,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16907,7 +16907,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -16921,7 +16921,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-recursion", @@ -16960,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "const_format", @@ -16998,7 +16998,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.831.0" +version = "1.832.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17009,7 +17009,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-recursion", @@ -17044,7 +17044,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17068,7 +17068,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17101,7 +17101,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17128,7 +17128,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17161,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17181,7 +17181,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17215,7 +17215,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17252,7 +17252,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17275,7 +17275,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17299,7 +17299,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-nats", @@ -17323,7 +17323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17358,7 +17358,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17386,7 +17386,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-trait", @@ -17411,7 +17411,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17430,7 +17430,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-once-cell", @@ -17549,7 +17549,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.831.0" +version = "1.832.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index a72480cab2..5b9ff24831 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.831.0" +version = "1.832.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.831.0" +version = "1.832.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 c0d6aa95d4..f8d4ae6c0d 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.831.0" +version = "1.832.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.831.0" +version = "1.832.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.831.0" +version = "1.832.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.831.0" +version = "1.832.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index d72119afd4..d72c4d87d0 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.831.0" +version = "1.832.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5295c5765f..3453b5e9ec 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.831.0 + version: 1.832.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 63e9a8a5d4..884f0a69b9 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.831.0"; +export const VERSION = "v1.832.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 91e9b9a081..b1c9148bbe 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.831.0", + "version": "1.832.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.831.0", + "version": "1.832.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 296be0e680..efae991922 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@windmill-labs/chat-sdk", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.831.0", + "version": "1.832.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index b6c029daa2..5afde16493 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.831.0"; +export const VERSION = "1.832.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3b5ecd97be..c93f513b25 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.831.0", + "version": "1.832.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.831.0", + "version": "1.832.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 33c24f43bd..7a9a3f0777 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.831.0", + "version": "1.832.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 4b782c4cf0..de0f791012 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.831.0" +wmill = ">=1.832.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 19ba842fff..e8a1c2e02e 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.831.0 + version: 1.832.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 573be0aa76..84fbf3eb14 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.831.0' + ModuleVersion = '1.832.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index dc227b7ccc..abe9debc23 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.831.0" +version = "1.832.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 3eaaece304..bb8ee9b251 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.831.0", + "version": "1.832.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 810c0a7940..3240d1ca3f 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.831.0", + "version": "1.832.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 74dfad0170..cebe79ce72 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.831.0 +1.832.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 94b3c36b9a..7918120f9f 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.831.0", + "version": "1.832.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.831.0", + "version": "1.832.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 77dbdfcecf..bb5b50b497 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.831.0", + "version": "1.832.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts", From 130adfdd20af0e30d87986cf77b61023a9732186 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 13 Oct 2026 22:51:48 +0200 Subject: [PATCH 21/30] fix: show the email handler's SMTP status to non-superadmin admins (#11745) * fix: show the email handler's SMTP status to non-superadmin workspace admins Co-Authored-By: Claude Opus 5.5 * fix: keep the SMTP status check out of the not-configured warning Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/windmill-api-settings/src/lib.rs | 10 ++++++++ backend/windmill-api/openapi.yaml | 18 ++++++++++++++ backend/windmill-common/src/server.rs | 24 +++++++++++++------ .../components/ErrorOrRecoveryHandler.svelte | 7 +----- 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 1359e8c142..f9ba1ff86f 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -132,6 +132,15 @@ async fn get_instance_ui( )) } +/// Whether email notifications can go out, for the workspace admins who configure email +/// handlers and cannot read `smtp_settings` itself (it holds the password). Asks the same +/// resolver the senders use, so SMTP set through `SMTP_*` env vars counts too. +async fn get_smtp_configured(Extension(db): Extension, _authed: ApiAuthed) -> JsonResult { + Ok(Json( + SMTP_ENABLED && windmill_common::server::is_smtp_configured(&db).await?, + )) +} + pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() @@ -152,6 +161,7 @@ pub fn global_service() -> Router { ) .route("/instance_config/yaml", get(get_instance_config_yaml)) .route("/test_smtp", post(test_email)) + .route("/smtp_configured", get(get_smtp_configured)) .route("/test_license_key", post(test_license_key)) .route("/send_stats", post(send_stats)) .route("/get_stats", get(get_stats)) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 3453b5e9ec..2acb854832 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1971,6 +1971,24 @@ paths: application/json: schema: {} + /settings/smtp_configured: + get: + summary: whether instance SMTP is configured + description: | + Readable by any authenticated user. True when this build can send email + and SMTP is set, either in the smtp_settings instance setting or through + the SMTP_* environment variables. + operationId: getSmtpConfigured + tags: + - setting + responses: + "200": + description: whether email notifications can be sent + content: + application/json: + schema: + type: boolean + /settings/test_smtp: post: summary: test smtp diff --git a/backend/windmill-common/src/server.rs b/backend/windmill-common/src/server.rs index 19da3222e9..52ef7d79cb 100644 --- a/backend/windmill-common/src/server.rs +++ b/backend/windmill-common/src/server.rs @@ -31,9 +31,24 @@ pub async fn load_smtp_config(db: &DB) -> error::Result> { Ok(parse_smtp_config(value)) } +/// Whether SMTP is set, without the "not configured" warning: any signed-in user can ask, and +/// "no" is an ordinary answer there rather than something to log. +pub async fn is_smtp_configured(db: &DB) -> error::Result { + let value = crate::global_settings::load_value_from_global_settings(db, SMTP_SETTING).await?; + Ok(resolve_smtp_config(value).is_some()) +} + /// The half of [`load_smtp_config`] after the read, so a batched settings pass can parse a /// value it already fetched. pub fn parse_smtp_config(value: Option) -> Option { + let smtp = resolve_smtp_config(value); + if smtp.is_none() { + tracing::warn!("SMTP not configured"); + } + smtp +} + +fn resolve_smtp_config(value: Option) -> Option { let config: SmtpConfigOpt = value .map(|x| serde_json::from_value(x).ok()) .flatten() @@ -57,7 +72,7 @@ pub fn parse_smtp_config(value: Option) -> Option { } else { None }; - let smtp = config_smtp.or( + config_smtp.or( if let (Some(host), username, password) = ( std::env::var("SMTP_HOST").ok(), std::env::var("SMTP_USERNAME").ok(), @@ -86,12 +101,7 @@ pub fn parse_smtp_config(value: Option) -> Option { } else { None }, - ); - if smtp.is_none() { - tracing::warn!("SMTP not configured"); - } - - smtp + ) } impl Default for SmtpConfigOpt { diff --git a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte index c624b5a148..ef805162a6 100644 --- a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte +++ b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte @@ -73,7 +73,6 @@ import { syncSlackBotArg } from './handlerSlackArg' import SmtpConfigurationStatus from './common/smtp/SmtpConfigurationStatus.svelte' import { SettingService } from '$lib/gen' - import { isSmtpSettingsValid } from './instanceSettings/SmtpSettings.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -171,11 +170,7 @@ async function loadSmtpConfiguration() { try { - const smtpSettings = (await SettingService.getGlobal({ key: 'smtp_settings' })) as Record< - string, - any - > | null - hasSmtpConfig = smtpSettings ? isSmtpSettingsValid(smtpSettings) : false + hasSmtpConfig = await SettingService.getSmtpConfigured() } catch (error) { hasSmtpConfig = false } From 9e0db2c7778d2e9e186f0d38c18674a9c373c5d1 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 13 Oct 2026 23:09:09 +0200 Subject: [PATCH 22/30] oauth: rename outlook provider to ms_outlook (#11743) * oauth: rename outlook provider to msoutlook Microsoft 365 products are ms (msteams, msoutlook). Renames the oauth_connect.json key and the icon map key. Co-Authored-By: Claude Opus 5.5 * oauth: use ms_outlook, not msoutlook Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- backend/oauth_connect.json | 2 +- frontend/src/lib/components/icons/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index b5ebd80e2a..a29aecb92d 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -446,7 +446,7 @@ ], "req_body_auth": true }, - "outlook": { + "ms_outlook": { "req_body_auth": true, "scopes": ["offline_access", "User.Read", "Mail.ReadWrite", "Mail.Send", "Calendars.ReadWrite", "Contacts.ReadWrite"], "scope_options": [ diff --git a/frontend/src/lib/components/icons/index.ts b/frontend/src/lib/components/icons/index.ts index 2b63491c6c..70d2450f29 100644 --- a/frontend/src/lib/components/icons/index.ts +++ b/frontend/src/lib/components/icons/index.ts @@ -446,7 +446,7 @@ export const APP_TO_ICON_COMPONENT = { quickbooks: QuickbooksIcon, ms_teams_webhook: MsTeamsIcon, teams: MsTeamsIcon, - outlook: OutlookIcon, + ms_outlook: OutlookIcon, mailgun: MailgunIcon, ipinfo: IpinfoIcon, gworkspace: GoogleIcon, From 2f9a111bbb14a40e1341879e1ef63c3457e7a08d Mon Sep 17 00:00:00 2001 From: AlexRV12 <71396855+AlexRV12@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:09:59 +0200 Subject: [PATCH 23/30] fix: error handler picker keeps no per-tab state (#11742) --- .../components/ErrorOrRecoveryHandler.svelte | 252 ++++-------------- .../AssetGraph/PipelineInsertMenu.svelte | 15 +- .../src/lib/components/handlerDraft.svelte.ts | 118 ++++++++ .../src/lib/components/handlerDraft.test.ts | 67 +++++ .../lib/components/handlerSlackArg.test.ts | 21 -- .../src/lib/components/handlerSlackArg.ts | 17 -- .../TriggerRetriesAndErrorHandler.svelte | 16 +- .../amqp/AmqpTriggerEditorInner.svelte | 33 ++- .../azure/AzureTriggerEditorInner.svelte | 40 +-- .../email/EmailTriggerEditorInner.svelte | 33 ++- .../triggers/gcp/GcpTriggerEditorInner.svelte | 33 ++- .../triggers/http/RouteEditorInner.svelte | 33 ++- .../kafka/KafkaTriggerEditorInner.svelte | 34 ++- .../mqtt/MqttTriggerEditorInner.svelte | 33 ++- .../nats/NatsTriggerEditorInner.svelte | 34 ++- .../PostgresTriggerEditorInner.svelte | 33 ++- .../schedules/ScheduleAdvancedOptions.svelte | 236 ++++++---------- .../schedules/ScheduleEditorInner.svelte | 175 +++--------- .../triggers/sqs/SqsTriggerEditorInner.svelte | 33 ++- frontend/src/lib/components/triggers/utils.ts | 15 -- .../WebsocketTriggerEditorInner.svelte | 33 ++- .../(logged)/workspace_settings/+page.svelte | 175 ++++++------ 22 files changed, 662 insertions(+), 817 deletions(-) create mode 100644 frontend/src/lib/components/handlerDraft.svelte.ts create mode 100644 frontend/src/lib/components/handlerDraft.test.ts delete mode 100644 frontend/src/lib/components/handlerSlackArg.test.ts delete mode 100644 frontend/src/lib/components/handlerSlackArg.ts diff --git a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte index ef805162a6..6d95ad0c82 100644 --- a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte +++ b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte @@ -1,6 +1,4 @@
- + {#snippet children({ item })} @@ -413,21 +290,21 @@
- {#if handlerSelected === 'custom'} + {#if draft.selected === 'custom'}
- {#if !handlerPath} + {#if !draft.custom.path}
- {#if handlerPath} + {#if draft.custom.path}

Extra arguments

{#await import('$lib/components/SchemaForm.svelte')} @@ -460,7 +337,7 @@ @@ -470,7 +347,7 @@ {/if}
{/if} - {:else if handlerSelected === 'slack'} + {:else if draft.selected === 'slack'} { - if (e.detail && errorOrRecovery === 'error') { - handlerPath = slackHandlerScriptPath - } else if (e.detail && errorOrRecovery === 'recovery') { - handlerPath = slackRecoveryHandler - } else if (e.detail && errorOrRecovery === 'success') { - handlerPath = slackSuccessHandler - } else { - handlerPath = undefined - } + on:change={(e) => { + draft.slack.path = e.detail ? builtinHubPath(errorOrRecovery, 'slack') : undefined }} /> {/if} - {#if workspaceConnectedToSlack && isSlackHandler(handlerPath)} + {#if workspaceConnectedToSlack && draft.slack.path}
{#await import('$lib/components/SchemaForm.svelte')} @@ -510,18 +379,18 @@ schemaFieldTooltip={{ channel: 'Slack channel name without the "#" - example: "windmill-alerts"' }} - bind:args={handlerExtraArgs} + bind:args={draft.slack.args} shouldHideNoInputs className="text-xs" /> {/await} - {#if $enterpriseLicense && isSlackHandler(handlerPath)} + {#if $enterpriseLicense} {#if connectionTestJob !== undefined} @@ -554,7 +423,7 @@ {:else if workspaceConnectedToSlack == undefined} {/if} - {:else if handlerSelected === 'teams'} + {:else if draft.selected === 'teams'} { - if (e.detail && errorOrRecovery === 'error') { - handlerPath = teamsHandlerScriptPath - } else if (e.detail && errorOrRecovery === 'recovery') { - handlerPath = teamsRecoveryHandler - } else if (e.detail && errorOrRecovery === 'success') { - handlerPath = teamsSuccessHandler - } else { - handlerPath = undefined - } + on:change={(e) => { + draft.teams.path = e.detail ? builtinHubPath(errorOrRecovery, 'teams') : undefined }} /> {/if} @@ -599,26 +460,26 @@ placeholder="Search Teams channels" workspace={effectiveWorkspace} teamId={teams_team_guid} - selectedChannel={handlerExtraArgs['channel'] + selectedChannel={draft.teams.args['channel'] ? { - channel_id: handlerExtraArgs['channel'], - channel_name: handlerExtraArgs['channel_name'] + channel_id: draft.teams.args['channel'], + channel_name: draft.teams.args['channel_name'] } : undefined} onSelectedChannelChange={(channel) => { - handlerExtraArgs['channel'] = channel?.channel_id - handlerExtraArgs['channel_name'] = channel?.channel_name + draft.teams.args['channel'] = channel?.channel_id + draft.teams.args['channel_name'] = channel?.channel_name }} onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)} />
- {#if $enterpriseLicense && isTeamsHandler(handlerPath) && workspaceConnectedToTeams} + {#if $enterpriseLicense && draft.teams.path && workspaceConnectedToTeams} {#if connectionTestJob !== undefined} @@ -644,7 +505,7 @@ {:else if workspaceConnectedToTeams == undefined} {/if} - {:else if handlerSelected === 'email'} + {:else if draft.selected === 'email'} {#if isCloudHosted()} Email notifications for trigger failures are only available in self-hosted Windmill @@ -657,8 +518,8 @@ handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [], - (recipients) => (handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = recipients) + () => draft.email.args.email_recipients ?? [], + (recipients) => (draft.email.args.email_recipients = recipients) } placeholder="Enter email addresses..." onCreateItem={(email) => { @@ -667,23 +528,24 @@ sendUserToast('Invalid email format', true) return } - const currentArray = handlerExtraArgs[EMAIL_RECIPIENTS_KEY] ?? [] - handlerExtraArgs[EMAIL_RECIPIENTS_KEY] = [...currentArray, email] + draft.email.args.email_recipients = [ + ...(draft.email.args.email_recipients ?? []), + email + ] }} class="w-full" /> - {#if handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length > 0} + {#if draft.email.args.email_recipients?.length > 0} - {handlerExtraArgs[EMAIL_RECIPIENTS_KEY]?.length} email{handlerExtraArgs[ - EMAIL_RECIPIENTS_KEY - ]?.length === 1 + {draft.email.args.email_recipients.length} email{draft.email.args.email_recipients + .length === 1 ? '' : 's'} configured {/if}
{/if} - {:else if handlerSelected === 'instance_alerts'} + {:else if draft.selected === 'instance_alerts'}
Failed jobs are reported to the Slack, Teams and email channels configured at the instance diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte index 56f0877ce0..c1b64b59a2 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineInsertMenu.svelte @@ -656,22 +656,13 @@ wsId={$workspaceStore} itemKind="script" canWrite - bind:errorHandlerSelected={config.advanced.errorHandlerSelected} - bind:errorHandlerPath={config.advanced.errorHandlerPath} - bind:errorHandleritemKind={config.advanced.errorHandleritemKind} - bind:errorHandlerExtraArgs={config.advanced.errorHandlerExtraArgs} + bind:errorHandler={config.advanced.errorHandler} bind:wsErrorHandlerMuted={config.advanced.wsErrorHandlerMuted} bind:failedTimes={config.advanced.failedTimes} bind:failedExact={config.advanced.failedExact} - bind:recoveryHandlerSelected={config.advanced.recoveryHandlerSelected} - bind:recoveryHandlerPath={config.advanced.recoveryHandlerPath} - bind:recoveryHandlerItemKind={config.advanced.recoveryHandlerItemKind} - bind:recoveryHandlerExtraArgs={config.advanced.recoveryHandlerExtraArgs} + bind:recoveryHandler={config.advanced.recoveryHandler} bind:recoveredTimes={config.advanced.recoveredTimes} - bind:successHandlerSelected={config.advanced.successHandlerSelected} - bind:successHandlerPath={config.advanced.successHandlerPath} - bind:successHandlerItemKind={config.advanced.successHandlerItemKind} - bind:successHandlerExtraArgs={config.advanced.successHandlerExtraArgs} + bind:successHandler={config.advanced.successHandler} bind:retry={config.advanced.retry} bind:dynamicSkipPath={config.advanced.dynamicSkipPath} bind:tag={config.advanced.tag} diff --git a/frontend/src/lib/components/handlerDraft.svelte.ts b/frontend/src/lib/components/handlerDraft.svelte.ts new file mode 100644 index 0000000000..a1422e42ed --- /dev/null +++ b/frontend/src/lib/components/handlerDraft.svelte.ts @@ -0,0 +1,118 @@ +import type { ErrorHandler } from '$lib/gen' +import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts' +import hubPaths from '$lib/hubPaths.json' + +export type HandlerMode = 'error' | 'recovery' | 'success' +type BuiltinDestination = 'slack' | 'teams' | 'email' + +export type HandlerSlot = { path?: string; args: Record } +export type HandlerDraft = { + selected: ErrorHandler + slack: HandlerSlot + teams: HandlerSlot + email: HandlerSlot + custom: HandlerSlot & { kind: 'script' | 'flow' } +} +type StoredHandler = { path?: string; kind: 'script' | 'flow'; args: Record } + +const SLACK_BOT_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH + +// Suffixes rather than full paths, so a handler stored under an older hub id is still recognised. +// Email uses one script for every mode. +const EMAIL_SUFFIX = '/workspace-or-error-handler-email' +const BUILTIN_SUFFIXES: Record> = { + error: { + slack: '/workspace-or-schedule-error-handler-slack', + teams: '/workspace-or-schedule-error-handler-teams' + }, + recovery: { + slack: '/schedule-recovery-handler-slack', + teams: '/schedule-recovery-handler-teams' + }, + success: { + slack: '/schedule-success-handler-slack', + teams: '/schedule-success-handler-teams' + } +} + +// The only settings each built-in hub script takes besides the ones the backend injects. +const BUILTIN_KEYS: Record = { + slack: ['channel'], + teams: ['channel', 'channel_name'], + email: ['email_recipients'] +} + +export function builtinHubPath(mode: HandlerMode, destination: 'slack' | 'teams'): string { + const key = + `${destination}${mode[0].toUpperCase()}${mode.slice(1)}Handler` as keyof typeof hubPaths + return hubPaths[key] +} + +export function handlerTypeForPath(mode: HandlerMode, path: string | undefined): ErrorHandler { + if (path?.startsWith('hub/')) { + if (path.endsWith(EMAIL_SUFFIX)) return 'email' + for (const [destination, suffix] of Object.entries(BUILTIN_SUFFIXES[mode])) { + if (path.endsWith(suffix)) return destination as BuiltinDestination + } + } + return 'custom' +} + +function emptyDraft(selected: ErrorHandler): HandlerDraft { + return { + selected, + slack: { args: {} }, + teams: { args: {} }, + email: { path: hubPaths.emailErrorHandler, args: {} }, + custom: { kind: 'script', args: {} } + } +} + +function pick(args: Record, keys: string[]): Record { + return Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]])) +} + +/** Puts a stored handler into the slot of its destination; every other slot starts empty. */ +export function handlerDraftFromStored( + mode: HandlerMode, + path: string | undefined, + kind: 'script' | 'flow' = 'script', + args: Record | null | undefined = {}, + opts: { asCustom?: boolean; emptySelected?: ErrorHandler } = {} +): HandlerDraft { + if (!path) return emptyDraft(opts.emptySelected ?? 'custom') + const destination = opts.asCustom ? 'custom' : handlerTypeForPath(mode, path) + const draft = emptyDraft(destination) + if (destination === 'custom') { + draft.custom = { path, kind, args: { ...(args ?? {}) } } + } else if (destination !== 'instance_alerts') { + draft[destination] = { path, args: pick(args ?? {}, BUILTIN_KEYS[destination]) } + } + return draft +} + +/** What a save stores: the selected destination only. */ +export function handlerDraftToStored(draft: HandlerDraft): StoredHandler { + const none: StoredHandler = { kind: 'script', args: {} } + switch (draft.selected) { + case 'custom': { + const { path, kind, args } = draft.custom + return path ? { path, kind, args: $state.snapshot(args) } : none + } + case 'slack': + case 'teams': + case 'email': { + const { path, args } = draft[draft.selected] + if (!path) return none + const stored = { path, kind: 'script' as const, args: $state.snapshot(args) } + if (draft.selected === 'slack') stored.args.slack = SLACK_BOT_ARG + return stored + } + default: + return none + } +} + +export function cloneHandlerDraft(draft: HandlerDraft): HandlerDraft { + return $state.snapshot(draft) as HandlerDraft +} diff --git a/frontend/src/lib/components/handlerDraft.test.ts b/frontend/src/lib/components/handlerDraft.test.ts new file mode 100644 index 0000000000..ee6ec4a040 --- /dev/null +++ b/frontend/src/lib/components/handlerDraft.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { + handlerDraftFromStored, + handlerDraftToStored, + handlerTypeForPath +} from './handlerDraft.svelte' + +const SLACK_BOT = '$res:f/slack_bot/bot_token' + +describe('handler draft', () => { + it('round-trips every destination unchanged', () => { + const stored = [ + { + path: 'hub/28794/workspace-or-schedule-error-handler-slack', + kind: 'script' as const, + args: { channel: 'alerts', slack: SLACK_BOT } + }, + { + path: 'hub/19742/workspace-or-schedule-error-handler-teams', + kind: 'script' as const, + args: { channel: 'c1', channel_name: 'General' } + }, + { + path: 'hub/19795/workspace-or-error-handler-email', + kind: 'script' as const, + args: { email_recipients: ['ops@example.com'] } + }, + { + path: 'f/handlers/on_error', + kind: 'flow' as const, + args: { slack: '$res:u/me/my_slack', channel: 'own' } + } + ] + for (const s of stored) { + const draft = handlerDraftFromStored('error', s.path, s.kind, s.args) + expect(handlerDraftToStored(draft)).toEqual(s) + } + }) + + it("saves only the selected destination's settings", () => { + const draft = handlerDraftFromStored('error', 'u/me/handler', 'script', { + webhook_url: 'https://hooks.example/custom' + }) + draft.selected = 'email' + draft.email.args.email_recipients = ['ops@example.com'] + expect(handlerDraftToStored(draft)).toEqual({ + path: 'hub/19795/workspace-or-error-handler-email', + kind: 'script', + args: { email_recipients: ['ops@example.com'] } + }) + }) + + it('recognises built-in handlers per mode', () => { + const recoverySlack = 'hub/28791/slack/schedule-recovery-handler-slack' + expect(handlerTypeForPath('recovery', recoverySlack)).toBe('slack') + expect(handlerTypeForPath('error', recoverySlack)).toBe('custom') + }) + + it('stores no handler when the destination has none', () => { + const draft = handlerDraftFromStored('error', undefined) + expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} }) + draft.selected = 'slack' + expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} }) + draft.selected = 'instance_alerts' + expect(handlerDraftToStored(draft)).toEqual({ kind: 'script', args: {} }) + }) +}) diff --git a/frontend/src/lib/components/handlerSlackArg.test.ts b/frontend/src/lib/components/handlerSlackArg.test.ts deleted file mode 100644 index b087a55f26..0000000000 --- a/frontend/src/lib/components/handlerSlackArg.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { syncSlackBotArg } from './handlerSlackArg' - -const AUTO = '$res:f/slack_bot/bot_token' - -describe('syncSlackBotArg', () => { - it("leaves a custom handler's slack value alone", () => { - const args = { slack: '$res:u/me/my_slack' } - syncSlackBotArg(args, false, false) - syncSlackBotArg(args, false, true) - expect(args).toEqual({ slack: '$res:u/me/my_slack' }) - }) - - it('sets the bot token for the built-in and drops it when leaving', () => { - const args: Record = {} - syncSlackBotArg(args, true, false) - expect(args.slack).toBe(AUTO) - syncSlackBotArg(args, false, true) - expect(args).toStrictEqual({}) - }) -}) diff --git a/frontend/src/lib/components/handlerSlackArg.ts b/frontend/src/lib/components/handlerSlackArg.ts deleted file mode 100644 index 6b8e4d6f5c..0000000000 --- a/frontend/src/lib/components/handlerSlackArg.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts' - -const AUTO_SLACK_ARG = '$res:' + WORKSPACE_SLACK_BOT_TOKEN_PATH - -// `slack` belongs to the Slack built-in only while it holds the value the picker set; any other -// value is a custom handler's own argument and must survive handler switches and reloads. -export function syncSlackBotArg( - args: Record, - isSlackBuiltin: boolean, - wasSlackBuiltin: boolean -) { - if (isSlackBuiltin) { - args.slack = AUTO_SLACK_ARG - } else if (wasSlackBuiltin && args.slack === AUTO_SLACK_ARG) { - delete args.slack - } -} diff --git a/frontend/src/lib/components/triggers/TriggerRetriesAndErrorHandler.svelte b/frontend/src/lib/components/triggers/TriggerRetriesAndErrorHandler.svelte index 86dbf31e46..f71205bc13 100644 --- a/frontend/src/lib/components/triggers/TriggerRetriesAndErrorHandler.svelte +++ b/frontend/src/lib/components/triggers/TriggerRetriesAndErrorHandler.svelte @@ -1,9 +1,10 @@ {#snippet workspaceDisc()} @@ -241,89 +264,129 @@ not this component) — so the lead can run the whole line. {/snippet} -