From d6476862b30692e450cceda09c58d47964f87d32 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 8 May 2026 04:05:13 +0000 Subject: [PATCH 01/50] fix(cli): forward HEADERS env var on every backend fetch call (#9075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several `fetch()` callers in the CLI bypassed `OpenAPI.HEADERS` and skipped the `HEADERS` env var, causing requests to fail behind auth gateways like Cloudflare Access (same shape as #6421): - `pushScript()` `/scripts/create` and `/scripts/create_snapshot` — regressed in #8936 when the call switched from `wmill.createScript()` (SDK) to a raw `fetch` for the `skip_if_noop` query param. - Script preview `/jobs/run/preview_bundle`. - App dev `/jobs_u/getupdate_sse` SSE stream. - `wmill docs` `/api/inkeep`. All four now spread `getHeaders()` and call `detectAuthGatewayChallenge()` so a Cloudflare/SSO challenge surfaces a clear error instead of an opaque JSON parse failure. Adds `test/headers_env_var.test.ts`: spins up an auth-gateway proxy that 403s requests missing `CF-Access-Client-Id` / `CF-Access-Client-Secret` and otherwise reverse-proxies to the test backend, then runs `wmill sync push` of a fresh script through the proxy. Negative case (no `HEADERS` env) verifies the proxy actually gates; positive case asserts every request including `/scripts/create` reaches the backend with the headers attached. Co-authored-by: Claude Opus 4.7 (1M context) --- cli/src/commands/app/dev.ts | 7 +- cli/src/commands/docs/docs.ts | 6 + cli/src/commands/script/script.ts | 20 ++- cli/test/headers_env_var.test.ts | 258 ++++++++++++++++++++++++++++++ 4 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 cli/test/headers_env_var.test.ts diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index 5b78f84e3f..7edb793bea 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -14,7 +14,8 @@ import * as path from "node:path"; import process from "node:process"; import { Buffer } from "node:buffer"; import { writeFileSync } from "node:fs"; -import { readTextFile } from "../../utils/utils.ts"; +import { getHeaders, readTextFile } from "../../utils/utils.ts"; +import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts"; import { WebSocket, WebSocketServer } from "ws"; import { createFrameworkPlugins, @@ -1714,13 +1715,17 @@ async function streamJobWithSSE( const sseUrl = `${baseUrl}api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?fast=true`; + const extraHeaders = getHeaders(); const response = await fetch(sseUrl, { headers: { Accept: "text/event-stream", Authorization: `Bearer ${token}`, + ...extraHeaders, }, }); + await detectAuthGatewayChallenge(response, sseUrl); + if (!response.ok) { throw new Error( `SSE request failed: ${response.status} ${response.statusText}`, diff --git a/cli/src/commands/docs/docs.ts b/cli/src/commands/docs/docs.ts index d86d4ca367..fed91201e0 100644 --- a/cli/src/commands/docs/docs.ts +++ b/cli/src/commands/docs/docs.ts @@ -4,6 +4,8 @@ import * as log from "../../core/log.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; import { GlobalOptions } from "../../types.ts"; +import { getHeaders } from "../../utils/utils.ts"; +import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts"; interface DocContentItem { title: string; @@ -36,6 +38,7 @@ async function docs( console.log(colors.bold(`\nSearching Windmill docs...\n`)); + const extraHeaders = getHeaders(); let res: Response; try { res = await fetch(url, { @@ -43,6 +46,7 @@ async function docs( headers: { "Content-Type": "application/json", Authorization: `Bearer ${workspace.token}`, + ...extraHeaders, }, body: JSON.stringify({ query }), }); @@ -50,6 +54,8 @@ async function docs( throw new Error(`Network error connecting to ${workspace.remote}: ${e}`); } + await detectAuthGatewayChallenge(res, url); + if (res.status === 403) { log.info( "Windmill documentation search is an Enterprise Edition feature. Please upgrade to use this command." diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 0635cd42ea..f1e605d8ff 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -12,7 +12,8 @@ import * as log from "../../core/log.ts"; import { sep as SEP } from "node:path"; import * as path from "node:path"; import { stringify as yamlStringify } from "yaml"; -import { deepEqual, readTextFile, readTextFileSync } from "../../utils/utils.ts"; +import { deepEqual, getHeaders, readTextFile, readTextFileSync } from "../../utils/utils.ts"; +import { detectAuthGatewayChallenge } from "../../utils/http_guards.ts"; import * as wmill from "../../../gen/services.gen.ts"; import * as specificItems from "../../core/specific_items.ts"; import { getCurrentGitBranch } from "../../utils/git.ts"; @@ -725,6 +726,7 @@ async function createScript( // (same content, lockfile, and metadata) as a no-op, so the CLI does not // produce phantom git-sync / promotion commits on re-pushes. const skipIfNoop = "skip_if_noop=true"; + const extraHeaders = getHeaders(); if (!bundleContent) { try { const url = @@ -738,9 +740,11 @@ async function createScript( headers: { Authorization: `Bearer ${workspace.token}`, "Content-Type": "application/json", + ...extraHeaders, }, body: JSON.stringify(body), }); + await detectAuthGatewayChallenge(req, url); if (req.status != 201) { throw Error( `${req.status} - ${req.statusText} - ${await req.text()}` @@ -771,9 +775,13 @@ async function createScript( skipIfNoop; const req = await fetch(url, { method: "POST", - headers: { Authorization: `Bearer ${workspace.token} ` }, + headers: { + Authorization: `Bearer ${workspace.token} `, + ...extraHeaders, + }, body: form, }); + await detectAuthGatewayChallenge(req, url); if (req.status != 201) { throw Error( `Script snapshot creation was not successful: ${req.status} - ${ @@ -1587,12 +1595,18 @@ async function preview( workspace.workspaceId + "/jobs/run/preview_bundle"; + const extraHeaders = getHeaders(); const response = await fetch(url, { method: "POST", - headers: { Authorization: `Bearer ${workspace.token}` }, + headers: { + Authorization: `Bearer ${workspace.token}`, + ...extraHeaders, + }, body: form, }); + await detectAuthGatewayChallenge(response, url); + if (!response.ok) { throw new Error( `Preview failed: ${response.status} - ${response.statusText} - ${await response.text()}` diff --git a/cli/test/headers_env_var.test.ts b/cli/test/headers_env_var.test.ts new file mode 100644 index 0000000000..ad483f4d05 --- /dev/null +++ b/cli/test/headers_env_var.test.ts @@ -0,0 +1,258 @@ +/** + * Integration test: HEADERS env var is forwarded on every CLI fetch call. + * + * Spins up an auth-gateway proxy in front of the test backend that: + * - 403s + Cloudflare-style HTML if the request is missing CF-Access-Client-Id / + * CF-Access-Client-Secret (mirrors the real-world Cloudflare Access challenge), + * - otherwise reverse-proxies to the backend. + * + * Then runs `wmill sync push` with --base-url pointed at the proxy. If any fetch + * in the CLI bypasses HEADERS, the proxy returns the challenge page and the push + * fails (or the request is logged as un-authenticated). Negative case verifies + * the gateway actually rejects un-headered requests, so a passing positive case + * is meaningful. + * + * Regression coverage for #6421 and the script.ts pushScript / + * jobs/run/preview_bundle / app dev SSE fetch calls that used to skip getHeaders(). + */ + +import { expect, test } from "bun:test"; +import { mkdir, writeFile } from "node:fs/promises"; +import type { Server } from "bun"; +import { withTestBackend } from "./test_backend.ts"; + +const HEADER_NAMES = ["CF-Access-Client-Id", "CF-Access-Client-Secret"] as const; +const HEADER_VALUES = { + "CF-Access-Client-Id": "test-cf-id", + "CF-Access-Client-Secret": "test-cf-secret", +} as const; + +const HEADERS_ENV = HEADER_NAMES + .map((h) => `${h}: ${HEADER_VALUES[h]}`) + .join(", "); + +interface ProxyState { + authenticatedRequests: { method: string; path: string }[]; + rejectedRequests: { method: string; path: string }[]; +} + +interface RunningProxy { + server: Server; + state: ProxyState; + url: string; +} + +function startGatewayProxy(backendUrl: string): RunningProxy { + const state: ProxyState = { + authenticatedRequests: [], + rejectedRequests: [], + }; + + const server = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + async fetch(req) { + const reqUrl = new URL(req.url); + const pathAndQuery = reqUrl.pathname + reqUrl.search; + + const id = req.headers.get("cf-access-client-id"); + const secret = req.headers.get("cf-access-client-secret"); + const passes = + id === HEADER_VALUES["CF-Access-Client-Id"] && + secret === HEADER_VALUES["CF-Access-Client-Secret"]; + + if (!passes) { + state.rejectedRequests.push({ method: req.method, path: pathAndQuery }); + return new Response( + 'Sign in ・ Cloudflare Access' + + 'Authenticate to reach this site.', + { + status: 403, + headers: { + "content-type": "text/html; charset=utf-8", + "cf-ray": "0000000000000000-TEST", + "cf-mitigated": "challenge", + }, + }, + ); + } + + state.authenticatedRequests.push({ method: req.method, path: pathAndQuery }); + + const target = new URL(pathAndQuery, backendUrl); + const forwardHeaders = new Headers(req.headers); + forwardHeaders.delete("cf-access-client-id"); + forwardHeaders.delete("cf-access-client-secret"); + forwardHeaders.set("host", new URL(backendUrl).host); + + const body = + req.method === "GET" || req.method === "HEAD" + ? undefined + : await req.arrayBuffer(); + + return await fetch(target, { + method: req.method, + headers: forwardHeaders, + body, + redirect: "manual", + }); + }, + }); + + return { + server, + state, + url: `http://127.0.0.1:${server.port}`, + }; +} + +async function runCliThroughProxy( + backend: { workspace: string; testConfigDir: string; token?: string }, + proxyUrl: string, + cliArgs: string[], + cwd: string, + env: Record, +): Promise<{ stdout: string; stderr: string; code: number }> { + const cliDir = new URL("..", import.meta.url).pathname; + const useNode = process.env["TEST_CLI_RUNTIME"] === "node"; + const runtime = useNode ? "node" : "bun"; + const entrypoint = useNode + ? `${cliDir}/npm/esm/main.js` + : `${cliDir}/src/main.ts`; + const runtimeArgs = useNode ? [entrypoint] : ["run", entrypoint]; + + const fullArgs = [ + "--base-url", proxyUrl, + "--workspace", backend.workspace, + "--token", backend.token ?? "", + "--config-dir", backend.testConfigDir, + ...cliArgs, + ]; + + const proc = Bun.spawn([runtime, ...runtimeArgs, ...fullArgs], { + cwd, + env: { ...(process.env as Record), ...env }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + return { stdout, stderr, code }; +} + +test( + "HEADERS env var is forwarded on every CLI fetch (sync push of new script)", + async () => { + await withTestBackend(async (backend, tempDir) => { + const proxy = startGatewayProxy(backend.baseUrl); + try { + await writeFile( + `${tempDir}/wmill.yaml`, + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8", + ); + + const uniqueId = Date.now(); + const scriptName = `headers_${uniqueId}`; + const scriptDir = `${tempDir}/f/test`; + await mkdir(scriptDir, { recursive: true }); + await writeFile( + `${scriptDir}/${scriptName}.ts`, + `export async function main() {\n return "headers test ${uniqueId}";\n}\n`, + "utf-8", + ); + await writeFile( + `${scriptDir}/${scriptName}.script.yaml`, + [ + `summary: "headers regression"`, + `description: "Push covers /scripts/create + /jobs/run/dependencies_async"`, + `lock: ""`, + `schema:`, + ` $schema: "https://json-schema.org/draft/2020-12/schema"`, + ` type: object`, + ` properties: {}`, + ` required: []`, + `is_template: false`, + `kind: script`, + `language: bun`, + ``, + ].join("\n"), + "utf-8", + ); + + const includesGlob = `f/test/${scriptName}**`; + + // Negative case: no HEADERS env -> the proxy should return the + // gateway challenge and the CLI should bail out non-zero. Proves the + // proxy is actually gating, so the positive case isn't a false pass. + const noHeaders = await runCliThroughProxy( + backend, + proxy.url, + ["sync", "push", "--yes", "--includes", includesGlob], + tempDir, + {}, + ); + expect(noHeaders.code).not.toEqual(0); + expect(proxy.state.rejectedRequests.length).toBeGreaterThan(0); + expect(proxy.state.authenticatedRequests.length).toEqual(0); + + // Reset proxy state between cases. + proxy.state.authenticatedRequests.length = 0; + proxy.state.rejectedRequests.length = 0; + + // Positive case: HEADERS env set -> the proxy must see those headers + // on every request the CLI makes for sync push to succeed. + const withHeaders = await runCliThroughProxy( + backend, + proxy.url, + ["sync", "push", "--yes", "--includes", includesGlob], + tempDir, + { HEADERS: HEADERS_ENV }, + ); + expect(withHeaders.code).toEqual(0); + expect(proxy.state.rejectedRequests).toEqual([]); + + const paths = proxy.state.authenticatedRequests.map((r) => r.path); + + // Print the full path list when an assertion below fails so the + // failure is debuggable without re-running with extra logging. + const debug = () => paths.join("\n "); + + // Tarball download (sync diff): src/commands/sync/pull.ts + expect( + paths.some((p) => + p.startsWith(`/api/w/${backend.workspace}/workspaces/tarball`), + ), + `expected tarball request, got:\n ${debug()}`, + ).toBe(true); + + // Script create: src/commands/script/script.ts pushScript(). + // This is the regression: PR #8936 switched from wmill.createScript() + // (SDK, inherits OpenAPI.HEADERS) to a raw fetch that didn't forward + // HEADERS. Without the fix, the proxy would 403 this request and the + // CLI exit would be non-zero — so this assertion is the load-bearing + // one for #8936 / #6421-style regressions. + expect( + paths.some((p) => + p.includes(`/api/w/${backend.workspace}/scripts/create`), + ), + `expected /scripts/create request, got:\n ${debug()}`, + ).toBe(true); + + // Lock generation: src/utils/metadata.ts. Was the original #6421 fix + // (PR #6422) — a soft check; the backend may skip queueing a lock job + // for trivial bun scripts under some feature configurations, so we + // only verify it *if* the CLI tried to generate one. + // (No assertion — covered by the tarball+create checks above plus + // the negative case proving the proxy actually gates requests.) + } finally { + proxy.server.stop(true); + } + }); + }, + 240_000, +); From bc527fd929577ac57d4e24196069ed236b702d71 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 8 May 2026 06:39:47 +0200 Subject: [PATCH 02/50] feat(cli): add --parallel flag to generate-metadata (#9074) * feat(cli): add --parallel flag to generate-metadata * fix(cli): validate --parallel input and harden flush ordering --- .../generate-metadata/generate-metadata.ts | 255 +++++++++++------- cli/src/guidance/skills.gen.ts | 2 + cli/src/utils/metadata.ts | 49 +++- .../auto-generated/cli/cli-commands.md | 2 + system_prompts/auto-generated/prompts.ts | 2 + .../skills/cli-commands/SKILL.md | 2 + 6 files changed, 209 insertions(+), 103 deletions(-) diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts index 226220592e..1de173e582 100644 --- a/cli/src/commands/generate-metadata/generate-metadata.ts +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -8,6 +8,8 @@ import { resolveWorkspace } from "../../core/context.ts"; import { requireLogin } from "../../core/auth.ts"; import * as log from "../../core/log.ts"; import { + beginLockfileBatch, + flushLockfileBatch, generateScriptMetadataInternal, getRawWorkspaceDependencies, readLockfile, @@ -200,6 +202,12 @@ export async function rehashOnly( const stubWorkspace = {} as any; const rehashOpts = { ...opts, rehashOnly: true } as any; + type RehashTask = + | { kind: "script"; scriptPath: string } + | { kind: "flow"; folder: string } + | { kind: "app"; folder: string; rawApp: boolean }; + const queue: RehashTask[] = []; + if (!rehashFilter?.skipScripts) { for (const e of scriptPaths) { // Filter against the derived remote path so a folder argument like @@ -210,14 +218,7 @@ export async function rehashOnly( if (rehashFilter?.missingOnly) { if (skipIfExisting(remotePath) || skipIfExisting(remotePath, "__script_hash")) continue; } - try { - await generateScriptMetadataInternal( - e, stubWorkspace, rehashOpts, false, true, {}, codebases, false, - ); - counts.scripts++; - } catch (err) { - log.warn(`Skipping ${e}: ${err instanceof Error ? err.message : err}`); - } + queue.push({ kind: "script", scriptPath: e }); } } @@ -228,12 +229,7 @@ export async function rehashOnly( const folderNormalized = f.replaceAll(SEP, "/"); if (skipIfExisting(folderNormalized, "__flow_hash")) continue; } - try { - await generateFlowLockInternal(f, false, stubWorkspace, rehashOpts, false, true); - counts.flows++; - } catch (err) { - log.warn(`Skipping ${f}: ${err instanceof Error ? err.message : err}`); - } + queue.push({ kind: "flow", folder: f }); } } @@ -244,13 +240,52 @@ export async function rehashOnly( const folderNormalized = appFolder.replaceAll(SEP, "/"); if (skipIfExisting(folderNormalized, "__app_hash")) continue; } - try { - await generateAppLocksInternal(appFolder, rawApp, false, stubWorkspace, rehashOpts, false, true); - counts.apps++; - } catch (err) { - log.warn(`Skipping ${appFolder}: ${err instanceof Error ? err.message : err}`); + queue.push({ kind: "app", folder: appFolder, rawApp }); + } + } + + let parallelism = Number(opts.parallel ?? 1); + if (!Number.isFinite(parallelism) || parallelism <= 0) parallelism = 1; + if (parallelism > 1) { + log.info(`Parallelizing ${parallelism} items at a time`); + } + + // Buffer wmill-lock.yaml writes during the parallel phase: each task mutates + // the shared in-memory lockfile, then we flush once. + await beginLockfileBatch(); + try { + const pool = new Set>(); + while (queue.length > 0 || pool.size > 0) { + while (pool.size < parallelism && queue.length > 0) { + const task = queue.shift()!; + const p = (async () => { + try { + if (task.kind === "script") { + await generateScriptMetadataInternal( + task.scriptPath, stubWorkspace, rehashOpts, false, true, {}, codebases, false, + ); + counts.scripts++; + } else if (task.kind === "flow") { + await generateFlowLockInternal(task.folder, false, stubWorkspace, rehashOpts, false, true); + counts.flows++; + } else { + await generateAppLocksInternal(task.folder, task.rawApp, false, stubWorkspace, rehashOpts, false, true); + counts.apps++; + } + } catch (err) { + const label = task.kind === "script" ? task.scriptPath : task.folder; + log.warn(`Skipping ${label}: ${err instanceof Error ? err.message : err}`); + } + })(); + pool.add(p); + p.then(() => pool.delete(p)); + } + if (pool.size > 0) { + await Promise.race(pool); } } + } finally { + await flushLockfileBatch(); } if (counts.scripts + counts.flows + counts.apps > 0 || !rehashFilter?.missingOnly) { @@ -519,85 +554,117 @@ export async function generateMetadata( const errors: { path: string; error: string }[] = []; - // Process scripts - for (const item of scripts) { - current++; - log.info(`${formatProgress(current)} script ${item.path}`); - try { - await generateScriptMetadataInternal( - item.path, // originalPath with extension - workspace, - opts, - false, // dryRun - true, // noStaleMessage - mismatchedWorkspaceDeps, - codebases, - false, - tree - ); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - errors.push({ path: item.path, error: msg }); - log.error(` Failed: ${msg}`); - } + let parallelism = Number(opts.parallel ?? 1); + if (!Number.isFinite(parallelism) || parallelism <= 0) parallelism = 1; + if (parallelism > 1) { + log.info(`Parallelizing ${parallelism} items at a time`); } - // Process flows - for (const item of flows) { - current++; - try { - const result = await generateFlowLockInternal( - item.folder.replaceAll("/", SEP), - false, // dryRun - workspace, - opts, - false, - true, // noStaleMessage - tree - ); - const flowResult = result as FlowLocksResult | undefined; - const scriptsInfo = flowResult?.updatedScripts?.length - ? colors.dim(colors.white(`: ${flowResult.updatedScripts.join(", ")}`)) - : ""; - log.info(`${formatProgress(current)} flow ${item.path}${scriptsInfo}`); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - errors.push({ path: item.path, error: msg }); - log.info(`${formatProgress(current)} flow ${item.path}`); - log.error(` Failed: ${msg}`); - } - } + type Task = + | { kind: "script"; item: StaleItem } + | { kind: "flow"; item: StaleItem } + | { kind: "app"; item: StaleItem }; + const queue: Task[] = [ + ...scripts.map((item) => ({ kind: "script", item })), + ...flows.map((item) => ({ kind: "flow", item })), + ...apps.map((item) => ({ kind: "app", item })), + ]; - // Process apps - for (const item of apps) { - current++; - try { - const result = await generateAppLocksInternal( - item.folder.replaceAll("/", SEP), - item.isRawApp!, // rawApp - false, // dryRun - workspace, - opts, - false, - true, // noStaleMessage - tree - ); - const appResult = result as AppLocksResult | undefined; - const scriptsInfo = appResult?.updatedScripts?.length - ? colors.dim(colors.white(`: ${appResult.updatedScripts.join(", ")}`)) - : ""; - log.info(`${formatProgress(current)} app ${item.path}${scriptsInfo}`); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - errors.push({ path: item.path, error: msg }); - log.info(`${formatProgress(current)} app ${item.path}`); - log.error(` Failed: ${msg}`); + // Buffer wmill-lock.yaml writes during the parallel phase: each task mutates + // the shared in-memory lockfile via clearGlobalLock/updateMetadataGlobalLock, + // then we flush once. Without this buffering, two workers' read-modify-write + // cycles would race and lose hashes. + await beginLockfileBatch(); + try { + const pool = new Set>(); + while (queue.length > 0 || pool.size > 0) { + while (pool.size < parallelism && queue.length > 0) { + const task = queue.shift()!; + const taskNumber = ++current; + const p = (async () => { + if (task.kind === "script") { + const item = task.item; + log.info(`${formatProgress(taskNumber)} script ${item.path}`); + try { + await generateScriptMetadataInternal( + item.path, // originalPath with extension + workspace, + opts, + false, // dryRun + true, // noStaleMessage + mismatchedWorkspaceDeps, + codebases, + false, + tree + ); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ path: item.path, error: msg }); + log.error(` Failed: ${msg}`); + } + } else if (task.kind === "flow") { + const item = task.item; + try { + const result = await generateFlowLockInternal( + item.folder.replaceAll("/", SEP), + false, // dryRun + workspace, + opts, + false, + true, // noStaleMessage + tree + ); + const flowResult = result as FlowLocksResult | undefined; + const scriptsInfo = flowResult?.updatedScripts?.length + ? colors.dim(colors.white(`: ${flowResult.updatedScripts.join(", ")}`)) + : ""; + log.info(`${formatProgress(taskNumber)} flow ${item.path}${scriptsInfo}`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ path: item.path, error: msg }); + log.info(`${formatProgress(taskNumber)} flow ${item.path}`); + log.error(` Failed: ${msg}`); + } + } else { + const item = task.item; + try { + const result = await generateAppLocksInternal( + item.folder.replaceAll("/", SEP), + item.isRawApp!, // rawApp + false, // dryRun + workspace, + opts, + false, + true, // noStaleMessage + tree + ); + const appResult = result as AppLocksResult | undefined; + const scriptsInfo = appResult?.updatedScripts?.length + ? colors.dim(colors.white(`: ${appResult.updatedScripts.join(", ")}`)) + : ""; + log.info(`${formatProgress(taskNumber)} app ${item.path}${scriptsInfo}`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + errors.push({ path: item.path, error: msg }); + log.info(`${formatProgress(taskNumber)} app ${item.path}`); + log.error(` Failed: ${msg}`); + } + } + })(); + pool.add(p); + p.then(() => pool.delete(p)); + } + if (pool.size > 0) { + await Promise.race(pool); + } } - } - // Persist all stale workspace dep hashes (not just filtered — deps are global, not folder-scoped) - const allStaleDeps = staleItems.filter((i) => i.type === "dependencies"); - await tree.persistDepsHashes(allStaleDeps.map((d) => d.path)); + // Persist all stale workspace dep hashes (not just filtered — deps are global, not folder-scoped) + const allStaleDeps = staleItems.filter((i) => i.type === "dependencies"); + await tree.persistDepsHashes(allStaleDeps.map((d) => d.path)); + } finally { + await flushLockfileBatch(); + } const succeeded = total - errors.length; log.info(""); @@ -640,6 +707,7 @@ const command = new Command() .option("--skip-flows", "Skip processing flows") .option("--skip-apps", "Skip processing apps") .option("--strict-folder-boundaries", "Only update items inside the specified folder (requires folder argument)") + .option("--parallel ", "Number of items to process in parallel") .option( "-i --includes ", "Comma separated patterns to specify which files to include" @@ -661,6 +729,7 @@ const command = new Command() .option("--skip-scripts", "Skip processing scripts") .option("--skip-flows", "Skip processing flows") .option("--skip-apps", "Skip processing apps") + .option("--parallel ", "Number of items to process in parallel") .option( "-i --includes ", "Comma separated patterns to specify which files to include" diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 3eb1c872cc..ede19be85b 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6730,6 +6730,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - \`--skip-flows\` - Skip processing flows - \`--skip-apps\` - Skip processing apps - \`--strict-folder-boundaries\` - Only update items inside the specified folder (requires folder argument) +- \`--parallel \` - Number of items to process in parallel - \`-i --includes \` - Comma separated patterns to specify which files to include - \`-e --excludes \` - Comma separated patterns to specify which files to exclude @@ -6739,6 +6740,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - \`--skip-scripts\` - Skip processing scripts - \`--skip-flows\` - Skip processing flows - \`--skip-apps\` - Skip processing apps + - \`--parallel \` - Number of items to process in parallel - \`-i --includes \` - Comma separated patterns to specify which files to include - \`-e --excludes \` - Comma separated patterns to specify which files to exclude diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 456336dae2..9668dd19e6 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -1202,7 +1202,32 @@ export function normalizeLockPath(p: string): string { return n; } +// When set, `clearGlobalLock` and `updateMetadataGlobalLock` mutate this +// in-memory copy instead of doing a full read-modify-write on disk for every +// call. Callers that fan out item processing through a worker pool wrap the +// pool with `beginLockfileBatch()`/`flushLockfileBatch()` so the lockfile is +// only written once at the end — see `generate-metadata` parallelism. +let inMemoryLock: Lock | null = null; + +export async function beginLockfileBatch(): Promise { + if (inMemoryLock) return; + inMemoryLock = await readLockfile(); +} + +export async function flushLockfileBatch(): Promise { + if (!inMemoryLock) return; + // Write first, then clear: if the disk write throws (e.g. ENOSPC), the + // buffered updates remain in memory and a retry can re-attempt the flush. + await writeFile( + WMILL_LOCKFILE, + yamlStringify(inMemoryLock as Record, yamlOptions), + "utf-8", + ); + inMemoryLock = null; +} + export async function readLockfile(): Promise { + if (inMemoryLock) return inMemoryLock; let parsed: unknown; try { parsed = await yamlParseFile(WMILL_LOCKFILE); @@ -1344,11 +1369,13 @@ export async function clearGlobalLock(path: string): Promise { } }); } - await writeFile( - WMILL_LOCKFILE, - yamlStringify(conf as Record, yamlOptions), - "utf-8" - ); + if (!inMemoryLock) { + await writeFile( + WMILL_LOCKFILE, + yamlStringify(conf as Record, yamlOptions), + "utf-8" + ); + } } } @@ -1377,9 +1404,11 @@ export async function updateMetadataGlobalLock( conf.locks[path] = hash; } } - await writeFile( - WMILL_LOCKFILE, - yamlStringify(conf as Record, yamlOptions), - "utf-8" - ); + if (!inMemoryLock) { + await writeFile( + WMILL_LOCKFILE, + yamlStringify(conf as Record, yamlOptions), + "utf-8" + ); + } } diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index e541df9198..692e2dd310 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -168,6 +168,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - `--skip-flows` - Skip processing flows - `--skip-apps` - Skip processing apps - `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument) +- `--parallel ` - Number of items to process in parallel - `-i --includes ` - Comma separated patterns to specify which files to include - `-e --excludes ` - Comma separated patterns to specify which files to exclude @@ -177,6 +178,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - `--skip-scripts` - Skip processing scripts - `--skip-flows` - Skip processing flows - `--skip-apps` - Skip processing apps + - `--parallel ` - Number of items to process in parallel - `-i --includes ` - Comma separated patterns to specify which files to include - `-e --excludes ` - Comma separated patterns to specify which files to exclude diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index aeb39bb6b7..83f6ae1157 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2316,6 +2316,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - \`--skip-flows\` - Skip processing flows - \`--skip-apps\` - Skip processing apps - \`--strict-folder-boundaries\` - Only update items inside the specified folder (requires folder argument) +- \`--parallel \` - Number of items to process in parallel - \`-i --includes \` - Comma separated patterns to specify which files to include - \`-e --excludes \` - Comma separated patterns to specify which files to exclude @@ -2325,6 +2326,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - \`--skip-scripts\` - Skip processing scripts - \`--skip-flows\` - Skip processing flows - \`--skip-apps\` - Skip processing apps + - \`--parallel \` - Number of items to process in parallel - \`-i --includes \` - Comma separated patterns to specify which files to include - \`-e --excludes \` - Comma separated patterns to specify which files to exclude diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index 66efee7a68..b4818b4fa1 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -173,6 +173,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - `--skip-flows` - Skip processing flows - `--skip-apps` - Skip processing apps - `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument) +- `--parallel ` - Number of items to process in parallel - `-i --includes ` - Comma separated patterns to specify which files to include - `-e --excludes ` - Comma separated patterns to specify which files to exclude @@ -182,6 +183,7 @@ Generate metadata (locks, schemas) for all scripts, flows, and apps - `--skip-scripts` - Skip processing scripts - `--skip-flows` - Skip processing flows - `--skip-apps` - Skip processing apps + - `--parallel ` - Number of items to process in parallel - `-i --includes ` - Comma separated patterns to specify which files to include - `-e --excludes ` - Comma separated patterns to specify which files to exclude From 2067e0719fd1fd1b899b015badec0f222c054e66 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 8 May 2026 04:43:07 +0000 Subject: [PATCH 03/50] perf(flows): skip flow_env DB+transform work when no resolution is needed (#9078) --- backend/windmill-worker/src/common.rs | 7 +++ backend/windmill-worker/src/worker_flow.rs | 50 ++++++++++++++++------ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 0946075443..99e323dcfd 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -148,6 +148,13 @@ lazy_static::lazy_static! { static ref RE_RES_VAR: Regex = Regex::new(r#"\$(?:var|jsonvar|res|encrypted)\:"#).unwrap(); } +/// Returns true if any value in `vs` contains a `$var:`/`$jsonvar:`/`$res:`/`$encrypted:` +/// reference that would need interpolation by `transform_json`. Cheap pre-check that +/// callers can use to skip the DB roundtrip + clone path when nothing requires resolution. +pub(crate) fn map_needs_resolution(vs: &HashMap>) -> bool { + vs.values().any(|v| (*RE_RES_VAR).is_match(v.get())) +} + pub async fn transform_json<'a>( client: &AuthedClient, workspace: &str, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 050ae4b44f..3a07d1bfe4 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -2394,21 +2394,47 @@ async fn resolve_flow_env_for_status_update( workspace_id: &str, flow_value: &FlowValue, ) -> Option>> { - let env = if let Some(ref e) = flow_value.flow_env { - e.clone() - } else { - fetch_root_flow_env(db, flow_job_id, workspace_id).await? - }; + // Fetch the env source. For the inherited path, we first need to know whether the + // flow even has a parent — `fetch_root_flow_env` runs a recursive CTE on `v2_job` + // and is wasted work for top-level flows with no own `flow_env`. The mini job we + // pull here can be reused below if `transform_json` ends up needing it. + let (env, mini): (HashMap>, Option) = + if let Some(ref e) = flow_value.flow_env { + (e.clone(), None) + } else { + let mini = match get_mini_pulled_job(db, &flow_job_id).await { + Ok(Some(j)) => j, + Ok(None) => return None, + Err(e) => { + tracing::warn!("Failed to fetch flow job to resolve flow_env: {e:#}"); + return None; + } + }; + if mini.parent_job.is_none() { + // No own flow_env and no parent to inherit from — nothing to resolve. + return None; + } + let env = fetch_root_flow_env(db, flow_job_id, workspace_id).await?; + (env, Some(mini)) + }; if env.is_empty() { return Some(env); } - let mini = match get_mini_pulled_job(db, &flow_job_id).await { - Ok(Some(j)) => j, - Ok(None) => return Some(env), - Err(e) => { - tracing::warn!("Failed to fetch flow job to resolve flow_env: {e:#}"); - return Some(env); - } + // Skip the DB roundtrip + `transform_json` when nothing in env needs interpolation. + // This is the common case: a flow_env containing only literal values. + if !crate::common::map_needs_resolution(&env) { + return Some(env); + } + let mini = match mini { + Some(m) => m, + None => match get_mini_pulled_job(db, &flow_job_id).await { + Ok(Some(j)) => j, + Ok(None) => return Some(env), + Err(e) => { + tracing::warn!("Failed to fetch flow job to resolve flow_env: {e:#}"); + return Some(env); + } + }, }; match transform_json( client, From 1c56148714861aafc4f489916c71aa4674e938c0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 8 May 2026 07:43:21 +0200 Subject: [PATCH 04/50] fix(cli-tests): stabilize flow lock-gen race + Windows path (#9080) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli-tests): stabilize flow lock-gen race + Windows path Three CLI test failures on the latest main, all flaky on CI: 1. `Mixed Case Paths: pull and push flow with capitalized folder` and `Integration: Mixed scripts and flows with nonDottedPaths are idempotent`: flow create/update queues an async FlowDependencies job that fills inline-script lockfiles and rewrites flow.value. The tests pulled/pushed before the worker finished, so dry-run idempotency saw phantom `*.inline_script.lock` adds and `flow.yaml` edits. Added a `waitForFlowDependencyJob` helper that polls `/flows/get` for the latest `dependency_job` and `/jobs_u/completed/get` until it lands, and called it after each API/CLI flow write in both tests. 2. `HEADERS env var is forwarded on every CLI fetch` (Windows-only, added in #9075): the new test built the CLI entrypoint via `new URL("..", import.meta.url).pathname`, which yields `/C:/...` on Windows and `Bun.spawn` rejected before reaching the proxy, leaving `rejectedRequests.length` at 0. Switched to `fileURLToPath` + `node:path.join` to match `cargo_backend.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(cli-tests): use /flows/deployment_status to actually wait for dep job CI reviewers (Claude, Codex) flagged the prior `waitForFlowDependencyJob` as a no-op: it read `flow.dependency_job` from `/api/w/{ws}/flows/get`, but `Flow` / `FlowWithStarred` (backend/windmill-types/src/flows.rs:20-60) do not include that field. The helper exited on the first iteration without polling. Switch to `/api/w/{ws}/flows/deployment_status/p/{path}`, which returns `{ lock_error_logs, job_id }`. `job_id` is the FlowDependencies UUID written into `deployment_metadata` in the same tx as the dep-job push (backend/windmill-api-flows/src/flows.rs:660-672 and :1275-1292), so by the time the create/update API call returns, the response carries the latest dep-job UUID. Then poll `/jobs_u/completed/get/{job_id}` as before. Local runtime for `mixed_case_paths.test.ts` jumps from ~9s to ~32s, confirming the helper now actually waits instead of returning immediately. The 404 short-circuit in `sync_pull_push.test.ts` still works — `get_deployment_status` returns 404 when the flow is absent. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- cli/test/headers_env_var.test.ts | 12 +++++-- cli/test/mixed_case_paths.test.ts | 55 +++++++++++++++++++++++++++++ cli/test/sync_pull_push.test.ts | 58 +++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 3 deletions(-) diff --git a/cli/test/headers_env_var.test.ts b/cli/test/headers_env_var.test.ts index ad483f4d05..72fdf4103f 100644 --- a/cli/test/headers_env_var.test.ts +++ b/cli/test/headers_env_var.test.ts @@ -18,6 +18,8 @@ import { expect, test } from "bun:test"; import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import type { Server } from "bun"; import { withTestBackend } from "./test_backend.ts"; @@ -113,12 +115,16 @@ async function runCliThroughProxy( cwd: string, env: Record, ): Promise<{ stdout: string; stderr: string; code: number }> { - const cliDir = new URL("..", import.meta.url).pathname; + // Use fileURLToPath + node:path so the CLI entrypoint is a real OS path on + // Windows. `new URL(..).pathname` yields `/C:/...` which Bun.spawn fails to + // resolve, so the negative case "rejected" on launch instead of reaching + // the proxy and the assertion on rejectedRequests > 0 flaked. + const cliDir = join(dirname(fileURLToPath(import.meta.url)), ".."); const useNode = process.env["TEST_CLI_RUNTIME"] === "node"; const runtime = useNode ? "node" : "bun"; const entrypoint = useNode - ? `${cliDir}/npm/esm/main.js` - : `${cliDir}/src/main.ts`; + ? join(cliDir, "npm", "esm", "main.js") + : join(cliDir, "src", "main.ts"); const runtimeArgs = useNode ? [entrypoint] : ["run", entrypoint]; const fullArgs = [ diff --git a/cli/test/mixed_case_paths.test.ts b/cli/test/mixed_case_paths.test.ts index d85f81cc83..872c6f94ff 100644 --- a/cli/test/mixed_case_paths.test.ts +++ b/cli/test/mixed_case_paths.test.ts @@ -130,6 +130,57 @@ async function createFlow( throw new Error(`Failed to create flow ${flowPath}: ${error}`); } await response.text(); + + // Flow creation queues an async FlowDependencies job that generates the + // inline-script lockfile and rewrites flow.value. If we don't wait, a + // subsequent pull/push races the worker and the dry-run idempotency + // assertion sees a phantom lock-add + flow.yaml-edit diff (CI-only flake). + await waitForFlowDependencyJob(backend, flowPath); +} + +async function waitForFlowDependencyJob( + backend: any, + flowPath: string, + timeoutMs: number = 30000, +): Promise { + // /flows/get does not return `dependency_job`. The deployment_status route + // joins flow_version against deployment_metadata, which is populated in the + // same tx as the FlowDependencies push, so by the time the create/update + // API call returns, job_id is already the latest dep-job UUID. + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const statusResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/flows/deployment_status/p/${flowPath}`, + ); + if (statusResp.status === 404) { + await statusResp.text().catch(() => {}); + return; + } + if (!statusResp.ok) { + await statusResp.text().catch(() => {}); + throw new Error( + `Failed to fetch deployment status for ${flowPath}: ${statusResp.status}`, + ); + } + const status = await statusResp.json(); + const depJobId: string | undefined = status?.job_id; + if (!depJobId) { + await new Promise((r) => setTimeout(r, 100)); + continue; + } + const completed = await backend.apiRequest!( + `/api/w/${backend.workspace}/jobs_u/completed/get/${depJobId}`, + ); + if (completed.ok) { + await completed.text(); + return; + } + await completed.text().catch(() => {}); + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error( + `Flow dependency job for ${flowPath} did not complete within ${timeoutMs}ms`, + ); } async function createApp( @@ -384,6 +435,10 @@ excludes: [] const updatedFlow = await getFlow(backend, flowPath); expect(updatedFlow.summary).toEqual("Modified Data Processor Flow from test"); + // The CLI push enqueues a fresh FlowDependencies job. Wait for it before + // the dry-run pull so the lock/flow.value writes have committed. + await waitForFlowDependencyJob(backend, flowPath); + // Verify no diff on subsequent pull (idempotency) await verifyNoDiffOnPull(backend, tempDir); }); diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index e64af246f2..e31c4e1084 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -493,6 +493,58 @@ async function cleanupTempDir(dir: string): Promise { } } +// Polls /flows/deployment_status/p/{path} + /jobs_u/completed/get until the +// most recent flow dependency job has completed. After a flow create/update +// the API queues a FlowDependencies job that asynchronously fills inline- +// script lockfiles and rewrites flow.value; tests that round-trip through +// sync push/pull must wait for it or they race the worker (CI-only flake). +// `deployment_status` is the right read here — `/flows/get` does not return +// `dependency_job`, but the `deployment_metadata.job_id` row is written in +// the same tx as the dep-job push, so the returned `job_id` is the latest +// dep-job UUID by the time the create/update API call has returned. Returns +// silently if the flow doesn't exist on the server (treats it as a no-op +// push, since the route returns 404 when the flow row is missing). +async function waitForFlowDependencyJob( + backend: any, + flowPath: string, + timeoutMs: number = 30000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const statusResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/flows/deployment_status/p/${flowPath}`, + ); + if (statusResp.status === 404) { + await statusResp.text().catch(() => {}); + return; + } + if (!statusResp.ok) { + await statusResp.text().catch(() => {}); + throw new Error( + `Failed to fetch deployment status for ${flowPath}: ${statusResp.status}`, + ); + } + const status = await statusResp.json(); + const depJobId: string | undefined = status?.job_id; + if (!depJobId) { + await new Promise((r) => setTimeout(r, 100)); + continue; + } + const completed = await backend.apiRequest!( + `/api/w/${backend.workspace}/jobs_u/completed/get/${depJobId}`, + ); + if (completed.ok) { + await completed.text(); + return; + } + await completed.text().catch(() => {}); + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error( + `Flow dependency job for ${flowPath} did not complete within ${timeoutMs}ms`, + ); +} + // ============================================================================= // Tests // ============================================================================= @@ -1797,6 +1849,12 @@ excludes: [] expect(pushResult.code).toEqual(0); + // sync push of the flow enqueues an async FlowDependencies job that + // generates the inline-script lockfile and rewrites flow.value. Wait for + // it before pulling back, otherwise pull races the worker and the + // dry-run push idempotency check sees phantom diffs (CI-only flake). + await waitForFlowDependencyJob(backend, flowName); + // Pull back const pullResult = await backend.runCLICommand( ["sync", "pull", "--yes", "--includes", `f/test/mixed_*_${uniqueId}**`], From e1a7c75e192b72b3b0d854c1901653e0b9386bf2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 8 May 2026 07:43:46 +0200 Subject: [PATCH 05/50] perf(flows): cache resolved flow_env per flow execution (#9079) * perf(flows): cache resolved flow_env per flow execution * perf(flows): tighten flow_env cache cap to 1024 and clarify memory note * perf(flows): don't cache transient flow_env resolution failures --- backend/Cargo.lock | 1 + backend/windmill-worker/Cargo.toml | 1 + backend/windmill-worker/src/worker_flow.rs | 85 ++++++++++++++++++---- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c6bf144004..323155c29b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -17895,6 +17895,7 @@ dependencies = [ "process-wrap", "prometheus", "prost", + "quick_cache", "rand 0.9.0", "rcgen", "regex", diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index ad3d87d8df..f1c7fa6c99 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -93,6 +93,7 @@ itertools.workspace = true regex.workspace = true prometheus = { workspace = true, optional = true } lazy_static.workspace = true +quick_cache.workspace = true chrono.workspace = true dotenv.workspace = true rand.workspace = true # TODO: Remove. only used by token creation hack. diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 3a07d1bfe4..91c6cf9ec7 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -81,6 +81,33 @@ use windmill_audit::ActionKind; use windmill_common::audit::AuditAuthor; use windmill_queue::{canceled_job_to_result, push}; +lazy_static::lazy_static! { + /// Per-worker LRU cache of resolved `flow_env` values, keyed by flow job id. + /// `update_flow_status_after_job_completion_internal` runs once per child-step + /// completion, and re-resolving `$var:`/`$res:` references each time was the + /// dominant heap-allocation source under flow-heavy load. The cache aligns + /// predicate evaluation with `handle_flow`'s input-transform path, which + /// already resolves once at flow entry — predicates were the only place still + /// re-resolving on every read. + /// + /// Per-worker scope (caveat): the cache lives in process memory, not the DB, + /// so different workers processing children of the same flow each compute + /// their own snapshot on first miss. If a `$var:`/`$res:` value mutates + /// mid-flow, predicate eval on different workers can observe different + /// values for the same flow run. For typical use (env values configured at + /// flow start, read-only during execution) this is invisible. Cross-worker + /// determinism would require persisting the resolved env in `v2_job_status`; + /// see follow-up notes. + /// + /// Entries become dead weight once a flow completes and are evicted by LRU + /// pressure. Bounded at 1024 entries; per-entry footprint depends on the + /// env's contents (literals are small but a single resolved `$res:` can be + /// tens of KB), so worst-case memory scales with workload mix rather than + /// being fixed. + static ref RESOLVED_FLOW_ENV_CACHE: quick_cache::sync::Cache>>> = + quick_cache::sync::Cache::new(1024); +} + #[derive(Debug)] pub struct SchedulePushZombieError(pub String); @@ -480,8 +507,26 @@ pub async fn update_flow_status_after_job_completion_internal( .failure_module .as_ref() .is_some_and(|fm| retry_uses_flow_env(fm)); - let resolved_flow_env: Option>> = if needs_flow_env { - resolve_flow_env_for_status_update(db, client, flow, w_id, flow_value).await + // The resolved env is constant for a flow's lifetime, so cache it by flow + // job id and reuse across child-step completions. Cache miss falls back to + // the existing resolve+persist path; same-worker subsequent completions are + // a single Arc::clone away. Transient-failure fallbacks (`is_cacheable == + // false`) bypass the insert so a single API blip doesn't poison the rest + // of the flow run. + let resolved_flow_env: Option>>> = if needs_flow_env { + if let Some(cached) = RESOLVED_FLOW_ENV_CACHE.get(&flow) { + Some(cached) + } else { + resolve_flow_env_for_status_update(db, client, flow, w_id, flow_value) + .await + .map(|(env, is_cacheable)| { + let arc = Arc::new(env); + if is_cacheable { + RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); + } + arc + }) + } } else { None }; @@ -632,7 +677,7 @@ pub async fn update_flow_status_after_job_completion_internal( let bool_res = compute_bool_from_expr( &expr, Marc::new(args), - resolved_flow_env.as_ref(), + resolved_flow_env.as_deref(), result.clone(), all_iters, id_ctx.as_ref(), @@ -916,7 +961,7 @@ pub async fn update_flow_status_after_job_completion_internal( &mut stop_early_err_msg, &mut nresult, args, - resolved_flow_env.as_ref(), + resolved_flow_env.as_deref(), flow, &old_status, ) @@ -1136,7 +1181,7 @@ pub async fn update_flow_status_after_job_completion_internal( &mut stop_early_err_msg, &mut nresult, args, - resolved_flow_env.as_ref(), + resolved_flow_env.as_deref(), flow, &old_status, ) @@ -1219,7 +1264,7 @@ pub async fn update_flow_status_after_job_completion_internal( &old_status.retry, result.clone(), Marc::new(args), - resolved_flow_env.as_ref(), + resolved_flow_env.as_deref(), Some(client), ) .await? @@ -1601,7 +1646,7 @@ pub async fn update_flow_status_after_job_completion_internal( &old_status.retry, result.clone(), Marc::new(args), - resolved_flow_env.as_ref(), + resolved_flow_env.as_deref(), Some(client), ) .await? @@ -2387,13 +2432,19 @@ async fn fetch_root_flow_id(db: &DB, flow_id: Uuid) -> Uuid { // `update_flow_status_after_job_completion_internal`: take the current flow's // `flow_env` if present, otherwise inherit from the root flow, then interpolate // any `$var:`/`$res:` references via `transform_json`. +// +// Returns `Some((env, is_cacheable))`. `is_cacheable` is `false` only when the +// returned env is a partially-resolved fallback after a transient error (e.g. +// `transform_json` failed mid-resolve, or the mini job fetch failed). Callers +// must not cache `is_cacheable == false` results — doing so would freeze the +// transient failure for the rest of the flow run. async fn resolve_flow_env_for_status_update( db: &DB, client: &AuthedClient, flow_job_id: Uuid, workspace_id: &str, flow_value: &FlowValue, -) -> Option>> { +) -> Option<(HashMap>, bool)> { // Fetch the env source. For the inherited path, we first need to know whether the // flow even has a parent — `fetch_root_flow_env` runs a recursive CTE on `v2_job` // and is wasted work for top-level flows with no own `flow_env`. The mini job we @@ -2418,21 +2469,23 @@ async fn resolve_flow_env_for_status_update( (env, Some(mini)) }; if env.is_empty() { - return Some(env); + return Some((env, true)); } // Skip the DB roundtrip + `transform_json` when nothing in env needs interpolation. // This is the common case: a flow_env containing only literal values. if !crate::common::map_needs_resolution(&env) { - return Some(env); + return Some((env, true)); } let mini = match mini { Some(m) => m, None => match get_mini_pulled_job(db, &flow_job_id).await { Ok(Some(j)) => j, - Ok(None) => return Some(env), + // Don't cache: we couldn't fetch the job, so the env we'd return is the + // pre-interpolation literal — caching that would freeze the broken state. + Ok(None) => return Some((env, false)), Err(e) => { tracing::warn!("Failed to fetch flow job to resolve flow_env: {e:#}"); - return Some(env); + return Some((env, false)); } }, }; @@ -2445,11 +2498,13 @@ async fn resolve_flow_env_for_status_update( ) .await { - Ok(Some(resolved)) => Some(resolved), - Ok(None) => Some(env), + Ok(Some(resolved)) => Some((resolved, true)), + Ok(None) => Some((env, true)), + // Don't cache transient resolution failures: one variable/resource API blip + // would otherwise poison the literal-only env for the rest of the flow run. Err(e) => { tracing::warn!("Failed to resolve flow_env references in status update: {e:#}"); - Some(env) + Some((env, false)) } } } From 2510a097505c1bc290783fce2fc93b570e64ffa0 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 8 May 2026 08:16:21 +0200 Subject: [PATCH 06/50] chore(main): release 1.698.0 (#9076) * chore(main): release 1.698.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 19 +++ backend/Cargo.lock | 156 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 137 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7f2f43e3a..1034bc4f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.698.0](https://github.com/windmill-labs/windmill/compare/v1.697.0...v1.698.0) (2026-05-08) + + +### Features + +* **cli:** add --parallel flag to generate-metadata ([#9074](https://github.com/windmill-labs/windmill/issues/9074)) ([bc527fd](https://github.com/windmill-labs/windmill/commit/bc527fd929577ac57d4e24196069ed236b702d71)) + + +### Bug Fixes + +* **cli-tests:** stabilize flow lock-gen race + Windows path ([#9080](https://github.com/windmill-labs/windmill/issues/9080)) ([1c56148](https://github.com/windmill-labs/windmill/commit/1c56148714861aafc4f489916c71aa4674e938c0)) +* **cli:** forward HEADERS env var on every backend fetch call ([#9075](https://github.com/windmill-labs/windmill/issues/9075)) ([d647686](https://github.com/windmill-labs/windmill/commit/d6476862b30692e450cceda09c58d47964f87d32)) + + +### Performance Improvements + +* **flows:** cache resolved flow_env per flow execution ([#9079](https://github.com/windmill-labs/windmill/issues/9079)) ([e1a7c75](https://github.com/windmill-labs/windmill/commit/e1a7c75e192b72b3b0d854c1901653e0b9386bf2)) +* **flows:** skip flow_env DB+transform work when no resolution is needed ([#9078](https://github.com/windmill-labs/windmill/issues/9078)) ([2067e07](https://github.com/windmill-labs/windmill/commit/2067e0719fd1fd1b899b015badec0f222c054e66)) + ## [1.697.0](https://github.com/windmill-labs/windmill/compare/v1.696.2...v1.697.0) (2026-05-07) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 323155c29b..47aff74297 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -16025,7 +16025,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-nats", @@ -16106,7 +16106,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.697.0" +version = "1.698.0" dependencies = [ "async-trait", "aws-config", @@ -16130,7 +16130,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16143,7 +16143,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "argon2", @@ -16286,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16309,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16322,7 +16322,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16348,7 +16348,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.697.0" +version = "1.698.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16358,7 +16358,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16375,7 +16375,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "base64 0.22.1", @@ -16397,7 +16397,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16420,7 +16420,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16436,7 +16436,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16457,7 +16457,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16478,7 +16478,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16492,7 +16492,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-nats", @@ -16524,7 +16524,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16549,7 +16549,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "flate2", @@ -16567,7 +16567,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16589,7 +16589,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16609,7 +16609,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16639,7 +16639,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16667,7 +16667,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.697.0" +version = "1.698.0" dependencies = [ "lazy_static", "serde", @@ -16679,7 +16679,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.697.0" +version = "1.698.0" dependencies = [ "argon2", "axum 0.8.4", @@ -16704,7 +16704,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16718,7 +16718,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.697.0" +version = "1.698.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16751,7 +16751,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.697.0" +version = "1.698.0" dependencies = [ "chrono", "lazy_static", @@ -16765,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16784,7 +16784,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.697.0" +version = "1.698.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16885,7 +16885,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.697.0" +version = "1.698.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16904,7 +16904,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.697.0" +version = "1.698.0" dependencies = [ "regex", "serde", @@ -16919,7 +16919,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16943,7 +16943,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "futures", @@ -16960,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.697.0" +version = "1.698.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16976,7 +16976,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -16997,7 +16997,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17028,7 +17028,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "arc-swap", @@ -17053,7 +17053,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-stream", @@ -17087,7 +17087,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "futures", @@ -17105,7 +17105,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.697.0" +version = "1.698.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -17114,7 +17114,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -17126,7 +17126,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde_json", @@ -17138,7 +17138,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "gosyn", @@ -17150,7 +17150,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -17162,7 +17162,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde_json", @@ -17174,7 +17174,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "nu-parser", @@ -17185,7 +17185,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17196,7 +17196,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17208,7 +17208,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17219,7 +17219,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-recursion", @@ -17241,7 +17241,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde_json", @@ -17253,7 +17253,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -17267,7 +17267,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -17284,7 +17284,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -17297,7 +17297,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde", @@ -17309,7 +17309,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -17327,7 +17327,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17343,7 +17343,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17359,7 +17359,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde", @@ -17370,7 +17370,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-recursion", @@ -17407,7 +17407,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "const_format", @@ -17445,7 +17445,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.697.0" +version = "1.698.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17456,7 +17456,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-recursion", @@ -17486,7 +17486,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17510,7 +17510,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17543,7 +17543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17576,7 +17576,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17596,7 +17596,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17630,7 +17630,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17666,7 +17666,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17689,7 +17689,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17713,7 +17713,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-nats", @@ -17737,7 +17737,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17772,7 +17772,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17800,7 +17800,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-trait", @@ -17823,7 +17823,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17842,7 +17842,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-once-cell", @@ -17955,7 +17955,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.697.0" +version = "1.698.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 0d25909c74..1a1b1f09d0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.697.0" +version = "1.698.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.697.0" +version = "1.698.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 4bb0074734..0bc102cc48 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.697.0" +version = "1.698.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.697.0" +version = "1.698.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.697.0" +version = "1.698.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.697.0" +version = "1.698.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index e9dc39abc7..43a8bd59ef 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.697.0" +version = "1.698.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index dd3c480241..0c8d63298e 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.697.0 + version: 1.698.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 2e1f9ef06a..a20c1b18af 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.697.0"; +export const VERSION = "v1.698.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 3d9a15dfd9..9650032679 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.697.0"; +export const VERSION = "1.698.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2e8ca86313..c616a8e212 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.697.0", + "version": "1.698.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.697.0", + "version": "1.698.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 79d4465429..e6d623791f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.697.0", + "version": "1.698.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index 24a854d8e2..efe961555a 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.697.0" +wmill = ">=1.698.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index b4cd99fb13..d7e823897c 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.697.0 + version: 1.698.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 5626e3544d..2e25edb52d 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.697.0' + ModuleVersion = '1.698.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 868da1a79c..d1ac989649 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.697.0" +version = "1.698.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 135c0c7be3..07b5df0602 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.697.0", + "version": "1.698.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index bbb6083085..566e4bd4e4 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.697.0", + "version": "1.698.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 20b63a5ebc..7185d214b0 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.697.0 +1.698.0 From d37277d2341c83faf72efa0035cbf70e2cfbd596 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 8 May 2026 09:12:33 +0200 Subject: [PATCH 07/50] fix: reject root-rooted paths in ansible playbook validator on windows (#9081) --- .../windmill-worker/src/ansible_executor.rs | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index c8289b6934..2608cc6088 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -121,18 +121,24 @@ fn validate_relative_path(path: &str, field_name: &str) -> error::Result<()> { ))); } let p = std::path::Path::new(trimmed); - if p.is_absolute() { - return Err(error::Error::BadRequest(format!( - "`{}` must be a relative path inside the cloned repo, got: {}", - field_name, trimmed - ))); - } for component in p.components() { - if matches!(component, std::path::Component::ParentDir) { - return Err(error::Error::BadRequest(format!( - "`{}` must not contain `..` segments, got: {}", - field_name, trimmed - ))); + match component { + // RootDir catches leading `/` or `\`; Prefix catches Windows drive + // letters and UNC paths. `Path::is_absolute()` alone misses + // RootDir-only paths on Windows (e.g. `/etc/passwd`). + std::path::Component::RootDir | std::path::Component::Prefix(_) => { + return Err(error::Error::BadRequest(format!( + "`{}` must be a relative path inside the cloned repo, got: {}", + field_name, trimmed + ))); + } + std::path::Component::ParentDir => { + return Err(error::Error::BadRequest(format!( + "`{}` must not contain `..` segments, got: {}", + field_name, trimmed + ))); + } + _ => {} } } Ok(()) @@ -1765,9 +1771,19 @@ mod tests { #[test] fn test_validate_relative_path_rejects_absolute() { + // `/etc/passwd` isn't `is_absolute()` on Windows (no drive prefix), but + // its leading RootDir still escapes the cloned repo, so reject it on + // every platform. assert!(validate_relative_path("/etc/passwd", "playbook").is_err()); } + #[cfg(windows)] + #[test] + fn test_validate_relative_path_rejects_windows_absolute() { + assert!(validate_relative_path("\\etc\\passwd", "playbook").is_err()); + assert!(validate_relative_path("C:\\Windows\\System32", "playbook").is_err()); + } + #[test] fn test_validate_relative_path_rejects_parent_dir() { assert!(validate_relative_path("../escape.yml", "playbook").is_err()); From ee3d82f01f52d835218f544dad6de9b7c3184fbb Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 8 May 2026 09:59:46 +0200 Subject: [PATCH 08/50] fix(native-triggers): serialize Google channel renewal across replicas (#9060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-triggers): serialize Google channel renewal across replicas `sync_all_triggers` runs every 5 minutes on every windmill-app replica with no leader election. Multiple replicas were each rotating the webhook token, creating a new Google watch channel, and racing the trigger UPDATE — leaving the loser's new token (in `token`) and channel (in Google) orphaned. Cloud was accumulating ~5 leaked tokens/week without the silent best-effort `delete_token_by_hash` ever logging a warning. Wrap each per-trigger renewal in a transaction and acquire the row with `SELECT … FOR UPDATE SKIP LOCKED`. Contending replicas skip the row instead of duplicating the work. The lock spans `rotate_webhook_token` → Google API call → `update_native_trigger_service_config` and is only released on commit. Re-checks `should_renew_channel` after acquiring the lock so a replica that committed seconds earlier doesn't trigger a duplicate renewal. The pattern matches existing batch-cleanup paths in `monitor.rs` (job-retention sweep) and other `FOR UPDATE SKIP LOCKED` call sites. Also logs at `debug!` when `delete_token_by_hash` finds no matching row, so future investigations can distinguish "deleted" from "not found" without changing the `Ok(false)` contract. Co-Authored-By: Claude Opus 4.7 (1M context) * fixup! fix(native-triggers): serialize Google channel renewal across replicas * fixup! fix(native-triggers): serialize Google channel renewal across replicas fixup! fix(native-triggers): serialize Google channel renewal across replicas Address claude review: - #5: per-skip log info -> debug (expected outcome under SKIP LOCKED) - #2: warn moved out of delete_token_by_hash to the call site that knows the expected state (try_renew_channel_locked); other callers are race-prone and shouldn't warn - #3: NULL service_config now warns (anomalous case) - #4: post-Google-API DB-update + commit failures log distinctly so the channel-orphan case is grep-able Plus: add 14d expiry to Google webhook tokens via ServiceName::webhook_token_expiration, mint fresh ephemeral-webhook-{service}-{rd5} labels at create + rotate so the existing 'ephemeral-' filter excludes them from user-token email/critical-alert paths (no filter changes in 3 places). Orphans now self-clean via the existing expiry sweep in monitor.rs. Co-Authored-By: Claude Opus 4.7 (1M context) * fixup! fix(native-triggers): serialize Google channel renewal across replicas fixup! fix(native-triggers): serialize Google channel renewal across replicas Address second-round review: - Claude #1 (P2): username_override_from_label now strips the 'ephemeral-' prefix for ephemeral-webhook-* labels, so created_by stays webhook-{service}-{rd5} instead of changing to label-ephemeral-webhook-... (preserves audit/job-list filter compatibility) - Codex (P2): updated renew_channel doc — labels are no longer copied; rotate mints fresh ephemeral-webhook-google-{rd5} with 14d expiration - Claude #3 (optional): test_rotate_webhook_token now asserts the rotated Google token has an ephemeral-webhook-google-* label and a populated expiration Co-Authored-By: Claude Opus 4.7 (1M context) * fixup! fix(native-triggers): serialize Google channel renewal across replicas fixup! fix(native-triggers): serialize Google channel renewal across replicas Reconsider the previous fixup: stripping the 'ephemeral-' prefix made created_by no longer match token.label exactly, defeating the linking purpose. Just allowlist 'ephemeral-webhook-' alongside the other recognized webhook/email/ws prefixes — created_by becomes ephemeral-webhook-google-XXXXX, matching token.label exactly. The 'ephemeral-' substring also informs operators that this is a system-managed auto-expiring token vs a user-managed webhook trigger. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- ...3d94c27fcbbf989a4a70678dbc54e0d896e24.json | 41 ++++ ...fb888d53c5e4aa53bfc43ca296863bc61813f.json | 46 +++++ ...a2be62ce1f8f7edf3793d7ad4af9eb11b648f.json | 28 +++ backend/windmill-api-auth/src/auth.rs | 3 +- .../tests/token_hash.rs | 33 ++- .../src/google/external.rs | 190 +++++++++++++----- .../windmill-native-triggers/src/handler.rs | 15 +- backend/windmill-native-triggers/src/lib.rs | 57 ++++-- 8 files changed, 333 insertions(+), 80 deletions(-) create mode 100644 backend/.sqlx/query-6474dbc070afdce3e6bc74b02f93d94c27fcbbf989a4a70678dbc54e0d896e24.json create mode 100644 backend/.sqlx/query-676405f5ba49c1c711646bd0882fb888d53c5e4aa53bfc43ca296863bc61813f.json create mode 100644 backend/.sqlx/query-b02d3563b72ee8989dea128a3c6a2be62ce1f8f7edf3793d7ad4af9eb11b648f.json diff --git a/backend/.sqlx/query-6474dbc070afdce3e6bc74b02f93d94c27fcbbf989a4a70678dbc54e0d896e24.json b/backend/.sqlx/query-6474dbc070afdce3e6bc74b02f93d94c27fcbbf989a4a70678dbc54e0d896e24.json new file mode 100644 index 0000000000..702e6e36bb --- /dev/null +++ b/backend/.sqlx/query-6474dbc070afdce3e6bc74b02f93d94c27fcbbf989a4a70678dbc54e0d896e24.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT service_config, webhook_token_hash\n FROM native_trigger\n WHERE workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n FOR UPDATE SKIP LOCKED\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "service_config", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "webhook_token_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "native_trigger_service", + "kind": { + "Enum": [ + "nextcloud", + "google", + "github" + ] + } + } + }, + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "6474dbc070afdce3e6bc74b02f93d94c27fcbbf989a4a70678dbc54e0d896e24" +} diff --git a/backend/.sqlx/query-676405f5ba49c1c711646bd0882fb888d53c5e4aa53bfc43ca296863bc61813f.json b/backend/.sqlx/query-676405f5ba49c1c711646bd0882fb888d53c5e4aa53bfc43ca296863bc61813f.json new file mode 100644 index 0000000000..a1aa4cc015 --- /dev/null +++ b/backend/.sqlx/query-676405f5ba49c1c711646bd0882fb888d53c5e4aa53bfc43ca296863bc61813f.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, scopes, workspace_id, super_admin, owner FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 2, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "owner", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true, + false, + true + ] + }, + "hash": "676405f5ba49c1c711646bd0882fb888d53c5e4aa53bfc43ca296863bc61813f" +} diff --git a/backend/.sqlx/query-b02d3563b72ee8989dea128a3c6a2be62ce1f8f7edf3793d7ad4af9eb11b648f.json b/backend/.sqlx/query-b02d3563b72ee8989dea128a3c6a2be62ce1f8f7edf3793d7ad4af9eb11b648f.json new file mode 100644 index 0000000000..3536567e16 --- /dev/null +++ b/backend/.sqlx/query-b02d3563b72ee8989dea128a3c6a2be62ce1f8f7edf3793d7ad4af9eb11b648f.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, expiration FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "expiration", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "b02d3563b72ee8989dea128a3c6a2be62ce1f8f7edf3793d7ad4af9eb11b648f" +} diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index d4c448e934..ac4017b81c 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -715,7 +715,8 @@ pub async fn resolve_opt_job_authed( fn username_override_from_label(label: Option) -> Option { match label { Some(label) - if label.starts_with("webhook-") + if label.starts_with("ephemeral-webhook-") + || label.starts_with("webhook-") || label.starts_with("http-") || label.starts_with("email-") || label.starts_with("ws-") => diff --git a/backend/windmill-api-integration-tests/tests/token_hash.rs b/backend/windmill-api-integration-tests/tests/token_hash.rs index 6f4ba6b0ac..45d324a3d4 100644 --- a/backend/windmill-api-integration-tests/tests/token_hash.rs +++ b/backend/windmill-api-integration-tests/tests/token_hash.rs @@ -274,7 +274,9 @@ async fn test_plaintext_backward_compat(db: Pool) -> anyhow::Result<() ); // --- Phase 2: All workers upgraded (version >= 1.650.0) --- - MIN_VERSION.store(std::sync::Arc::new(MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone())); + MIN_VERSION.store(std::sync::Arc::new( + MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone(), + )); let resp = authed(client().post(format!("{base}/tokens/create"))) .json(&json!({"label": "new-worker-token"})) @@ -324,7 +326,7 @@ async fn test_plaintext_backward_compat(db: Pool) -> anyhow::Result<() async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { initialize_tracing().await; - use windmill_native_triggers::{delete_token_by_hash, rotate_webhook_token}; + use windmill_native_triggers::{delete_token_by_hash, rotate_webhook_token, ServiceName}; // Insert a token directly with known values let original_token = "test-webhook-token-original-1234"; @@ -342,7 +344,7 @@ async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { .await?; // Rotate the token - let rotated = rotate_webhook_token(&db, &original_hash) + let rotated = rotate_webhook_token(&db, &original_hash, ServiceName::Google) .await? .expect("rotate must return Some for existing token"); @@ -350,16 +352,27 @@ async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { assert_ne!(rotated.new_token, original_token); assert_eq!(rotated.old_token_hash, original_hash); - // New token's hash should exist in DB + // New token's hash should exist in DB with the per-service label and expiration let new_hash = hash_token(&rotated.new_token); - let exists: bool = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + let new_row = sqlx::query!( + "SELECT label, expiration FROM token WHERE token_hash = $1", new_hash ) - .fetch_one(&db) + .fetch_optional(&db) .await? - .unwrap_or(false); - assert!(exists, "new token hash must exist in DB after rotation"); + .expect("new token hash must exist in DB after rotation"); + assert!( + new_row + .label + .as_deref() + .is_some_and(|l| l.starts_with("ephemeral-webhook-google-")), + "rotated token must carry an ephemeral-webhook-google-* label, got {:?}", + new_row.label + ); + assert!( + new_row.expiration.is_some(), + "rotated Google token must carry an expiration" + ); // Old token should still exist (deletion deferred to caller) let old_exists: bool = sqlx::query_scalar!( @@ -389,7 +402,7 @@ async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { assert!(!old_gone, "old token must be gone after explicit deletion"); // Rotating a non-existent hash should return None - let result = rotate_webhook_token(&db, "nonexistent_hash").await?; + let result = rotate_webhook_token(&db, "nonexistent_hash", ServiceName::Google).await?; assert!( result.is_none(), "rotating a non-existent token must return None" diff --git a/backend/windmill-native-triggers/src/google/external.rs b/backend/windmill-native-triggers/src/google/external.rs index 29dd1c943f..616f8d3f63 100644 --- a/backend/windmill-native-triggers/src/google/external.rs +++ b/backend/windmill-native-triggers/src/google/external.rs @@ -323,9 +323,10 @@ impl Google { } /// Renew an expiring Google watch channel. - /// Rotates the webhook token (creating a new one with the same label), - /// stops the old channel and creates a new one with a fresh channel ID - /// (Google rejects reused channel IDs with `channelIdNotUnique`). + /// Rotates the webhook token (mints a fresh `ephemeral-webhook-google-{rd5}` label + /// and a 14-day expiration via `rotate_webhook_token`), stops the old channel and + /// creates a new one with a fresh channel ID (Google rejects reused channel IDs + /// with `channelIdNotUnique`). /// Returns (new_service_config, new_plaintext_token, old_token_hash). /// Callers should delete old_token_hash after successfully updating the trigger. pub async fn renew_channel( @@ -341,7 +342,13 @@ impl Google { .transpose()? .ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?; - let rotated = match rotate_webhook_token(db, &trigger.webhook_token_hash).await? { + let rotated = match rotate_webhook_token( + db, + &trigger.webhook_token_hash, + ServiceName::Google, + ) + .await? + { Some(r) => r, None => { return Err(Error::InternalErr(format!( @@ -464,6 +471,115 @@ pub fn should_renew_channel(service_config: &serde_json::Value) -> bool { remaining_ms < renewal_window_ms } +enum RenewOutcome { + Renewed, + /// Another replica holds the lock, or the row was already renewed. + Skipped, +} + +/// Renew one Google watch channel under a row lock. +/// `sync_all_triggers` runs on every replica with no leader election — without +/// the lock, parallel renewals orphan the losers' new tokens and Google channels. +async fn try_renew_channel_locked( + handler: &Google, + db: &DB, + workspace_id: &str, + trigger: &NativeTrigger, +) -> Result { + let mut tx = db.begin().await?; + + let row = sqlx::query!( + r#" + SELECT service_config, webhook_token_hash + FROM native_trigger + WHERE workspace_id = $1 + AND service_name = $2 + AND external_id = $3 + FOR UPDATE SKIP LOCKED + "#, + workspace_id, + ServiceName::Google as ServiceName, + trigger.external_id, + ) + .fetch_optional(&mut *tx) + .await?; + + let Some(row) = row else { + return Ok(RenewOutcome::Skipped); + }; + + let Some(service_config) = row.service_config else { + // Anomalous: a Google trigger row should always carry a service_config. + tracing::warn!( + "Google trigger '{}' has NULL service_config — skipping renewal", + trigger.external_id + ); + return Ok(RenewOutcome::Skipped); + }; + + // Re-check after the lock — a contending replica may have just renewed. + if !should_renew_channel(&service_config) { + return Ok(RenewOutcome::Skipped); + } + + // Use freshly-read fields — webhook_token_hash may have rotated since list time. + let fresh_trigger = NativeTrigger { + service_config: Some(service_config), + webhook_token_hash: row.webhook_token_hash, + ..trigger.clone() + }; + + let (new_config, new_token, old_token_hash) = handler + .renew_channel(workspace_id, &fresh_trigger, db) + .await?; + + // Past this point a new Google channel exists. Any failure leaks it. + if let Err(e) = update_native_trigger_service_config( + &mut *tx, + workspace_id, + ServiceName::Google, + &trigger.external_id, + &new_config, + Some(&new_token), + ) + .await + { + tracing::error!( + "DB update failed after creating new Google channel for '{}' — channel orphaned in Google: {}", + trigger.external_id, + e + ); + return Err(e); + } + + if let Err(e) = tx.commit().await { + tracing::error!( + "Commit failed after creating new Google channel for '{}' — channel orphaned in Google: {}", + trigger.external_id, + e + ); + return Err(e.into()); + } + + // With the lock + rotation in place, the old token row must exist here. + // Ok(false) means a concurrent path deleted it (or the expiry sweep collected it). + match crate::delete_token_by_hash(db, &old_token_hash).await { + Ok(true) => {} + Ok(false) => tracing::warn!( + "Old webhook token already gone after renewal for '{}' (hash {})", + trigger.external_id, + old_token_hash + ), + Err(e) => tracing::warn!( + "Failed to delete old webhook token after channel renewal for '{}': {}", + trigger.external_id, + e + ), + } + + Ok(RenewOutcome::Renewed) +} + async fn renew_expiring_channels( handler: &Google, db: &DB, @@ -488,53 +604,25 @@ async fn renew_expiring_channels( workspace_id ); - match handler.renew_channel(workspace_id, trigger, db).await { - Ok((new_config, new_token, old_token_hash)) => { - match update_native_trigger_service_config( - db, - workspace_id, - ServiceName::Google, - &trigger.external_id, - &new_config, - Some(&new_token), - ) - .await - { - Ok(()) => { - // Trigger updated — clean up old token (best-effort) - if let Err(e) = crate::delete_token_by_hash(db, &old_token_hash).await { - tracing::warn!( - "Failed to delete old webhook token after channel renewal for {}: {}", - trigger.external_id, e - ); - } - tracing::info!( - "Renewed Google channel {} for '{}'", - trigger.external_id, - trigger.script_path - ); - synced.push(TriggerSyncInfo { - external_id: trigger.external_id.clone(), - script_path: trigger.script_path.clone(), - action: SyncAction::ConfigUpdated, - }); - } - Err(e) => { - tracing::error!( - "Failed to update DB after renewing Google channel {}: {}", - trigger.external_id, - e - ); - errors.push(SyncError { - resource_path: format!("workspace:{}", workspace_id), - error_message: format!( - "Failed to update DB after channel renewal for {}: {}", - trigger.external_id, e - ), - error_type: "channel_renewal_error".to_string(), - }); - } - } + match try_renew_channel_locked(handler, db, workspace_id, trigger).await { + Ok(RenewOutcome::Renewed) => { + tracing::info!( + "Renewed Google channel {} for '{}'", + trigger.external_id, + trigger.script_path + ); + synced.push(TriggerSyncInfo { + external_id: trigger.external_id.clone(), + script_path: trigger.script_path.clone(), + action: SyncAction::ConfigUpdated, + }); + } + Ok(RenewOutcome::Skipped) => { + // Expected outcome under SKIP LOCKED: contending replica or already-renewed row. + tracing::debug!( + "Skipped Google channel renewal for '{}': another replica is renewing or the row was already renewed", + trigger.external_id + ); } Err(e) => { tracing::error!( diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 91a8b9531e..1b6aa83b92 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -1,7 +1,8 @@ use crate::{ decrypt_oauth_data, delete_native_trigger, delete_token_by_hash, get_native_trigger, list_native_triggers, rotate_webhook_token, store_native_trigger, update_native_trigger_error, - External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName, + webhook_token_label, External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, + ServiceName, }; use axum::{ extract::{Path, Query}, @@ -18,7 +19,6 @@ use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::{ db::UserDB, error::{Error, JsonResult, Result}, - utils::rd_string, DB, }; @@ -84,10 +84,13 @@ async fn new_webhook_token( let kind = if is_flow { "flows" } else { "scripts" }; let scopes = vec![format!("jobs:run:{kind}:{script_path}")]; - let label = format!("webhook-{}-{}", service_name.as_str(), rd_string(5)); + let label = webhook_token_label(service_name); + let expiration = service_name + .webhook_token_expiration() + .map(|d| chrono::Utc::now() + d); let token_config = NewToken::new( Some(label), - None, + expiration, None, Some(scopes), Some(workspace_id.to_owned()), @@ -255,8 +258,8 @@ async fn update_native_trigger_handler( tx = user_db.begin(&authed).await?; token } else { - // Same runnable — rotate the token keeping the same label - match rotate_webhook_token(&db, &existing.webhook_token_hash).await? { + // Same runnable — rotate the token (mints a fresh label + expiration) + match rotate_webhook_token(&db, &existing.webhook_token_hash, service_name).await? { Some(rotated) => { old_token_hash_to_delete = Some(rotated.old_token_hash); rotated.new_token diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index c871159b0f..743b9f8210 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -180,6 +180,17 @@ impl ServiceName { pub fn integration_service(&self) -> ServiceName { *self } + + /// How long webhook tokens for this service should remain valid. `None` = no expiry. + /// Google channels turn over on a tight schedule (24h Drive, 7d Calendar) — a finite + /// TTL lets `delete_expired_items` (`monitor.rs`) sweep orphaned tokens automatically. + /// Persistent-webhook services (Nextcloud, GitHub) return `None`. + pub fn webhook_token_expiration(&self) -> Option { + match self { + ServiceName::Google => Some(chrono::Duration::days(14)), + ServiceName::Nextcloud | ServiceName::Github => None, + } + } } impl std::fmt::Display for ServiceName { @@ -759,22 +770,23 @@ async fn update_oauth_token_resource( } } -/// Create a new webhook token that keeps the same label as the old one. -/// The old token is **not** deleted — callers must call `delete_token_by_hash` -/// on `old_token_hash` after the trigger row has been successfully updated. -/// This ensures the trigger keeps working if the external service call or -/// subsequent DB update fails. +/// Create a new webhook token, minting a fresh `ephemeral-webhook-{service}-{rd5}` +/// label and the per-service expiration (see `ServiceName::webhook_token_expiration`). +/// The old token is **not** deleted — callers must call `delete_token_by_hash` on +/// `old_token_hash` after the trigger row has been successfully updated. /// /// Returns `Ok(None)` if the old token no longer exists (e.g. manually deleted by user). -/// In that case, `renew_channel` returns an error which `renew_expiring_channels` writes -/// to the trigger's `error` column — visible in the UI so the user can re-create the trigger. -pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result> { +pub async fn rotate_webhook_token( + db: &DB, + old_token_hash: &str, + service_name: ServiceName, +) -> Result> { use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN}; use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; use windmill_common::utils::rd_string; let old = match sqlx::query!( - "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1", + "SELECT email, scopes, workspace_id, super_admin, owner FROM token WHERE token_hash = $1", old_token_hash ) .fetch_optional(db) @@ -799,6 +811,11 @@ pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result Result, old.email, - old.label, + new_label, old.super_admin, old.scopes.as_deref(), old.workspace_id, old.owner, - old.expiration, + new_expiration, ) .execute(db) .await?; @@ -822,6 +839,19 @@ pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result String { + use windmill_common::utils::rd_string; + format!( + "ephemeral-webhook-{}-{}", + service_name.as_str(), + rd_string(5) + ) +} + pub struct RotatedToken { pub new_token: String, /// Hash of the old token — callers should delete this after the @@ -829,7 +859,10 @@ pub struct RotatedToken { pub old_token_hash: String, } -/// Delete a token from the token table using its hash (exact match). +/// Delete a token by hash. Returns `Ok(false)` when no row matched. +/// Some call sites legitimately race against expiry sweeps or concurrent deletes; +/// callers that consider 0-rows anomalous should log themselves at the appropriate +/// level rather than have this helper warn unconditionally. pub async fn delete_token_by_hash<'c, E: sqlx::Executor<'c, Database = Postgres>>( db: E, token_hash: &str, From 4b4aa0e303f9c47c4f931511977107f42f93abc3 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 8 May 2026 17:57:04 +0200 Subject: [PATCH 09/50] fix(cli): bump svelte version in `wmill app new` template (#9084) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): bump svelte version in `wmill app new` template The svelte5 template pinned `svelte` to `5.45.2`, but the Svelte compiler bundled in `wmill app dev` emits `$.delegated('click', ...)` calls. The `delegated` export was added later, so 5.45.2 doesn't have it — esbuild warns `Import "delegated" will always be undefined`, replaces the call with `void 0`, and the page crashes at first event-handler bind (white screen). Bump to `^5.55.5` so the compiler and runtime stay in sync. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(frontend): bump svelte version in raw_apps UI template Mirror the CLI fix: the UI's `Add raw app` flow scaffolds a package.json with `svelte: "5.45.2"`. That works today only because the bundled rolldown worker also pins 5.45.2 — when the worker is upgraded past 5.51.1, the compiler will emit `$.delegated()` and the runtime won't have it, producing the same white-page crash that hit the CLI. 5.55.5 still exports `event` (used by the current bundled compiler), so this is forward-compatible: it works with the 5.45.2 compiler now and won't break when the worker is upgraded. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- cli/src/commands/app/new.ts | 2 +- frontend/src/routes/(root)/(logged)/apps_raw/add/templates.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/app/new.ts b/cli/src/commands/app/new.ts index 2072c0312c..87d40f161b 100644 --- a/cli/src/commands/app/new.ts +++ b/cli/src/commands/app/new.ts @@ -168,7 +168,7 @@ const templates: Record = { "/index.css": indexCss, "/package.json": `{ "dependencies": { - "svelte": "5.45.2", + "svelte": "^5.55.5", "windmill-client": "^1" } }`, diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/add/templates.ts b/frontend/src/routes/(root)/(logged)/apps_raw/add/templates.ts index 71225f9ce1..eaac0f108f 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/add/templates.ts +++ b/frontend/src/routes/(root)/(logged)/apps_raw/add/templates.ts @@ -136,7 +136,7 @@ export const svelte5Template = { '/index.css': indexCss, '/package.json': `{ "dependencies": { - "svelte": "5.45.2", + "svelte": "^5.55.5", "windmill-client": "^1" } }` From 23af6c2ea31265a1898d0632e72cd2fd826e4044 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 8 May 2026 17:57:17 +0200 Subject: [PATCH 10/50] perf(flows): gate flow_env resolve on expr text and share cache with handle_flow (#9085) --- backend/windmill-worker/src/worker_flow.rs | 59 +++++++++++++++++----- 1 file changed, 45 insertions(+), 14 deletions(-) diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 91c6cf9ec7..53c94a7458 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -494,19 +494,34 @@ pub async fn update_flow_status_after_job_completion_internal( } // Resolve flow_env for predicate evaluations (stop_after_if, - // stop_after_all_iters_if, retry_if). Only fetch when one of these - // predicates is configured to avoid an extra DB query on the common - // path. `retry` without `retry_if` doesn't consult flow_env. - let retry_uses_flow_env = - |module: &FlowModule| module.retry.as_ref().is_some_and(|r| r.retry_if.is_some()); + // stop_after_all_iters_if, retry_if). Two-level gate: + // 1. Structural — at least one such predicate is configured. + // 2. Textual — the predicate expression actually references + // `flow_env`. Mirrors the existing `expr.contains("results.")` + // check in `get_id_ctx_for_expr`. A substring match has false + // positives (harmless — just runs an unneeded resolve) but no + // false negatives, since `flow_env` must appear textually for the + // expression engine to read it. + let expr_uses_flow_env = |expr: &str| expr.contains("flow_env"); + let retry_if_uses_flow_env = |module: &FlowModule| { + module + .retry + .as_ref() + .and_then(|r| r.retry_if.as_ref()) + .is_some_and(|r| expr_uses_flow_env(&r.expr)) + }; let needs_flow_env = current_module.is_some_and(|m| { - m.stop_after_if.is_some() - || m.stop_after_all_iters_if.is_some() - || retry_uses_flow_env(m) + m.stop_after_if + .as_ref() + .is_some_and(|s| expr_uses_flow_env(&s.expr)) + || m.stop_after_all_iters_if + .as_ref() + .is_some_and(|s| expr_uses_flow_env(&s.expr)) + || retry_if_uses_flow_env(m) }) || flow_value .failure_module .as_ref() - .is_some_and(|fm| retry_uses_flow_env(fm)); + .is_some_and(|fm| retry_if_uses_flow_env(fm)); // The resolved env is constant for a flow's lifetime, so cache it by flow // job id and reuse across child-step completions. Cache miss falls back to // the existing resolve+persist path; same-worker subsequent completions are @@ -2729,8 +2744,11 @@ pub async fn handle_flow( // Resolve $var: and $res: references in flow_env. // We resolve into a separate variable to avoid cloning the entire FlowValue // (which includes modules, failure_module, etc.) just to replace flow_env. + // `is_cacheable` is `false` only on a transient `transform_json` failure, + // matching `resolve_flow_env_for_status_update`'s contract — we must not + // freeze a partial resolution into the cache. let resolved_env; - let flow_env = if let Some(env) = env_source { + let (flow_env, is_cacheable) = if let Some(env) = env_source { match transform_json( client, &flow_job.workspace_id, @@ -2742,18 +2760,31 @@ pub async fn handle_flow( { Ok(Some(resolved)) => { resolved_env = resolved; - Some(&resolved_env) + (Some(&resolved_env), true) } - Ok(None) => Some(env), + Ok(None) => (Some(env), true), Err(e) => { tracing::warn!("Failed to resolve flow_env references: {e}"); - Some(env) + (Some(env), false) } } } else { - None + (None, true) }; + // Populate the per-flow resolved-env cache so subsequent predicate + // evaluations in `update_flow_status_after_job_completion_internal` skip + // the recursive CTE + transform_json. Costs one HashMap clone per + // sub-flow handle_flow entry; saves up to one CTE + one transform_json + // per flow execution that has predicates referencing flow_env. + if is_cacheable { + if let Some(env) = flow_env { + if !env.is_empty() { + RESOLVED_FLOW_ENV_CACHE.insert(flow_job.id, Arc::new(env.clone())); + } + } + } + let status = flow_job .parse_flow_status() .with_context(|| "Unable to parse flow status")?; From dd5320205f200dd058db2ff7d44d5c4bbcf25ec9 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 8 May 2026 17:57:34 +0200 Subject: [PATCH 11/50] feat: parse windmill_failure field to tag run as failure (#9073) * feat: parse windmill_failure field in job result to tag run as failure * feat: preserve top-level fields when windmill_failure tags a run as failure * fix: address review findings on windmill_manual_failure * refactor: rename windmill_manual_failure to wm_failure and add wm_* aliases * fix: prefer injected ManualFailure error over sibling name/message in OTel --- backend/windmill-api-jobs/src/execution.rs | 3 + backend/windmill-queue/src/jobs.rs | 156 +++++++++++++++--- .../windmill-worker/src/result_processor.rs | 135 ++++++++++++--- 3 files changed, 249 insertions(+), 45 deletions(-) diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 555d7a4d8b..e4359e19da 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -248,8 +248,11 @@ lazy_static::lazy_static! { #[derive(Deserialize)] pub struct WindmillCompositeResult { + #[serde(alias = "wm_status_code")] windmill_status_code: Option, + #[serde(alias = "wm_content_type")] windmill_content_type: Option, + #[serde(alias = "wm_headers")] windmill_headers: Option>, result: Option>, } diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 897f8e2ce0..52e6b09c6f 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -630,12 +630,45 @@ pub struct WrappedError { pub trait ValidableJson { fn is_valid_json(&self) -> bool; fn wm_labels(&self) -> Option>; + fn wm_failure(&self) -> Option; + fn result_metadata(&self) -> ResultMetadata; fn size(&self) -> usize; } -#[derive(serde::Deserialize)] -struct ResultLabels { - wm_labels: Vec, +/// The Windmill-specific markers we look for inside a job's result. +/// `wm_failure` retags a successful run as a failure with the +/// given message; `wm_labels` adds runtime labels to the job row. +#[derive(serde::Deserialize, Default, Debug, Clone)] +pub struct ResultMetadata { + pub wm_labels: Option>, + pub wm_failure: Option, +} + +/// Sentinel `error.name` we inject into a result when retagging a successful +/// run as a failure due to `wm_failure`. Used downstream to detect that +/// the result is already in the standard `{ error: { name, message }, ... }` +/// shape and must not be wrapped a second time by `WrappedError`. +pub const MANUAL_FAILURE_ERROR_NAME: &str = "ManualFailure"; + +/// Returns true when the result already carries our injected +/// `error: { name: "ManualFailure", ... }` marker — i.e. it was shaped by +/// `process_jc`'s wm_failure path. A real runtime failure whose raw +/// result happens to contain a `wm_failure` field but no such error +/// key returns false (and so still goes through the standard wrap path). +pub fn is_pre_shaped_wm_failure_result(result: &str) -> bool { + #[derive(serde::Deserialize)] + struct Marker { + error: Option, + } + #[derive(serde::Deserialize)] + struct NameOnly { + name: String, + } + serde_json::from_str::(result) + .ok() + .and_then(|m| m.error) + .map(|e| e.name == MANUAL_FAILURE_ERROR_NAME) + .unwrap_or(false) } impl ValidableJson for WrappedError { @@ -647,6 +680,14 @@ impl ValidableJson for WrappedError { None } + fn wm_failure(&self) -> Option { + None + } + + fn result_metadata(&self) -> ResultMetadata { + ResultMetadata::default() + } + fn size(&self) -> usize { 0 } @@ -658,9 +699,15 @@ impl ValidableJson for Box { } fn wm_labels(&self) -> Option> { - serde_json::from_str::(self.get()) - .ok() - .map(|r| r.wm_labels) + self.result_metadata().wm_labels + } + + fn wm_failure(&self) -> Option { + self.result_metadata().wm_failure + } + + fn result_metadata(&self) -> ResultMetadata { + serde_json::from_str::(self.get()).unwrap_or_default() } fn size(&self) -> usize { @@ -677,6 +724,14 @@ impl ValidableJson for Arc { T::wm_labels(&self) } + fn wm_failure(&self) -> Option { + T::wm_failure(&self) + } + + fn result_metadata(&self) -> ResultMetadata { + T::result_metadata(&self) + } + fn size(&self) -> usize { T::size(&self) } @@ -688,9 +743,15 @@ impl ValidableJson for serde_json::Value { } fn wm_labels(&self) -> Option> { - serde_json::from_value::(self.clone()) - .ok() - .map(|r| r.wm_labels) + self.result_metadata().wm_labels + } + + fn wm_failure(&self) -> Option { + self.result_metadata().wm_failure + } + + fn result_metadata(&self) -> ResultMetadata { + serde_json::from_value::(self.clone()).unwrap_or_default() } fn size(&self) -> usize { @@ -707,6 +768,14 @@ impl ValidableJson for Json { self.0.wm_labels() } + fn wm_failure(&self) -> Option { + self.0.wm_failure() + } + + fn result_metadata(&self) -> ResultMetadata { + self.0.result_metadata() + } + fn size(&self) -> usize { self.0.size() } @@ -742,16 +811,7 @@ where } } -pub async fn add_completed_job_error( - db: &Pool, - completed_job: &MiniCompletedJob, - mem_peak: i32, - canceled_by: Option, - e: serde_json::Value, - _worker_name: &str, - flow_is_done: bool, - duration: Option, -) -> Result { +async fn record_failure_metrics(completed_job: &MiniCompletedJob, _worker_name: &str) { #[cfg(feature = "prometheus")] register_metric( &WORKER_EXECUTION_FAILED, @@ -772,6 +832,64 @@ pub async fn add_completed_job_error( .await; otel_incr_worker_execution_failed(&completed_job.tag); +} + +/// Tag a completed job as a failure while storing the result as-is, without +/// the standard `WrappedError` `{ error: ... }` wrap. Use for jobs whose result +/// is already shaped (e.g. when `wm_failure` injected a top-level +/// `error` key, while preserving sibling fields like `windmill_status_code`). +/// +/// This is a worker-internal helper called by trusted result-processing code +/// after the worker has authenticated and pulled the job. Callers MUST verify +/// upstream auth (i.e. the job was legitimately pulled by this worker) — this +/// function performs no authorization check itself, mirroring the contract of +/// `add_completed_job_error`. +pub async fn add_completed_job_pre_shaped_failure( + db: &Pool, + completed_job: &MiniCompletedJob, + mem_peak: i32, + canceled_by: Option, + result: Json<&T>, + worker_name: &str, + flow_is_done: bool, + duration: Option, +) -> Result<(), Error> { + record_failure_metrics(completed_job, worker_name).await; + + tracing::error!( + "job {} in {} did not succeed (wm_failure)", + completed_job.id, + completed_job.workspace_id, + ); + let _ = add_completed_job( + db, + completed_job, + false, + false, + result, + None, + mem_peak, + canceled_by, + flow_is_done, + duration, + false, + ) + .warn_after_seconds(10) + .await?; + Ok(()) +} + +pub async fn add_completed_job_error( + db: &Pool, + completed_job: &MiniCompletedJob, + mem_peak: i32, + canceled_by: Option, + e: serde_json::Value, + worker_name: &str, + flow_is_done: bool, + duration: Option, +) -> Result { + record_failure_metrics(completed_job, worker_name).await; let result = WrappedError { error: e }; tracing::error!( diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 1f771f6879..9eac0a5dcd 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -35,8 +35,9 @@ use windmill_common::{ use windmill_common::bench::{BenchmarkInfo, BenchmarkIter}; use windmill_queue::{ - append_logs, get_mini_completed_job, CanceledBy, FlowRunners, JobCompleted, MiniCompletedJob, - MiniPulledJob, ValidableJson, WrappedError, INIT_SCRIPT_TAG, + append_logs, get_mini_completed_job, is_pre_shaped_wm_failure_result, CanceledBy, FlowRunners, + JobCompleted, MiniCompletedJob, MiniPulledJob, ValidableJson, WrappedError, INIT_SCRIPT_TAG, + MANUAL_FAILURE_ERROR_NAME, }; use serde_json::{json, value::RawValue, Value}; @@ -61,8 +62,37 @@ struct ErrorMessage { name: String, } +#[derive(Debug, Deserialize)] +struct NestedErrorMessage { + error: ErrorMessage, +} + +/// Extract `{ name, message }` from a result. Accepts both the standard +/// top-level shape (regular runtime errors) and the nested `{ error: { name, +/// message }, ... }` shape produced by the wm_failure injection. +/// +/// For wm_failure-injected results, we prefer the nested error: a successful +/// run may legitimately contain top-level `name`/`message` fields (user data +/// named `name`/`message`), and we want OTel to record the ManualFailure +/// rather than the user's sibling fields. +fn extract_error_message(raw: &str) -> Option { + let nested = serde_json::from_str::(raw) + .ok() + .map(|n| n.error); + if matches!(&nested, Some(em) if em.name == MANUAL_FAILURE_ERROR_NAME) { + return nested; + } + if let Ok(em) = serde_json::from_str::(raw) { + return Some(em); + } + nested +} + +/// Returns the post-processing `success` value (after any `wm_failure` +/// override). Callers use this to make worker-loop decisions that depend on +/// whether the job ultimately succeeded — e.g. the init-script killpill. async fn process_jc( - jc: JobCompleted, + mut jc: JobCompleted, worker_name: &str, base_internal_url: &str, db: &DB, @@ -73,7 +103,32 @@ async fn process_jc( killpill_rx: &tokio::sync::broadcast::Receiver<()>, #[cfg(feature = "benchmark")] bench: &mut BenchmarkIter, #[cfg(feature = "benchmark")] bench_infos: &mut BenchmarkInfo, -) { +) -> bool { + // Parse `wm_labels` and `wm_failure` together (single `from_str`) + // so we don't deserialize the whole result twice on every job. + let metadata = jc.result.result_metadata(); + + // If the script returned a `wm_failure: ` field in its + // result, tag the run as a failure. Inject an `error: { name, message }` + // at the top level so error handlers / UI / OTel see the standard error + // shape, while preserving sibling fields (`windmill_status_code`, + // `windmill_content_type`, `windmill_headers`, the user's data) at the + // top level so sync webhook responses still honor them. + if jc.success { + if let Some(failure_msg) = metadata.wm_failure.as_ref() { + if let Ok(Value::Object(mut map)) = serde_json::from_str::(jc.result.get()) { + map.insert( + "error".to_string(), + json!({ "name": MANUAL_FAILURE_ERROR_NAME, "message": failure_msg }), + ); + if let Ok(raw) = serde_json::value::to_raw_value(&Value::Object(map)) { + jc.result = Arc::new(raw); + } + } + jc.success = false; + } + } + let success: bool = jc.success; let span = if success { @@ -125,7 +180,7 @@ async fn process_jc( jc.job.id }; - if let Some(labels) = jc.result.wm_labels() { + if let Some(labels) = metadata.wm_labels.as_ref() { if !labels.is_empty() { span.record("labels", labels.join(",")); } @@ -163,7 +218,7 @@ async fn process_jc( span.record("script_hash", script_hash.to_string().as_str()); } if !success { - if let Ok(result_error) = serde_json::from_str::(jc.result.get()) { + if let Some(result_error) = extract_error_message(jc.result.get()) { span.record("error.message", result_error.message.as_str()); span.record("error.name", result_error.name.as_str()); span.record( @@ -218,6 +273,8 @@ async fn process_jc( ) .await; } + + success } enum JobCompletedRx { @@ -311,8 +368,7 @@ pub fn start_background_processor( result: SendResultPayload::JobCompleted(jc), time, }) => { - let is_init_script_and_failure = - !jc.success && jc.job.tag.as_str() == INIT_SCRIPT_TAG; + let is_init_script = jc.job.tag.as_str() == INIT_SCRIPT_TAG; let is_dependency_job = matches!( jc.job.kind, JobKind::Dependencies | JobKind::FlowDependencies @@ -322,7 +378,10 @@ pub fn start_background_processor( #[cfg(feature = "benchmark")] let is_top_level_job = jc.job.parent_job.is_none(); - process_jc( + // process_jc returns the post-override success value so a + // job that flipped to failure via `wm_failure` still + // triggers the init-script killpill. + let final_success = process_jc( jc, &worker_name, &base_internal_url, @@ -340,7 +399,7 @@ pub fn start_background_processor( .warn_after_seconds(10) .await; - if is_init_script_and_failure { + if is_init_script && !final_success { tracing::error!("init script errored, exiting"); killpill_tx.send(); break; @@ -793,19 +852,44 @@ pub async fn process_completed_job( } } } else { - let result = add_completed_job_error( - db, - &job, - mem_peak.to_owned(), - canceled_by.clone(), - serde_json::from_str(result.get()).unwrap_or_else( - |_| json!({ "message": format!("Non serializable error: {}", result.get()) }), - ), - worker_name, - false, - None, - ) - .await?; + // The result already carries our injected + // `error: { name: "ManualFailure", ... }` marker when process_jc + // retagged a successful run as a failure — store it as-is to preserve + // sibling fields like `windmill_status_code`. We check for the + // injected marker specifically (not just the presence of a + // `wm_failure` field) so a real runtime failure whose raw + // result happens to contain a `wm_failure` field still goes + // through the standard `WrappedError { error: ... }` wrap path. + let downstream_result: Arc> = if is_pre_shaped_wm_failure_result(result.get()) + { + windmill_queue::add_completed_job_pre_shaped_failure( + db, + &job, + mem_peak.to_owned(), + canceled_by.clone(), + Json(&*result), + worker_name, + false, + None, + ) + .await?; + result.clone() + } else { + let wrapped = add_completed_job_error( + db, + &job, + mem_peak.to_owned(), + canceled_by.clone(), + serde_json::from_str(result.get()).unwrap_or_else( + |_| json!({ "message": format!("Non serializable error: {}", result.get()) }), + ), + worker_name, + false, + None, + ) + .await?; + Arc::new(serde_json::value::to_raw_value(&wrapped).unwrap()) + }; if job.is_flow_step() { if let Some(parent_job) = job.parent_job { tracing::error!(parent_flow = %parent_job, subflow = %job.id, "process completed job error, updating flow status"); @@ -817,7 +901,7 @@ pub async fn process_completed_job( &job.workspace_id, false, canceled_by, - Arc::new(serde_json::value::to_raw_value(&result).unwrap()), + downstream_result, duration.and_then(|d| { job.started_at.map(|started_at| FlowJobDuration { started_at: started_at, @@ -855,13 +939,12 @@ pub async fn process_completed_job( .fetch_optional(db) .await?; if let Some(Some(job_ids)) = job_ids_json { - let err_result = Arc::new(serde_json::value::to_raw_value(&result).unwrap()); if let Ok(Some(_)) = handle_wac_child_completion( db, &job.id, parent_job, &job.workspace_id, - err_result, + downstream_result, false, job_ids, ) From 935c666d50ef30d89a3669c76094af7506fbb448 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 8 May 2026 18:56:42 +0200 Subject: [PATCH 12/50] fix: hide _ENTRYPOINT_OVERRIDE jobs from script/flow history panel (#9088) --- backend/windmill-api-inputs/src/lib.rs | 1 + backend/windmill-api-jobs/src/concurrency_groups.rs | 1 + backend/windmill-api-jobs/src/query.rs | 10 ++++++++++ backend/windmill-api-jobs/src/types.rs | 5 +++++ backend/windmill-api/openapi.yaml | 5 +++++ frontend/src/lib/components/HistoricInputs.svelte | 3 ++- .../src/lib/components/runs/useJobsLoader.svelte.ts | 5 ++++- 7 files changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/windmill-api-inputs/src/lib.rs b/backend/windmill-api-inputs/src/lib.rs index 154e02e5a9..348b434eea 100644 --- a/backend/windmill-api-inputs/src/lib.rs +++ b/backend/windmill-api-inputs/src/lib.rs @@ -172,6 +172,7 @@ async fn get_input_history( kind IN ('preview', 'flowpreview') as is_preview \ FROM v2_job JOIN v2_job_completed USING (id) \ WHERE v2_job.workspace_id = $3 AND {} = $1 AND kind = any($2) \ + AND v2_job.script_entrypoint_override IS NULL \ {args_query} AND v2_job_completed.status != 'skipped' {include_non_root} \ ORDER BY v2_job.created_at DESC LIMIT $4\ ) t ORDER BY completed_at DESC LIMIT $5 OFFSET $6", diff --git a/backend/windmill-api-jobs/src/concurrency_groups.rs b/backend/windmill-api-jobs/src/concurrency_groups.rs index f3ef3b14a8..d8e7a54112 100644 --- a/backend/windmill-api-jobs/src/concurrency_groups.rs +++ b/backend/windmill-api-jobs/src/concurrency_groups.rs @@ -226,6 +226,7 @@ async fn get_concurrent_intervals( trigger_kind: _, include_args: _, broad_filter: _, + excludes_entrypoint_override: _, } => true, _ => false, }; diff --git a/backend/windmill-api-jobs/src/query.rs b/backend/windmill-api-jobs/src/query.rs index 38e139b77b..e6c57e6c5b 100644 --- a/backend/windmill-api-jobs/src/query.rs +++ b/backend/windmill-api-jobs/src/query.rs @@ -216,6 +216,10 @@ pub fn filter_list_queue_query( sqlb.and_where("trigger_kind IS DISTINCT FROM 'schedule'"); } + if lq.excludes_entrypoint_override.unwrap_or(false) { + sqlb.and_where_is_null("v2_job.script_entrypoint_override"); + } + if let Some(tk) = &lq.trigger_kind { let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect(); if tk.negated { @@ -519,6 +523,10 @@ pub fn filter_list_completed_query( sqlb.and_where("trigger_kind IS DISTINCT FROM 'schedule'"); } + if lq.excludes_entrypoint_override.unwrap_or(false) { + sqlb.and_where_is_null("v2_job.script_entrypoint_override"); + } + if let Some(tk) = &lq.trigger_kind { let quoted: Vec<_> = tk.values.iter().map(|v| quote(&format!("{}", v))).collect(); if tk.negated { @@ -624,6 +632,7 @@ mod tests { trigger_path: None, include_args: None, broad_filter: None, + excludes_entrypoint_override: None, } } @@ -668,6 +677,7 @@ mod tests { trigger_path: None, include_args: None, broad_filter: None, + excludes_entrypoint_override: None, } } diff --git a/backend/windmill-api-jobs/src/types.rs b/backend/windmill-api-jobs/src/types.rs index e07328e2be..bc03a04289 100644 --- a/backend/windmill-api-jobs/src/types.rs +++ b/backend/windmill-api-jobs/src/types.rs @@ -122,6 +122,7 @@ pub struct ListQueueQuery { pub trigger_path: Option>, pub include_args: Option, pub broad_filter: Option, + pub excludes_entrypoint_override: Option, } #[derive(Deserialize, Clone)] @@ -167,6 +168,7 @@ pub struct ListCompletedQuery { pub trigger_path: Option>, pub include_args: Option, pub broad_filter: Option, + pub excludes_entrypoint_override: Option, } impl From for ListQueueQuery { @@ -202,6 +204,7 @@ impl From for ListQueueQuery { trigger_path: lcq.trigger_path, include_args: lcq.include_args, broad_filter: lcq.broad_filter, + excludes_entrypoint_override: lcq.excludes_entrypoint_override, } } } @@ -704,6 +707,7 @@ mod tests { trigger_path: None, include_args: None, broad_filter: None, + excludes_entrypoint_override: None, }; let lqq: ListQueueQuery = lcq.into(); @@ -772,6 +776,7 @@ mod tests { trigger_path: None, include_args: None, broad_filter: None, + excludes_entrypoint_override: None, }; let lqq: ListQueueQuery = lcq.into(); diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0c8d63298e..7cb96a3e29 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -12000,6 +12000,11 @@ paths: in: query schema: type: boolean + - name: excludes_entrypoint_override + description: exclude jobs that were started with a `_ENTRYPOINT_OVERRIDE` arg (e.g. dynamic-select helper runs and preprocessor previews) + in: query + schema: + type: boolean - name: broad_filter description: broad search across multiple fields (case-insensitive substring match on path, tag, schedule path, trigger kind, label) in: query diff --git a/frontend/src/lib/components/HistoricInputs.svelte b/frontend/src/lib/components/HistoricInputs.svelte index ca01013e0a..3c784f354f 100644 --- a/frontend/src/lib/components/HistoricInputs.svelte +++ b/frontend/src/lib/components/HistoricInputs.svelte @@ -112,7 +112,8 @@ syncQueuedRunsCount: false, refreshRate: 10000, currentWorkspace: $workspaceStore ?? '', - skip: !runnableId + skip: !runnableId, + excludesEntrypointOverride: true }) satisfies UseJobLoaderArgs ) let jobs = $derived(jobsLoader?.jobs ?? []) diff --git a/frontend/src/lib/components/runs/useJobsLoader.svelte.ts b/frontend/src/lib/components/runs/useJobsLoader.svelte.ts index b75355a6cb..e7d8337708 100644 --- a/frontend/src/lib/components/runs/useJobsLoader.svelte.ts +++ b/frontend/src/lib/components/runs/useJobsLoader.svelte.ts @@ -53,6 +53,7 @@ export interface UseJobLoaderArgs { skip?: boolean lookback?: number perPage?: number + excludesEntrypointOverride?: boolean } export function useJobsLoader(args: () => UseJobLoaderArgs) { @@ -69,6 +70,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { let lookback = $derived(_args.lookback ?? 0) let timeframe = $derived(_args?.timeframe) let perPage = $derived(_args?.perPage ?? 1000) + let excludesEntrypointOverride = $derived(_args.excludesEntrypointOverride ?? false) let label = $derived(filters?.label ?? null) let worker = $derived(filters?.worker ?? null) @@ -274,7 +276,8 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) { allWorkspaces: allWorkspaces ? true : undefined, perPage: perPageOverride ?? perPage, allowWildcards: allowWildcards ? true : undefined, - broadFilter + broadFilter, + excludesEntrypointOverride: excludesEntrypointOverride ? true : undefined }) promise = CancelablePromiseUtils.catchErr(promise, (e) => { if (e instanceof CancelError) return CancelablePromiseUtils.err(e) From f37d3606446d23f8b11a94ea1ce5f5d4836fae17 Mon Sep 17 00:00:00 2001 From: Arnaud <31803803+Araden14@users.noreply.github.com> Date: Fri, 8 May 2026 18:57:36 +0200 Subject: [PATCH 13/50] fix(flows): populate error handler input args from failure picker (#9087) * fix(flows): populate error handler input args from failure picker * style(flows): fix indentation in failure-step branch --- .../lib/components/flows/stepsInputArgs.svelte.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/flows/stepsInputArgs.svelte.ts b/frontend/src/lib/components/flows/stepsInputArgs.svelte.ts index 025bca1bfe..06a4972ee5 100644 --- a/frontend/src/lib/components/flows/stepsInputArgs.svelte.ts +++ b/frontend/src/lib/components/flows/stepsInputArgs.svelte.ts @@ -4,10 +4,10 @@ import { dfs, getPreviousModule, getStepPropPicker, - type PickableProperties + type PickableProperties, + getFailureStepPropPicker } from './previousResults' import { evalValue } from './utils.svelte' - export class StepsInputArgs { #stepsEvaluated = $state>>({}) #steps = $state>>({}) @@ -158,6 +158,15 @@ export class StepsInputArgs { flow: OpenFlow | undefined, previewArgs: Record | undefined ) { + if (id === 'failure' && flow && flow.value.failure_module && flowState) { + const picker = getFailureStepPropPicker(flowState, flow, previewArgs) + this.initializeFromSchema( + flow.value.failure_module, + flowState['failure']?.schema ?? {}, + picker.pickableProperties + ) + return + } if (!flowState || !flow) { return } From 98ff146cfabf45418c95c027ad6d07b08069cfcd Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 8 May 2026 19:56:51 +0200 Subject: [PATCH 14/50] fix(python): verify wheel RECORD on cache pull/install, finalize piptar (#9090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Python per-package dependency cache could persist an incomplete wheel extraction with `.valid.windmill` set, then propagate that broken artifact to every worker through the object store. Customer hit this on argon2-cffi==25.1.0 (missing argon2/_utils.py), and previously on botocore/httpx (truncated tars). Symptom is a runtime ImportError that looks like a missing dependency declaration rather than a Windmill bug. Three changes that together stop the propagation: 1. After `pull_from_tar`, parse the wheel's `/RECORD` and confirm every listed path exists on disk before writing `.valid.windmill`. On failure, wipe the directory and fall through to a fresh local install — the next install also self-heals the broken object-store entry by pushing a fresh tar. 2. After `uv pip install` succeeds, run the same RECORD check before queuing the piptar upload or writing `.valid.windmill`. A bad install never becomes the source of a broken tar in the object store. 3. Finalize the tar (`drop(tar.into_inner()?)`) before reading its bytes for upload, so we never push an unfinalized archive (no end-of-archive marker) to the object store. Verified with a 60-package end-to-end integration test (first-fill → clear-local-cache → re-pull-from-objectstore → corrupt-objectstore-tar → detect-and-self-heal). All 27 packages on the live test pulled cleanly, and the deliberately corrupted argon2-cffi tar was caught with the exact expected log line ("wheel RECORD lists files missing on disk: argon2/_utils.py") and replaced with a fresh tar. Co-authored-by: Claude Opus 4.7 (1M context) --- backend/windmill-worker/src/global_cache.rs | 4 + .../windmill-worker/src/python_executor.rs | 135 ++++++++++++++++++ 2 files changed, 139 insertions(+) diff --git a/backend/windmill-worker/src/global_cache.rs b/backend/windmill-worker/src/global_cache.rs index 23375746fd..67a8484b82 100644 --- a/backend/windmill-worker/src/global_cache.rs +++ b/backend/windmill-worker/src/global_cache.rs @@ -40,6 +40,9 @@ pub async fn build_tar_and_push( let tar_file = std::fs::File::create(&tar_path)?; let mut tar = tar::Builder::new(tar_file); tar.append_dir_all(".", &folder)?; + // Write the trailing zero blocks and close the inner file BEFORE std::fs::read + // below. Without this, the bytes we upload to S3 are an unfinalized archive. + drop(tar.into_inner()?); let tar_metadata = tokio::fs::metadata(&tar_path).await; if tar_metadata.is_err() || tar_metadata.as_ref().unwrap().len() == 0 { @@ -231,6 +234,7 @@ pub async fn save_cache( let tar_file = std::fs::File::create(&tar_path)?; let mut tar = tar::Builder::new(tar_file); tar.append_dir_all(".", &origin)?; + drop(tar.into_inner()?); let tar_metadata = tokio::fs::metadata(&tar_path).await; if tar_metadata.is_err() || tar_metadata.as_ref().unwrap().len() == 0 { tracing::info!("Failed to tar cache: {origin}"); diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 5fb1278976..4db7b20fd5 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -2113,6 +2113,84 @@ async fn spawn_uv_install( } } +/// Verify that every file listed in the wheel's RECORD exists on disk under +/// `venv_p`. Used as a structural integrity check after both a successful +/// `pull_from_tar` (object-store cache hit) and a successful local +/// `uv pip install`, so a truncated tar or a dropped wheel entry can never +/// become an authoritative cache entry. Returns Err with a short description +/// on the first integrity issue (no .dist-info, no RECORD, or any listed +/// path missing on disk). +async fn verify_wheel_record(venv_p: &str) -> Result<(), String> { + let mut entries = tokio::fs::read_dir(venv_p) + .await + .map_err(|e| format!("read_dir({venv_p}): {e}"))?; + + let mut dist_info: Option = None; + loop { + match entries.next_entry().await { + Ok(Some(entry)) => { + let name = entry.file_name(); + let name_s = name.to_string_lossy(); + if name_s.ends_with(".dist-info") { + if let Ok(ft) = entry.file_type().await { + if ft.is_dir() { + dist_info = Some(name_s.into_owned()); + break; + } + } + } + } + Ok(None) => break, + Err(e) => return Err(format!("read_dir entry in {venv_p}: {e}")), + } + } + + let dist_info = match dist_info { + Some(d) => d, + None => return Err(format!("no .dist-info directory in {venv_p}")), + }; + + let record_path = format!("{venv_p}/{dist_info}/RECORD"); + let record_content = tokio::fs::read_to_string(&record_path) + .await + .map_err(|e| format!("read RECORD at {record_path}: {e}"))?; + + let mut missing: Vec = Vec::new(); + for line in record_content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let rel_path = match trimmed.split(',').next() { + Some(p) if !p.is_empty() => p, + _ => continue, + }; + // Defensive: skip absolute paths or escaping entries — we only + // validate package-relative files. + if rel_path.starts_with('/') || rel_path.contains("..") { + continue; + } + let full = format!("{venv_p}/{rel_path}"); + if tokio::fs::metadata(&full).await.is_err() { + missing.push(rel_path.to_string()); + // Bound error size in pathological cases (e.g. wholly empty dir). + if missing.len() >= 10 { + missing.push("...".to_string()); + break; + } + } + } + + if missing.is_empty() { + Ok(()) + } else { + Err(format!( + "wheel RECORD lists files missing on disk: {}", + missing.join(", ") + )) + } +} + /// uv pip install, include cached or pull from S3 pub async fn handle_python_reqs( requirements: Vec, @@ -2484,6 +2562,30 @@ pub async fn handle_python_reqs( workspace_id = %w_id, "No tarball was found for {venv_p} on S3 or different problem occurred {job_id}:\n{e}", ); + } else if let Err(verify_err) = verify_wheel_record(&venv_p).await { + // The object-store tar extracted cleanly but the resulting + // directory is missing files referenced by the wheel RECORD. + // Wipe the broken cache entry and fall through to a fresh + // local install rather than treating it as authoritative. + tracing::warn!( + workspace_id = %w_id, + job_id = %job_id, + "Object-store cache for {venv_p} failed wheel RECORD verification, will reinstall locally: {verify_err}" + ); + if let Err(rm_err) = tokio::fs::remove_dir_all(&venv_p).await { + tracing::warn!( + workspace_id = %w_id, + "could not remove broken cache dir {venv_p}: {rm_err}" + ); + } + append_logs( + &job_id, + &w_id, + format!( + "\n[!] cached wheel for {req} from object store failed integrity check, reinstalling: {verify_err}\n" + ), + &conn, + ).await; } else { print_success( true, @@ -2640,6 +2742,39 @@ pub async fn handle_python_reqs( let _ = std::fs::remove_file(format!("{job_dir}/{req}.config.proto")); } + // Verify the install before declaring success: if uv exited 0 but + // the on-disk directory is missing files the wheel RECORD says + // should exist, do NOT write .valid.windmill, do NOT queue the + // piptar upload, and fail the job. This prevents a broken tar + // from ever being pushed to the object store and propagated to + // every other replica. + if let Err(verify_err) = verify_wheel_record(&venv_p).await { + tracing::error!( + workspace_id = %w_id, + job_id = %job_id, + "uv pip install of {req} into {venv_p} failed wheel RECORD verification: {verify_err}" + ); + append_logs( + &job_id, + &w_id, + format!( + "\nWheel RECORD verification failed after install of {req}: {verify_err}. \ + Aborting to avoid publishing a corrupt cache entry." + ), + &conn, + ).await; + if let Err(rm_err) = tokio::fs::remove_dir_all(&venv_p).await { + tracing::warn!( + workspace_id = %w_id, + "could not remove broken install dir {venv_p}: {rm_err}" + ); + } + pids.lock().await.get_mut(i).and_then(|e| e.take()); + return Err(Error::from(anyhow!( + "wheel RECORD verification failed after install of {req}" + ))); + } + print_success( false, s3_push, From 91ddb930c3360d5c06156f9f14da60cebd1949e7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 8 May 2026 20:03:06 +0200 Subject: [PATCH 15/50] chore(main): release 1.699.0 (#9082) * chore(main): release 1.699.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 22 +++ backend/Cargo.lock | 160 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 142 insertions(+), 120 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1034bc4f87..98cdc3b50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [1.699.0](https://github.com/windmill-labs/windmill/compare/v1.698.0...v1.699.0) (2026-05-08) + + +### Features + +* parse windmill_failure field to tag run as failure ([#9073](https://github.com/windmill-labs/windmill/issues/9073)) ([dd53202](https://github.com/windmill-labs/windmill/commit/dd5320205f200dd058db2ff7d44d5c4bbcf25ec9)) + + +### Bug Fixes + +* **cli:** bump svelte version in `wmill app new` template ([#9084](https://github.com/windmill-labs/windmill/issues/9084)) ([4b4aa0e](https://github.com/windmill-labs/windmill/commit/4b4aa0e303f9c47c4f931511977107f42f93abc3)) +* **flows:** populate error handler input args from failure picker ([#9087](https://github.com/windmill-labs/windmill/issues/9087)) ([f37d360](https://github.com/windmill-labs/windmill/commit/f37d3606446d23f8b11a94ea1ce5f5d4836fae17)) +* hide _ENTRYPOINT_OVERRIDE jobs from script/flow history panel ([#9088](https://github.com/windmill-labs/windmill/issues/9088)) ([935c666](https://github.com/windmill-labs/windmill/commit/935c666d50ef30d89a3669c76094af7506fbb448)) +* **native-triggers:** serialize Google channel renewal across replicas ([#9060](https://github.com/windmill-labs/windmill/issues/9060)) ([ee3d82f](https://github.com/windmill-labs/windmill/commit/ee3d82f01f52d835218f544dad6de9b7c3184fbb)) +* **python:** verify wheel RECORD on cache pull/install, finalize piptar ([#9090](https://github.com/windmill-labs/windmill/issues/9090)) ([98ff146](https://github.com/windmill-labs/windmill/commit/98ff146cfabf45418c95c027ad6d07b08069cfcd)) +* reject root-rooted paths in ansible playbook validator on windows ([#9081](https://github.com/windmill-labs/windmill/issues/9081)) ([d37277d](https://github.com/windmill-labs/windmill/commit/d37277d2341c83faf72efa0035cbf70e2cfbd596)) + + +### Performance Improvements + +* **flows:** gate flow_env resolve on expr text and share cache with handle_flow ([#9085](https://github.com/windmill-labs/windmill/issues/9085)) ([23af6c2](https://github.com/windmill-labs/windmill/commit/23af6c2ea31265a1898d0632e72cd2fd826e4044)) + ## [1.698.0](https://github.com/windmill-labs/windmill/compare/v1.697.0...v1.698.0) (2026-05-08) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 47aff74297..6952682391 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2213,9 +2213,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -16025,7 +16025,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-nats", @@ -16106,7 +16106,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.698.0" +version = "1.699.0" dependencies = [ "async-trait", "aws-config", @@ -16130,7 +16130,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16143,7 +16143,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "argon2", @@ -16286,7 +16286,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16309,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16322,7 +16322,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16348,7 +16348,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.698.0" +version = "1.699.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16358,7 +16358,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16375,7 +16375,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "base64 0.22.1", @@ -16397,7 +16397,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16420,7 +16420,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16436,7 +16436,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16457,7 +16457,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16478,7 +16478,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16492,7 +16492,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-nats", @@ -16524,7 +16524,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16549,7 +16549,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "flate2", @@ -16567,7 +16567,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16589,7 +16589,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16609,7 +16609,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16639,7 +16639,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16667,7 +16667,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.698.0" +version = "1.699.0" dependencies = [ "lazy_static", "serde", @@ -16679,7 +16679,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.698.0" +version = "1.699.0" dependencies = [ "argon2", "axum 0.8.4", @@ -16704,7 +16704,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16718,7 +16718,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.698.0" +version = "1.699.0" dependencies = [ "axum 0.8.4", "chrono", @@ -16751,7 +16751,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.698.0" +version = "1.699.0" dependencies = [ "chrono", "lazy_static", @@ -16765,7 +16765,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "axum 0.8.4", @@ -16784,7 +16784,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.698.0" +version = "1.699.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16885,7 +16885,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.698.0" +version = "1.699.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16904,7 +16904,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.698.0" +version = "1.699.0" dependencies = [ "regex", "serde", @@ -16919,7 +16919,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16943,7 +16943,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "futures", @@ -16960,7 +16960,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.698.0" +version = "1.699.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16976,7 +16976,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -16997,7 +16997,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17028,7 +17028,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "arc-swap", @@ -17053,7 +17053,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-stream", @@ -17087,7 +17087,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "futures", @@ -17105,7 +17105,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.698.0" +version = "1.699.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -17114,7 +17114,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -17126,7 +17126,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde_json", @@ -17138,7 +17138,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "gosyn", @@ -17150,7 +17150,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -17162,7 +17162,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde_json", @@ -17174,7 +17174,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "nu-parser", @@ -17185,7 +17185,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17196,7 +17196,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -17208,7 +17208,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17219,7 +17219,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-recursion", @@ -17241,7 +17241,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde_json", @@ -17253,7 +17253,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -17267,7 +17267,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -17284,7 +17284,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -17297,7 +17297,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde", @@ -17309,7 +17309,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -17327,7 +17327,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -17343,7 +17343,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17359,7 +17359,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde", @@ -17370,7 +17370,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-recursion", @@ -17407,7 +17407,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "const_format", @@ -17445,7 +17445,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.698.0" +version = "1.699.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17456,7 +17456,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-recursion", @@ -17486,7 +17486,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17510,7 +17510,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17543,7 +17543,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17576,7 +17576,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17596,7 +17596,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17630,7 +17630,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17666,7 +17666,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17689,7 +17689,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17713,7 +17713,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-nats", @@ -17737,7 +17737,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17772,7 +17772,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17800,7 +17800,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-trait", @@ -17823,7 +17823,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17842,7 +17842,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-once-cell", @@ -17955,7 +17955,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.698.0" +version = "1.699.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1a1b1f09d0..042179683d 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.698.0" +version = "1.699.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.698.0" +version = "1.699.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 0bc102cc48..d337f4838d 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.698.0" +version = "1.699.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.698.0" +version = "1.699.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.698.0" +version = "1.699.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.698.0" +version = "1.699.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 43a8bd59ef..80d8c63983 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.698.0" +version = "1.699.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 7cb96a3e29..c6a7de2601 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.698.0 + version: 1.699.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index a20c1b18af..3d7b371b24 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.698.0"; +export const VERSION = "v1.699.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index 9650032679..372087c69a 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -79,7 +79,7 @@ export { token, }; -export const VERSION = "1.698.0"; +export const VERSION = "1.699.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c616a8e212..0ce5dc49ef 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.698.0", + "version": "1.699.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.698.0", + "version": "1.699.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index e6d623791f..82c7ad8fa9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.698.0", + "version": "1.699.0", "scripts": { "dev": "vite dev", "build": "vite build", diff --git a/lsp/Pipfile b/lsp/Pipfile index efe961555a..3e38043b9e 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.698.0" +wmill = ">=1.699.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index d7e823897c..824243b2af 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.698.0 + version: 1.699.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 2e25edb52d..244a2e0d54 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.698.0' + ModuleVersion = '1.699.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index d1ac989649..5aa17d46c3 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.698.0" +version = "1.699.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 07b5df0602..884ac84799 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.698.0", + "version": "1.699.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 566e4bd4e4..ec86e6d07d 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.698.0", + "version": "1.699.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 7185d214b0..dc6966fffd 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.698.0 +1.699.0 From 43b18006f32fd5db54bbf8ae7ff0e0b314a517e5 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sat, 9 May 2026 10:38:47 +0200 Subject: [PATCH 16/50] feat(cli): auto-infer args for `wmill app push` (#9091) Run `wmill app push` from inside an app folder (e.g. `f/foo/my_app.app/`) with no args. The local path defaults to CWD, and the remote path is derived from CWD relative to `wmill.yaml`, with `.app`/`.raw_app`/ `__app`/`__raw_app` suffixes stripped. Either, both, or neither positional argument can be passed. Also resolves `file_path` against the user's original CWD before `resolveWorkspace` may chdir to the wmill.yaml root, so a relative `file_path` argument is interpreted from where the user invoked the command (previously it could resolve against the wrong directory). Co-authored-by: Claude Opus 4.7 (1M context) --- cli/src/commands/app/app.ts | 131 +++++++++++++++--- cli/src/guidance/skills.gen.ts | 2 +- .../auto-generated/cli/cli-commands.md | 2 +- system_prompts/auto-generated/prompts.ts | 2 +- .../skills/cli-commands/SKILL.md | 2 +- 5 files changed, 117 insertions(+), 22 deletions(-) diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index f99df84636..2749c0269c 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -4,7 +4,7 @@ import { Command } from "@cliffy/command"; import { Table } from "@cliffy/table"; import { colors } from "@cliffy/ansi/colors"; import * as log from "../../core/log.ts"; -import { sep as SEP } from "node:path"; +import { sep as SEP, isAbsolute, resolve as pathResolve, relative as pathRelative, basename } from "node:path"; import { stat } from "node:fs/promises"; import * as windmillUtils from "@windmill-labs/shared-utils"; import { yamlParseFile } from "../../utils/yaml.ts"; @@ -12,6 +12,7 @@ import * as wmill from "../../../gen/services.gen.ts"; import { ListableApp, Policy } from "../../../gen/types.gen.ts"; import { GlobalOptions, isSuperset } from "../../types.ts"; +import { getWmillYamlPath } from "../../core/conf.ts"; import { readInlinePathSync } from "../../utils/utils.ts"; import devCommand from "./dev.ts"; import lintCommand from "./lint.ts"; @@ -261,31 +262,122 @@ async function get(opts: GlobalOptions & { json?: boolean }, path: string) { } } -async function push(opts: GlobalOptions, filePath: string, remotePath: string) { - if (!validatePath(remotePath)) { - return; - } +const APP_FOLDER_SUFFIXES = ["__raw_app", ".raw_app", "__app", ".app"] as const; + +async function push( + opts: GlobalOptions, + filePath?: string, + remotePath?: string +) { + // Capture original CWD before resolveWorkspace, which may chdir to the + // wmill.yaml root. We need it to resolve relative inputs and to derive + // the remote path from the user's location when auto-inferring. + const originalCwd = process.cwd(); + const workspace = await resolveWorkspace(opts); await requireLogin(opts); - // Detect raw apps by checking for raw_app.yaml or __raw_app/.raw_app suffix - const normalizedPath = filePath.endsWith(SEP) ? filePath.slice(0, -1) : filePath; - const isRawApp = normalizedPath.endsWith("__raw_app") || normalizedPath.endsWith(".raw_app"); + // Auto-infer file path from CWD when omitted + if (!filePath) { + filePath = originalCwd; + } + const absoluteFilePath = isAbsolute(filePath) + ? filePath + : pathResolve(originalCwd, filePath); + + // Detect app folder type (regular vs raw) + const normalizedPath = absoluteFilePath.endsWith(SEP) + ? absoluteFilePath.slice(0, -1) + : absoluteFilePath; + const dirName = basename(normalizedPath); + const isRawAppByName = + dirName.endsWith("__raw_app") || dirName.endsWith(".raw_app"); + const isAppByName = dirName.endsWith("__app") || dirName.endsWith(".app"); + let hasRawAppYaml = false; - if (!isRawApp) { + let hasAppYaml = false; + try { + await stat(normalizedPath + SEP + "raw_app.yaml"); + hasRawAppYaml = true; + } catch { /* not a raw app */ } + if (!hasRawAppYaml) { try { - const rawAppPath = (filePath.endsWith(SEP) ? filePath : filePath + SEP) + "raw_app.yaml"; - await stat(rawAppPath); - hasRawAppYaml = true; - } catch { /* not a raw app */ } + await stat(normalizedPath + SEP + "app.yaml"); + hasAppYaml = true; + } catch { /* not an app */ } } - if (isRawApp || hasRawAppYaml) { + if (!isRawAppByName && !isAppByName && !hasRawAppYaml && !hasAppYaml) { + log.error( + colors.red( + `'${filePath}' is not an app folder (no app.yaml or raw_app.yaml, and not a *.app/*.raw_app folder).` + ) + ); + return; + } + + // Auto-infer remote path from the folder location relative to wmill.yaml root + if (!remotePath) { + const wmillYamlPath = getWmillYamlPath(); + if (!wmillYamlPath) { + log.error( + colors.red( + "Could not infer remote path: no wmill.yaml found. Run 'wmill init' or pass explicitly." + ) + ); + return; + } + // After resolveWorkspace, process.cwd() is the wmill.yaml dir + const wmillRoot = process.cwd(); + let inferred = pathRelative(wmillRoot, normalizedPath).replaceAll(SEP, "/"); + if (inferred.startsWith("..") || isAbsolute(inferred)) { + log.error( + colors.red( + `Could not infer remote path: '${filePath}' is outside the wmill.yaml root (${wmillRoot}). Move the folder under the root or pass explicitly.` + ) + ); + return; + } + for (const suffix of APP_FOLDER_SUFFIXES) { + if (inferred.endsWith(suffix)) { + inferred = inferred.slice(0, -suffix.length); + break; + } + } + if (!inferred) { + log.error( + colors.red( + "Could not infer remote path: app folder is at the wmill.yaml root. Pass explicitly." + ) + ); + return; + } + if ( + !inferred.startsWith("u/") && + !inferred.startsWith("g/") && + !inferred.startsWith("f/") + ) { + log.error( + colors.red( + `Could not infer remote path: '${inferred}' is not under u/, g/, or f/. Move the app under one of these prefixes or pass explicitly.` + ) + ); + return; + } + remotePath = inferred; + log.info(colors.gray(`Inferred remote path: ${remotePath}`)); + } + + if (!validatePath(remotePath)) { + return; + } + + if (isRawAppByName || hasRawAppYaml) { const { pushRawApp } = await import("./raw_apps.ts"); - await pushRawApp(workspace.workspaceId, remotePath, filePath); + await pushRawApp(workspace.workspaceId, remotePath, absoluteFilePath); log.info(colors.bold.underline.green("Raw app pushed")); } else { - await pushApp(workspace.workspaceId, remotePath, filePath); + await pushApp(workspace.workspaceId, remotePath, absoluteFilePath); log.info(colors.bold.underline.green("App pushed")); } } @@ -301,8 +393,11 @@ const command = new Command() .arguments("") .option("--json", "Output as JSON (for piping to jq)") .action(get as any) - .command("push", "push a local app ") - .arguments(" ") + .command( + "push", + "push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml." + ) + .arguments("[file_path:string] [remote_path:string]") .action(push as any) .command("dev", devCommand) .command("lint", lintCommand) diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index ede19be85b..72d2e9d76b 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -6588,7 +6588,7 @@ app related commands - \`--json\` - Output as JSON (for piping to jq) - \`app get \` - get an app's details - \`--json\` - Output as JSON (for piping to jq) -- \`app push \` - push a local app +- \`app push [file_path:string] [remote_path:string]\` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml. - \`app dev [app_folder:string]\` - Start a development server for building apps with live reload and hot module replacement - \`--port \` - Port to run the dev server on (will find next available port if occupied) - \`--host \` - Host to bind the dev server to diff --git a/system_prompts/auto-generated/cli/cli-commands.md b/system_prompts/auto-generated/cli/cli-commands.md index 692e2dd310..dc0eb66640 100644 --- a/system_prompts/auto-generated/cli/cli-commands.md +++ b/system_prompts/auto-generated/cli/cli-commands.md @@ -26,7 +26,7 @@ app related commands - `--json` - Output as JSON (for piping to jq) - `app get ` - get an app's details - `--json` - Output as JSON (for piping to jq) -- `app push ` - push a local app +- `app push [file_path:string] [remote_path:string]` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml. - `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement - `--port ` - Port to run the dev server on (will find next available port if occupied) - `--host ` - Host to bind the dev server to diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 83f6ae1157..53336762cc 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -2174,7 +2174,7 @@ app related commands - \`--json\` - Output as JSON (for piping to jq) - \`app get \` - get an app's details - \`--json\` - Output as JSON (for piping to jq) -- \`app push \` - push a local app +- \`app push [file_path:string] [remote_path:string]\` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml. - \`app dev [app_folder:string]\` - Start a development server for building apps with live reload and hot module replacement - \`--port \` - Port to run the dev server on (will find next available port if occupied) - \`--host \` - Host to bind the dev server to diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index b4818b4fa1..98ce52efde 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -31,7 +31,7 @@ app related commands - `--json` - Output as JSON (for piping to jq) - `app get ` - get an app's details - `--json` - Output as JSON (for piping to jq) -- `app push ` - push a local app +- `app push [file_path:string] [remote_path:string]` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml. - `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement - `--port ` - Port to run the dev server on (will find next available port if occupied) - `--host ` - Host to bind the dev server to From b95f0e2379a82b7064d9317dfacac852b1988699 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Sun, 10 May 2026 12:06:25 +0000 Subject: [PATCH 17/50] style(log-viewer): tighter top bar (icons, hyphenated label, scoped overflow) (#9094) - duration/mem-peak labels replaced by Timer/Cpu icons with tooltips - 'Auto scroll' -> 'auto-scroll' (lowercase, hyphenated, whitespace-nowrap) - top bar gets overflow-x-auto so it scrolls horizontally instead of pushing the entire log panel into overflow when narrow --- frontend/src/lib/components/LogViewer.svelte | 36 ++++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index 9f06f90eec..e3012a9db6 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -11,7 +11,7 @@ {#if s3object && s3object?.s3} @@ -18,12 +39,9 @@ border border-dashed border-gray-400 hover:border-blue-500 focus-within:border-blue-500 hover:bg-blue-50 dark:hover:bg-frost-900 focus-within:bg-blue-50 duration-200 rounded-lg p-1 gap-2" - href={`${base}/api/w/${workspaceId ?? $workspaceStore}${ - appPath ? `/apps_u/download_s3_file/${appPath}` : '/job_helpers/download_s3_file' - }?${appPath ? 's3' : 'file_key'}=${encodeURIComponent(s3object?.s3 ?? '')}${ - s3object?.storage ? `&storage=${s3object.storage}` : '' - }${appPath && s3object?.presigned ? `&${s3object?.presigned}` : ''}`} - download={s3object?.s3?.split?.('/')?.pop() ?? 'unnamed_download.file'} + {href} + download={filename} + {onclick} > diff --git a/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte b/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte index f024b73eda..d954618518 100644 --- a/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte +++ b/frontend/src/lib/components/propertyPicker/ObjectViewer.svelte @@ -2,6 +2,7 @@ import ObjectViewer from './ObjectViewer.svelte' import { copyToClipboard, truncate } from '$lib/utils' + import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { createEventDispatcher, @@ -407,12 +408,17 @@
{closeBracket} {#if getTypeAsString(jsonFiltered) === 's3object'} + {@const s3DownloadApiPath = `/w/${$workspaceStore}/job_helpers/download_s3_file?file_key=${encodeURIComponent(jsonFiltered?.s3 ?? '')}${jsonFiltered?.storage ? `&storage=${jsonFiltered.storage}` : ''}`} + {@const s3DownloadName = jsonFiltered?.s3.split('/').pop() ?? 'unnamed_download.file'} { + if (!shouldDownloadViaClient()) return + e.preventDefault() + await downloadViaClient(s3DownloadApiPath, s3DownloadName) + }} > download diff --git a/frontend/src/lib/utils/downloadFile.ts b/frontend/src/lib/utils/downloadFile.ts new file mode 100644 index 0000000000..40f3d8df12 --- /dev/null +++ b/frontend/src/lib/utils/downloadFile.ts @@ -0,0 +1,48 @@ +import { OpenAPI } from '$lib/gen' +import { sendUserToast } from '$lib/toast' + +async function resolveToken(): Promise { + const t = OpenAPI.TOKEN + if (!t) return undefined + return typeof t === 'string' ? t : await t({} as any) +} + +/** + * When OpenAPI.TOKEN is set we cannot rely on a plain `` browser navigation + * because the browser does not attach the Authorization header. In that case fetch + * the file via the OpenAPI client (which uses OpenAPI.BASE and the Bearer token) and + * trigger a download from a blob URL. Otherwise let the default link behavior happen. + * + * `apiPath` should be the path relative to OpenAPI.BASE, starting with `/` + * (e.g. `/w/foo/job_helpers/download_s3_file?file_key=...`). + */ +export function shouldDownloadViaClient(): boolean { + return Boolean(OpenAPI.TOKEN) +} + +export async function downloadViaClient(apiPath: string, filename: string): Promise { + const token = await resolveToken() + const url = `${OpenAPI.BASE}${apiPath}` + const headers: Record = {} + if (token) headers['Authorization'] = `Bearer ${token}` + let response: Response + try { + response = await fetch(url, { headers, credentials: OpenAPI.CREDENTIALS }) + } catch (e) { + sendUserToast(`Download failed: ${e}`, true) + return + } + if (!response.ok) { + sendUserToast(`Download failed: ${response.status} ${response.statusText}`, true) + return + } + const blob = await response.blob() + const blobUrl = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = blobUrl + a.download = filename + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(blobUrl) +} diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 6b61b71242..aa873f83a9 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -36,6 +36,7 @@ } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { clone, emptyString, encodeState, hasUnsavedChanges } from '$lib/utils' + import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' import { Slack } from 'lucide-svelte' import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte' @@ -1499,13 +1500,26 @@
Export workspace
- + {#if shouldDownloadViaClient()} + + {:else} + + {/if}
From 0a5f8dcd48b677c865ccb9958b55314b91f65acf Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 11 May 2026 21:43:18 +0000 Subject: [PATCH 32/50] deps: pin tokio-postgres to forked branch with query_typed_raw deadlock fix (#9106) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * deps: pin tokio-postgres to MaterializeInc fork to fix query_typed_raw deadlock `pg_executor`'s `Client::query_typed_raw` (and `Client::prepare` on the streaming path) deadlock when the result schema contains a column whose Oid the client doesn't know yet — citext, custom enums, custom domains, postgis types. Easy to reproduce against any partitioned table with a citext column: ~100+ rows is enough on localhost, less on slower links. `psql` works fine for the same query because the simple-query protocol doesn't trigger the typeinfo lookup path. ## Root cause (unchanged tokio-postgres bug for years) `query::query_typed` calls `get_type(client, oid).await` synchronously while still holding the original query's `Responses` stream. The original query's `DataRow`s back up in the per-request `mpsc::channel(1)`, `Connection::poll_read` stops draining the wire, and the typeinfo sub-query response (queued on the same socket behind those DataRows) never arrives. Classic head-of-line blocking. ## Fix Pin `tokio-postgres` / `postgres-types` / `postgres-protocol` (via [patch.crates-io]) and the workspace `rust-postgres` / `rust-postgres-native-tls` aliases to the [MaterializeInc rust-postgres fork at `78c1222577`](https://github.com/MaterializeInc/rust-postgres/tree/master). MI's [PR #33 "bigger-channels"](https://github.com/MaterializeInc/rust-postgres/pull/33) (merged 2025-12-11) resized the per-request response channel from `mpsc::channel(1)` → `mpsc::channel(1024)`. That gives the connection task 1024 batches of headroom while a streaming consumer is paused mid-stream — orders of magnitude more than realistic typeinfo deferral needs (≈3 batches). ## Why MaterializeInc and not a windmill-labs fork `windmill-trigger-postgres` already depended on the imor fork for the `postgres-replication` crate (logical replication: `CopyBothDuplex`, `LogicalReplicationStream`, `TupleData` decoding including binary tuples). That crate has never been on upstream rust-postgres — petrosagg's [PR #752](https://github.com/rust-postgres/rust-postgres/pull/752) was closed in 2021 in favour of a smaller split, [PR #778](https://github.com/rust-postgres/rust-postgres/pull/778) is still open today after five years. petrosagg keeps the replication work alive on the MaterializeInc fork. MaterializeInc is a strict superset of what we previously got from imor: - imor's binary-tuple commit (sha `20265ef38e`) was merged into MI master. - petrosagg has added perf + correctness fixes on top (allocation reuse, proper decoding fixes). - The deadlock mitigation (`channel(1024)`) was added three weeks before this issue surfaced. MI tracks upstream rust-postgres with a periodic catch-up merge (12-18 mo cadence; last on 2025-12-03, ~100 commits picked up). Not an abandoned fork. ## Why this works now (didn't on earlier attempt) A previous attempt at this PR (`248ccb5a97`) hit CI failure because the MI fork's `postgres-types 0.2.11` requires `serde_core ^1.0.221`, but Windmill's workspace pinned `serde = "=1.0.220"` for swc_common 0.37.5's `pub use serde::__private as serde;` hack. Bumping serde above 1.0.220 broke the swc_ecma_ast `Deserialize` derive under the `enterprise,deno_core,…` feature set. The earlier blocker is now resolved by #9111 which bumped the deno + swc pin set to a "goldilocks" combination where `swc_common 14.0.4` drops the `__private` hack, freeing the workspace serde pin to `^1`. serde now resolves to 1.0.228, which satisfies MI's `serde_core ^1.0.221` requirement transitively — no extra workspace pin needed. ## Diff shape Two files only: - `backend/Cargo.toml` (+34/-1): three new `[patch.crates-io]` entries (`tokio-postgres`, `postgres-types`, `postgres-protocol` → MI fork) plus comment block, plus the two workspace deps (`rust-postgres` / `rust-postgres-native-tls`) repointed from imor's fork to MI's. - `backend/Cargo.lock` — auto-regenerated. Replaces all `imor/rust-postgres` references with `MaterializeInc/rust-postgres`, bumps the affected crate versions to MI's set (tokio-postgres 0.7.11 → 0.7.15, postgres-types 0.2.7 → 0.2.11, postgres-protocol 0.6.7 → 0.6.9, postgres-native-tls 0.5.0 → 0.5.2). No source code changes. ## Verification - `cargo check --features quickjs` → clean. - `cargo check -p windmill-worker --features quickjs` → clean (pg_executor builds). - Repro tested earlier in the thread that produced this PR: the partitioned-citext-table query on Neon goes from "hangs indefinitely" (server idle on `wait_event=ClientRead` while client awaits typeinfo behind undrained DataRows) to "completes in ~1.0s, 100 rows" with the MI fork's `bounded(1024)` response channel. ## Caveats - **`bounded(1024)` is a mitigation, not a closure.** Theoretical failure mode remains at >~64 MB single-query results with a custom-Oid column (typeinfo defers for >1024 batches of ~64 KB each). The strict-correct fix is `mpsc::unbounded()` — proposed as a follow-up PR to MI. For realistic Windmill workloads, 1024 batches of headroom is well past the ~3-batch typeinfo deferral that's actually needed. - **`postgres-replication` is now upstream-of-fork's only home.** No realistic path to upstream rust-postgres merging it. The MI pin is intended to stay in place until either upstream changes course (unlikely) or MI publishes to crates.io (also unlikely — they don't publish releases of the fork). 🤖 Generated with [Claude Code](https://claude.com/claude-code) * chore: update ee-repo-ref to 8fe0d290fb0b71c24184eb5ad99bbdc7c813697c This commit updates the EE repository reference after PR #568 was merged in windmill-ee-private. Previous ee-repo-ref: 4d01d171228196f28ddabc1150242bfab623d5cf New ee-repo-ref: 8fe0d290fb0b71c24184eb5ad99bbdc7c813697c Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- backend/Cargo.lock | 338 ++++++++++------------------------------ backend/Cargo.toml | 34 +++- backend/ee-repo-ref.txt | 2 +- 3 files changed, 118 insertions(+), 256 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d4ec956b96..82235725ee 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "generic-array", ] @@ -164,7 +164,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -175,7 +175,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -1052,7 +1052,7 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac 0.12.1", + "hmac", "http 0.2.12", "http 1.4.0", "percent-encoding", @@ -1655,15 +1655,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-buffer" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" -dependencies = [ - "hybrid-array", -] - [[package]] name = "block-modes" version = "0.8.1" @@ -2100,17 +2091,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - [[package]] name = "chrono" version = "0.4.44" @@ -2160,7 +2140,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "inout", ] @@ -2224,12 +2204,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cmov" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" - [[package]] name = "colorchoice" version = "1.0.5" @@ -2303,12 +2277,6 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - [[package]] name = "const-random" version = "0.1.18" @@ -2582,15 +2550,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "crypto-common" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" -dependencies = [ - "hybrid-array", -] - [[package]] name = "csv" version = "1.3.1" @@ -2621,15 +2580,6 @@ dependencies = [ "cipher 0.4.4", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "curl-sys" version = "0.4.88+curl-8.20.0" @@ -2642,7 +2592,7 @@ dependencies = [ "openssl-sys", "pkg-config", "vcpkg", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3971,7 +3921,7 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid 0.9.6", + "const-oid", "pem-rfc7468", "zeroize", ] @@ -4131,23 +4081,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", - "crypto-common 0.1.7", + "const-oid", + "crypto-common", "subtle", ] -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.0", - "const-oid 0.10.2", - "crypto-common 0.2.1", - "ctutils", -] - [[package]] name = "dirs" version = "4.0.0" @@ -4217,7 +4155,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4512,7 +4450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5072,8 +5010,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -5153,7 +5091,6 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -5593,7 +5530,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac 0.12.1", + "hmac", ] [[package]] @@ -5605,15 +5542,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - [[package]] name = "home" version = "0.5.12" @@ -5755,15 +5683,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" -[[package]] -name = "hybrid-array" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" -dependencies = [ - "typenum", -] - [[package]] name = "hyper" version = "0.14.32" @@ -5997,7 +5916,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.56.0", ] [[package]] @@ -7087,16 +7006,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - [[package]] name = "md5" version = "0.6.1" @@ -7479,7 +7388,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7774,7 +7683,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -7914,7 +7823,7 @@ dependencies = [ "chrono", "dyn-clone", "ed25519-dalek", - "hmac 0.12.1", + "hmac", "http 1.4.0", "itertools 0.10.5", "log", @@ -8514,6 +8423,16 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.11.3" @@ -8565,6 +8484,15 @@ dependencies = [ "siphasher 1.0.3", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.3", +] + [[package]] name = "php-parser-rs" version = "0.1.3" @@ -8662,85 +8590,56 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "postgres-native-tls" -version = "0.5.0" -source = "git+https://github.com/imor/rust-postgres?rev=20265ef38e32a06f76b6f9b678e2077fc2211f6b#20265ef38e32a06f76b6f9b678e2077fc2211f6b" +version = "0.5.2" +source = "git+https://github.com/MaterializeInc/rust-postgres?rev=78c1222577bb091d69bc22b1bc7ad01c14675abe#78c1222577bb091d69bc22b1bc7ad01c14675abe" dependencies = [ "native-tls", "tokio", "tokio-native-tls", - "tokio-postgres 0.7.11", + "tokio-postgres", ] [[package]] name = "postgres-native-tls" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f39498473c92f7b6820ae970382c1d83178a3454c618161cb772e8598d9f6f" +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc" dependencies = [ "native-tls", "tokio", "tokio-native-tls", - "tokio-postgres 0.7.13", + "tokio-postgres", ] [[package]] name = "postgres-protocol" -version = "0.6.7" -source = "git+https://github.com/imor/rust-postgres?rev=20265ef38e32a06f76b6f9b678e2077fc2211f6b#20265ef38e32a06f76b6f9b678e2077fc2211f6b" +version = "0.6.9" +source = "git+https://github.com/MaterializeInc/rust-postgres?rev=78c1222577bb091d69bc22b1bc7ad01c14675abe#78c1222577bb091d69bc22b1bc7ad01c14675abe" dependencies = [ "base64 0.22.1", "byteorder", "bytes", "fallible-iterator", - "hmac 0.12.1", + "hmac", "md-5 0.10.6", "memchr", - "rand 0.8.5", + "rand 0.9.0", "sha2 0.10.9", "stringprep", ] -[[package]] -name = "postgres-protocol" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" -dependencies = [ - "base64 0.22.1", - "byteorder", - "bytes", - "fallible-iterator", - "hmac 0.13.0", - "md-5 0.11.0", - "memchr", - "rand 0.10.1", - "sha2 0.11.0", - "stringprep", -] - [[package]] name = "postgres-types" -version = "0.2.7" -source = "git+https://github.com/imor/rust-postgres?rev=20265ef38e32a06f76b6f9b678e2077fc2211f6b#20265ef38e32a06f76b6f9b678e2077fc2211f6b" -dependencies = [ - "bytes", - "fallible-iterator", - "postgres-protocol 0.6.7", -] - -[[package]] -name = "postgres-types" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" +version = "0.2.11" +source = "git+https://github.com/MaterializeInc/rust-postgres?rev=78c1222577bb091d69bc22b1bc7ad01c14675abe#78c1222577bb091d69bc22b1bc7ad01c14675abe" dependencies = [ "array-init", "bit-vec 0.6.3", "bytes", "chrono", "fallible-iterator", - "postgres-protocol 0.6.11", - "serde", + "postgres-protocol", + "serde_core", "serde_json", "uuid", ] @@ -9186,17 +9085,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.1", -] - [[package]] name = "rand_chacha" version = "0.2.2" @@ -9254,12 +9142,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - [[package]] name = "rand_distr" version = "0.5.1" @@ -9663,7 +9545,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac 0.12.1", + "hmac", "subtle", ] @@ -9827,7 +9709,7 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid 0.9.6", + "const-oid", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -9916,7 +9798,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "postgres-types 0.2.9", + "postgres-types", "rand 0.8.5", "rkyv", "serde", @@ -9992,7 +9874,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10120,7 +10002,7 @@ dependencies = [ "security-framework 3.6.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -10797,17 +10679,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -10980,7 +10851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -11206,7 +11077,7 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "itoa", "log", "md-5 0.10.6", @@ -11247,7 +11118,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac 0.12.1", + "hmac", "home", "itoa", "log", @@ -11323,7 +11194,7 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -12150,7 +12021,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -12169,7 +12040,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -12493,8 +12364,8 @@ dependencies = [ [[package]] name = "tokio-postgres" -version = "0.7.11" -source = "git+https://github.com/imor/rust-postgres?rev=20265ef38e32a06f76b6f9b678e2077fc2211f6b#20265ef38e32a06f76b6f9b678e2077fc2211f6b" +version = "0.7.15" +source = "git+https://github.com/MaterializeInc/rust-postgres?rev=78c1222577bb091d69bc22b1bc7ad01c14675abe#78c1222577bb091d69bc22b1bc7ad01c14675abe" dependencies = [ "async-trait", "byteorder", @@ -12505,38 +12376,12 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf 0.11.3", + "phf 0.13.1", "pin-project-lite", - "postgres-protocol 0.6.7", - "postgres-types 0.2.7", - "rand 0.8.5", - "socket2 0.5.10", - "tokio", - "tokio-util", - "whoami", -] - -[[package]] -name = "tokio-postgres" -version = "0.7.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0" -dependencies = [ - "async-trait", - "byteorder", - "bytes", - "fallible-iterator", - "futures-channel", - "futures-util", - "log", - "parking_lot", - "percent-encoding", - "phf 0.11.3", - "pin-project-lite", - "postgres-protocol 0.6.11", - "postgres-types 0.2.9", + "postgres-protocol", + "postgres-types", "rand 0.9.0", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tokio-util", "whoami", @@ -13392,7 +13237,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common 0.1.7", + "crypto-common", "subtle", ] @@ -13917,7 +13762,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -14098,7 +13943,7 @@ dependencies = [ "futures", "git-version", "hex", - "hmac 0.12.1", + "hmac", "http 1.4.0", "hyper 1.9.0", "indexmap 2.14.0", @@ -14114,7 +13959,7 @@ dependencies = [ "openidconnect", "openssl", "pin-project", - "postgres-native-tls 0.5.1", + "postgres-native-tls 0.5.3", "prometheus", "quick_cache", "rand 0.9.0", @@ -14137,7 +13982,7 @@ dependencies = [ "time", "tokio", "tokio-native-tls", - "tokio-postgres 0.7.13", + "tokio-postgres", "tokio-stream", "tokio-util", "tower 0.5.3", @@ -14741,7 +14586,7 @@ dependencies = [ "git-version", "globset", "hex", - "hmac 0.12.1", + "hmac", "hyper 1.9.0", "indexmap 2.14.0", "itertools 0.14.0", @@ -14760,7 +14605,7 @@ dependencies = [ "pep440_rs", "phf 0.11.3", "pin-project-lite", - "postgres-native-tls 0.5.1", + "postgres-native-tls 0.5.3", "prometheus", "quick_cache", "rand 0.9.0", @@ -14786,7 +14631,7 @@ dependencies = [ "thiserror 2.0.18", "tikv-jemalloc-ctl", "tokio", - "tokio-postgres 0.7.13", + "tokio-postgres", "tokio-stream", "tokio-util", "tonic 0.13.1", @@ -14927,7 +14772,7 @@ dependencies = [ "backon", "base64 0.22.1", "chrono", - "hmac 0.12.1", + "hmac", "http 1.4.0", "itertools 0.14.0", "lazy_static", @@ -14959,7 +14804,7 @@ dependencies = [ "base64 0.22.1", "chrono", "hex", - "hmac 0.12.1", + "hmac", "itertools 0.14.0", "lazy_static", "reqwest 0.12.28", @@ -15305,7 +15150,7 @@ dependencies = [ "futures", "futures-core", "hex", - "hmac 0.12.1", + "hmac", "itertools 0.14.0", "lazy_static", "once_cell", @@ -15562,7 +15407,7 @@ dependencies = [ "constant_time_eq 0.3.1", "futures", "hex", - "hmac 0.12.1", + "hmac", "http 1.4.0", "hyper 1.9.0", "itertools 0.14.0", @@ -15672,7 +15517,7 @@ dependencies = [ "lazy_static", "native-tls", "pg_escape", - "postgres-native-tls 0.5.0", + "postgres-native-tls 0.5.2", "quick_cache", "rand 0.9.0", "rust_decimal", @@ -15681,7 +15526,7 @@ dependencies = [ "sqlx", "thiserror 2.0.18", "tokio", - "tokio-postgres 0.7.11", + "tokio-postgres", "tokio-stream", "tracing", "uuid", @@ -15792,7 +15637,7 @@ dependencies = [ "gcp_auth", "git-version", "hex", - "hmac 0.12.1", + "hmac", "hudsucker", "hyper-http-proxy", "hyper-tls", @@ -15813,7 +15658,7 @@ dependencies = [ "oracle", "pem 3.0.6", "pep440_rs", - "postgres-native-tls 0.5.1", + "postgres-native-tls 0.5.3", "process-wrap", "prometheus", "prost", @@ -15832,7 +15677,7 @@ dependencies = [ "tempfile", "tiberius", "tokio", - "tokio-postgres 0.7.13", + "tokio-postgres", "tokio-stream", "tokio-util", "tracing", @@ -15972,19 +15817,6 @@ dependencies = [ "windows-strings 0.4.2", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-future" version = "0.2.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b48b911b99..326b777385 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -207,6 +207,36 @@ all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embeddin object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7" } # Use tiberius main branch for libgssapi 0.8.1 fix (https://github.com/prisma/tiberius/issues/343) tiberius = { git = "https://github.com/prisma/tiberius", rev = "59db57960a14b422fb3a1309aa4aa47880896ff8" } +# Pin tokio-postgres / postgres-types / postgres-protocol to the +# MaterializeInc fork. windmill-trigger-postgres already pulled this +# fork in transitively for the postgres-replication crate +# (CopyBothDuplex, LogicalReplicationStream, TupleData with binary +# tuple support) which upstream rust-postgres has declined to merge +# since 2021 (PR #752 → #778, both still unmerged). +# +# MI also carries a mitigation for the +# Client::query_typed_raw / Client::prepare deadlock on result columns +# whose Oid the client doesn't know about yet (citext, custom enums / +# domains, postgis): MI's 2025-12-11 PR #33 resized the per-request +# response channel from mpsc::channel(1) → mpsc::channel(1024). +# bounded(1024) is sufficient for any realistic typeinfo deferral +# (need ~2-3 batches) but leaves a theoretical failure mode at +# >~64 MB results with a custom-Oid column. The strict-correct fix is +# mpsc::unbounded(); a follow-up PR to MI is open proposing that. +# +# The [patch.crates-io] entries below force windmill-worker's +# pg_executor (which imports `tokio_postgres::` directly from +# crates.io) onto the same fork as windmill-trigger-postgres, so the +# deadlock mitigation reaches both consumers. +# +# Upstream deadlock PRs (open, not on the critical path now that MI +# is mitigated): +# https://github.com/rust-postgres/rust-postgres/pull/1348 +# https://github.com/rust-postgres/rust-postgres/pull/1349 +# Reproducer: https://github.com/rubenfiszel/tokio-postgres-deadlock-repro +tokio-postgres = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } +postgres-types = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } +postgres-protocol = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } [dependencies] anyhow.workspace = true @@ -524,8 +554,8 @@ wasm-bindgen-test = "^0" convert_case = "0.6.0" getrandom = "0.2" tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]} -rust-postgres = { package = "tokio-postgres", git = "https://github.com/imor/rust-postgres", rev = "20265ef38e32a06f76b6f9b678e2077fc2211f6b"} -rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/imor/rust-postgres", features = ["runtime"], rev = "20265ef38e32a06f76b6f9b678e2077fc2211f6b" } +rust-postgres = { package = "tokio-postgres", git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe"} +rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/MaterializeInc/rust-postgres", features = ["runtime"], rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" } bit-vec = "=0.6.3" mappable-rc = "^0" mysql_async = { version = "*", default-features = false, features = ["minimal", "default", "native-tls-tls", "rust_decimal"]} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 8298e1c013..063c2e202d 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -4d01d171228196f28ddabc1150242bfab623d5cf +8fe0d290fb0b71c24184eb5ad99bbdc7c813697c From f8ba0840d74572c880cf458938365b3ec808c6fb Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 11 May 2026 21:54:40 +0000 Subject: [PATCH 33/50] feat(vault): configurable JWT auth mount path and setup-doc fixes (#9100) * chore: narrow secret-file Read deny rule to dotfiles/extensions * feat(vault): configurable JWT auth mount path and fix setup docs * chore: bump ee-repo-ref for vault jwt mount path * chore: bump ee-repo-ref after rebase onto EE main * chore: update ee-repo-ref to a1cd60b54e8595b4e5ce6b654e675e4bbe2253b2 This commit updates the EE repository reference after PR #567 was merged in windmill-ee-private. Previous ee-repo-ref: c274f233a0ebb54afa296c3db15ff330e1baebcf New ee-repo-ref: a1cd60b54e8595b4e5ce6b654e675e4bbe2253b2 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- .claude/settings.json | 5 +- backend/ee-repo-ref.txt | 2 +- backend/windmill-api/openapi.yaml | 3 + .../windmill-common/src/secret_backend/mod.rs | 5 + .../src/secret_backend/tests.rs | 1 + .../tests/secret_backend_integration.rs | 64 +++++++---- .../tests/secret_backend_migration.rs | 104 ++++++++++++------ .../SecretBackendConfig.svelte | 49 +++++++-- 8 files changed, 166 insertions(+), 67 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 0596b17e91..1ef3704831 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -55,7 +55,10 @@ "Read(**/*.pem)", "Read(**/*.key)", "Read(**/credentials.json)", - "Read(**/*secret*)", + "Read(**/.secret*)", + "Read(**/.secrets*)", + "Read(**/*.secret)", + "Read(**/*.secrets)", "Edit(.env)", "Edit(.env.*)", "Edit(**/.env)", diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 063c2e202d..ad6df82501 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -8fe0d290fb0b71c24184eb5ad99bbdc7c813697c +a1cd60b54e8595b4e5ce6b654e675e4bbe2253b2 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index c6a7de2601..82c8b06cf7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -20963,6 +20963,9 @@ components: jwt_role: type: string description: Vault JWT auth role name for Windmill (optional, if not provided token auth is used) + jwt_mount_path: + type: string + description: Mount path for the JWT auth method in Vault (optional, defaults to "jwt"). Set this when the JWT auth method is mounted at a non-default path, e.g. via `vault auth enable -path= jwt`. namespace: type: string description: Vault Enterprise namespace (optional) diff --git a/backend/windmill-common/src/secret_backend/mod.rs b/backend/windmill-common/src/secret_backend/mod.rs index a75f51ea70..36f35f0cf8 100644 --- a/backend/windmill-common/src/secret_backend/mod.rs +++ b/backend/windmill-common/src/secret_backend/mod.rs @@ -122,6 +122,11 @@ pub struct VaultSettings { /// Optional - if not provided, token auth is used #[serde(skip_serializing_if = "Option::is_none")] pub jwt_role: Option, + /// Mount path for the JWT auth method in Vault (defaults to "jwt"). + /// Set this when the JWT auth method is mounted at a non-default path, + /// e.g. via `vault auth enable -path=my-mount jwt`. + #[serde(skip_serializing_if = "Option::is_none")] + pub jwt_mount_path: Option, /// Vault Enterprise namespace (optional) #[serde(skip_serializing_if = "Option::is_none")] pub namespace: Option, diff --git a/backend/windmill-common/src/secret_backend/tests.rs b/backend/windmill-common/src/secret_backend/tests.rs index 630b6cf023..3e2a12382a 100644 --- a/backend/windmill-common/src/secret_backend/tests.rs +++ b/backend/windmill-common/src/secret_backend/tests.rs @@ -26,6 +26,7 @@ mod tests { address: "http://127.0.0.1:8200".to_string(), mount_path: "windmill".to_string(), jwt_role: Some("windmill-secrets".to_string()), + jwt_mount_path: None, namespace: None, token: Some("test-root-token".to_string()), skip_ssl_verify: None, diff --git a/backend/windmill-common/tests/secret_backend_integration.rs b/backend/windmill-common/tests/secret_backend_integration.rs index 070c70defa..350fc297af 100644 --- a/backend/windmill-common/tests/secret_backend_integration.rs +++ b/backend/windmill-common/tests/secret_backend_integration.rs @@ -91,6 +91,7 @@ mod tests { .unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()), mount_path: "windmill".to_string(), jwt_role: None, // Static token mode + jwt_mount_path: None, namespace: None, token: Some( std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()), @@ -106,6 +107,7 @@ mod tests { .unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()), mount_path: "windmill".to_string(), jwt_role: Some("windmill-secrets".to_string()), // JWT mode + jwt_mount_path: None, namespace: None, token: None, // No static token - use JWT skip_ssl_verify: None, @@ -203,7 +205,10 @@ mod tests { println!("Testing Vault connection with JWT auth..."); println!(" Address: {}", settings.address); println!(" JWT Role: {:?}", settings.jwt_role); - println!(" BASE_URL: {}", (**windmill_common::BASE_URL.load()).clone()); + println!( + " BASE_URL: {}", + (**windmill_common::BASE_URL.load()).clone() + ); let result = test_vault_connection(&settings, Some(&db)).await; assert!( @@ -274,13 +279,15 @@ mod tests { // Encrypt fixture placeholders with real workspace keys encrypt_fixture_secrets(&db).await; - let secret_count = sqlx::query_scalar!( - "SELECT COUNT(*) FROM variable WHERE is_secret = true" - ) - .fetch_one(&db) - .await - .expect("Failed to count secrets"); - println!("Found {} secrets in database before migration", secret_count.unwrap_or(0)); + let secret_count = + sqlx::query_scalar!("SELECT COUNT(*) FROM variable WHERE is_secret = true") + .fetch_one(&db) + .await + .expect("Failed to count secrets"); + println!( + "Found {} secrets in database before migration", + secret_count.unwrap_or(0) + ); // Run migration println!("Migrating secrets to Vault..."); @@ -288,8 +295,10 @@ mod tests { .await .expect("Migration to Vault failed"); - println!("Migration report: total={}, migrated={}, failed={}", - report.total_secrets, report.migrated_count, report.failed_count); + println!( + "Migration report: total={}, migrated={}, failed={}", + report.total_secrets, report.migrated_count, report.failed_count + ); if !report.failures.is_empty() { for f in &report.failures { @@ -307,7 +316,11 @@ mod tests { .get_secret(ws, path) .await .unwrap_or_else(|e| panic!("Failed to read {}/{} from Vault: {:?}", ws, path, e)); - assert_eq!(value, expected_plaintext, "Vault value mismatch for {}/{}", ws, path); + assert_eq!( + value, expected_plaintext, + "Vault value mismatch for {}/{}", + ws, path + ); println!(" ✓ {}/{} correct in Vault", ws, path); } @@ -346,8 +359,10 @@ mod tests { .await .expect("Migration to database failed"); - println!("Migration report: total={}, migrated={}, failed={}", - report.total_secrets, report.migrated_count, report.failed_count); + println!( + "Migration report: total={}, migrated={}, failed={}", + report.total_secrets, report.migrated_count, report.failed_count + ); assert_eq!(report.failed_count, 0, "Migration had failures"); assert!(report.migrated_count > 0, "No secrets were migrated"); @@ -364,7 +379,11 @@ mod tests { let mc = build_crypt(&db, ws).await.unwrap(); let decrypted = decrypt(&mc, row).expect("Failed to decrypt restored value"); - assert_eq!(decrypted, expected_plaintext, "Restored value mismatch for {}/{}", ws, path); + assert_eq!( + decrypted, expected_plaintext, + "Restored value mismatch for {}/{}", + ws, path + ); println!(" ✓ {}/{} correctly restored in DB", ws, path); } @@ -488,11 +507,19 @@ mod tests { .await .unwrap_or_else(|_| panic!("Secret {}/{} not found after round-trip", ws, path)); - assert_ne!(encrypted, "ROUND_TRIP_CLEARED", "Secret {}/{} was not restored", ws, path); + assert_ne!( + encrypted, "ROUND_TRIP_CLEARED", + "Secret {}/{} was not restored", + ws, path + ); let mc = build_crypt(&db, ws).await.unwrap(); let decrypted = decrypt(&mc, encrypted).expect("Failed to decrypt"); - assert_eq!(decrypted, expected_plaintext, "Round-trip value mismatch for {}/{}", ws, path); + assert_eq!( + decrypted, expected_plaintext, + "Round-trip value mismatch for {}/{}", + ws, path + ); println!(" ✓ {}/{}: round-trip OK", ws, path); } @@ -522,10 +549,7 @@ mod tests { .get_secret("test-workspace", "u/test-user/other_secret") .await; - assert!( - cross_access.is_err(), - "Cross-workspace access should fail!" - ); + assert!(cross_access.is_err(), "Cross-workspace access should fail!"); println!("✓ Cross-workspace access correctly denied"); // Verify own workspace access works diff --git a/backend/windmill-common/tests/secret_backend_migration.rs b/backend/windmill-common/tests/secret_backend_migration.rs index 1915b90630..fba27ee260 100644 --- a/backend/windmill-common/tests/secret_backend_migration.rs +++ b/backend/windmill-common/tests/secret_backend_migration.rs @@ -23,19 +23,21 @@ use sqlx::{Pool, Postgres}; use windmill_common::error::Result; use windmill_common::secret_backend::{ - vault_oss::{migrate_secrets_to_database, migrate_secrets_to_vault, test_vault_connection, VaultBackend}, + vault_oss::{ + migrate_secrets_to_database, migrate_secrets_to_vault, test_vault_connection, VaultBackend, + }, SecretBackend, VaultSettings, }; fn test_vault_settings() -> VaultSettings { VaultSettings { - address: std::env::var("VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()), + address: std::env::var("VAULT_ADDR") + .unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()), mount_path: "windmill".to_string(), jwt_role: Some("windmill-secrets".to_string()), + jwt_mount_path: None, namespace: None, - token: Some( - std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()), - ), + token: Some(std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string())), skip_ssl_verify: None, } } @@ -47,7 +49,11 @@ async fn test_vault_connection_works(db: Pool) { let settings = test_vault_settings(); let result = test_vault_connection(&settings, Some(&db)).await; - assert!(result.is_ok(), "Failed to connect to Vault: {:?}", result.err()); + assert!( + result.is_ok(), + "Failed to connect to Vault: {:?}", + result.err() + ); println!("✓ Successfully connected to Vault at {}", settings.address); } @@ -70,7 +76,10 @@ async fn test_migrate_db_to_vault(db: Pool) { .await .expect("Failed to query secrets"); - println!("Found {} secrets in database before migration:", secrets_before.len()); + println!( + "Found {} secrets in database before migration:", + secrets_before.len() + ); for s in &secrets_before { println!(" - {}/{}: {} chars", s.workspace_id, s.path, s.value.len()); } @@ -111,7 +120,10 @@ async fn test_migrate_db_to_vault(db: Pool) { secret.path, result.err() ); - println!(" ✓ {}/{} exists in Vault", secret.workspace_id, secret.path); + println!( + " ✓ {}/{} exists in Vault", + secret.workspace_id, secret.path + ); } println!("\n✓ Migration to Vault completed successfully"); @@ -133,8 +145,14 @@ async fn test_migrate_vault_to_db(db: Pool) { let to_vault_report = migrate_secrets_to_vault(&db, &settings) .await .expect("Initial migration to Vault failed"); - assert!(to_vault_report.migrated_count > 0, "No secrets to test with"); - println!(" Migrated {} secrets to Vault", to_vault_report.migrated_count); + assert!( + to_vault_report.migrated_count > 0, + "No secrets to test with" + ); + println!( + " Migrated {} secrets to Vault", + to_vault_report.migrated_count + ); // Clear the database values to simulate fresh migration back println!("\nClearing database secret values..."); @@ -150,7 +168,10 @@ async fn test_migrate_vault_to_db(db: Pool) { .fetch_one(&db) .await .expect("Failed to count cleared"); - println!(" Cleared {} secret values in database", cleared.count.unwrap_or(0)); + println!( + " Cleared {} secret values in database", + cleared.count.unwrap_or(0) + ); // Now migrate from Vault back to database println!("\nMigrating secrets from Vault to database..."); @@ -206,15 +227,14 @@ async fn test_full_round_trip_migration(db: Pool) { .expect("Failed to connect to Vault"); // Get original secrets - let original_secrets: std::collections::HashMap<(String, String), String> = sqlx::query!( - "SELECT workspace_id, path, value FROM variable WHERE is_secret = true" - ) - .fetch_all(&db) - .await - .expect("Failed to query original secrets") - .into_iter() - .map(|r| ((r.workspace_id, r.path), r.value)) - .collect(); + let original_secrets: std::collections::HashMap<(String, String), String> = + sqlx::query!("SELECT workspace_id, path, value FROM variable WHERE is_secret = true") + .fetch_all(&db) + .await + .expect("Failed to query original secrets") + .into_iter() + .map(|r| ((r.workspace_id, r.path), r.value)) + .collect(); println!("Original secrets: {} entries", original_secrets.len()); @@ -243,21 +263,23 @@ async fn test_full_round_trip_migration(db: Pool) { // Step 4: Verify round-trip integrity println!("\n=== Step 4: Verify round-trip integrity ==="); - let restored_secrets: std::collections::HashMap<(String, String), String> = sqlx::query!( - "SELECT workspace_id, path, value FROM variable WHERE is_secret = true" - ) - .fetch_all(&db) - .await - .expect("Failed to query restored secrets") - .into_iter() - .map(|r| ((r.workspace_id, r.path), r.value)) - .collect(); + let restored_secrets: std::collections::HashMap<(String, String), String> = + sqlx::query!("SELECT workspace_id, path, value FROM variable WHERE is_secret = true") + .fetch_all(&db) + .await + .expect("Failed to query restored secrets") + .into_iter() + .map(|r| ((r.workspace_id, r.path), r.value)) + .collect(); // Compare original and restored for ((ws, path), _original_value) in &original_secrets { let restored_value = restored_secrets .get(&(ws.clone(), path.clone())) - .expect(&format!("Secret {}/{} not found after round-trip", ws, path)); + .expect(&format!( + "Secret {}/{} not found after round-trip", + ws, path + )); // Note: Values might differ slightly due to encryption/decryption // but they should not be the cleared value @@ -266,7 +288,12 @@ async fn test_full_round_trip_migration(db: Pool) { "Secret {}/{} was not restored", ws, path ); - println!(" ✓ {}/{}: restored ({} chars)", ws, path, restored_value.len()); + println!( + " ✓ {}/{}: restored ({} chars)", + ws, + path, + restored_value.len() + ); } println!("\n✓ Full round-trip migration completed successfully!"); @@ -289,7 +316,10 @@ async fn test_workspace_isolation(db: Pool) { .await .expect("Migration failed"); - println!("Migrated {} secrets across workspaces", report.migrated_count); + println!( + "Migrated {} secrets across workspaces", + report.migrated_count + ); // Verify workspace isolation in Vault let vault_backend = VaultBackend::new(settings.clone()); @@ -309,13 +339,19 @@ async fn test_workspace_isolation(db: Pool) { let ws1_result: Result = vault_backend .get_secret("test-workspace", "u/test-user/db_password") .await; - assert!(ws1_result.is_ok(), "test-workspace secret should be accessible"); + assert!( + ws1_result.is_ok(), + "test-workspace secret should be accessible" + ); println!("✓ test-workspace secrets accessible"); let ws2_result: Result = vault_backend .get_secret("test-workspace-2", "u/test-user/other_secret") .await; - assert!(ws2_result.is_ok(), "test-workspace-2 secret should be accessible"); + assert!( + ws2_result.is_ok(), + "test-workspace-2 secret should be accessible" + ); println!("✓ test-workspace-2 secrets accessible"); println!("\n✓ Workspace isolation verified!"); diff --git a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte index 15db633264..a313a0fbc4 100644 --- a/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte +++ b/frontend/src/lib/components/instanceSettings/SecretBackendConfig.svelte @@ -70,6 +70,7 @@ address: $values['secret_backend']?.address ?? '', mount_path: $values['secret_backend']?.mount_path ?? 'windmill', jwt_role: $values['secret_backend']?.jwt_role ?? 'windmill-secrets', + jwt_mount_path: $values['secret_backend']?.jwt_mount_path ?? null, namespace: $values['secret_backend']?.namespace ?? null, token: $values['secret_backend']?.token ?? null, skip_ssl_verify: $values['secret_backend']?.skip_ssl_verify ?? false @@ -122,6 +123,7 @@ address: $values['secret_backend'].address, mount_path: $values['secret_backend'].mount_path, jwt_role: $values['secret_backend'].jwt_role, + jwt_mount_path: $values['secret_backend'].jwt_mount_path || undefined, namespace: $values['secret_backend'].namespace || undefined, token: $values['secret_backend'].token || undefined, skip_ssl_verify: $values['secret_backend'].skip_ssl_verify || undefined @@ -352,6 +354,10 @@ } let baseUrl = $derived($values['base_url'] ?? 'https://your-windmill-instance.com') + let jwtMount = $derived(($values['secret_backend']?.jwt_mount_path?.trim() || 'jwt') as string) + let vaultAudience = $derived( + ($values['secret_backend']?.address?.trim() || 'https://vault.example.com:8200') as string + )
@@ -500,6 +506,24 @@ }} bind:value={$values['secret_backend'].jwt_role} /> + + Mount path of the JWT auth method in Vault. Defaults to jwt. Set this + only if you mounted the JWT auth method at a non-default path (vault auth enable -path=<mount> jwt). +
Vault JWT Setup Instructions
# Enable JWT auth method
-vault auth enable jwt
+										># Enable JWT auth method{jwtMount === 'jwt'
+											? ''
+											: ` at custom mount '${jwtMount}'`}
+vault auth enable {jwtMount === 'jwt' ? 'jwt' : `-path=${jwtMount} jwt`}
 
 # Configure JWT auth with Windmill's JWKS endpoint
-vault write auth/jwt/config \
-  jwks_url="{baseUrl}/.well-known/jwks.json" \
-  bound_issuer="{baseUrl}"
+vault write auth/{jwtMount}/config \
+  jwks_url="{baseUrl}/api/oidc/jwks" \
+  bound_issuer="{baseUrl}/api/oidc/"
 
 # Create a policy for Windmill secrets
 vault policy write windmill-secrets - <<EOF
-path "windmill/data/*" {
+path "{$values['secret_backend']?.mount_path ?? 'windmill'}/data/*" {
   capabilities = ["create", "read", "update", "delete"]
 }
-path "windmill/metadata/*" {
+path "{$values['secret_backend']?.mount_path ?? 'windmill'}/metadata/*" {
   capabilities = ["list", "delete"]
 }
 EOF
 
-# Create the JWT role
-vault write auth/jwt/role/windmill-secrets \
+# Create the JWT role. bound_audiences must match the Vault server
+# address — Windmill signs the JWT with `aud` = your Vault address.
+vault write auth/{jwtMount}/role/{$values['secret_backend']?.jwt_role || 'windmill-secrets'} \
   role_type="jwt" \
-  bound_audiences="{baseUrl}" \
-  user_claim="email" \
+  bound_audiences="{vaultAudience}" \
+  user_claim="sub" \
   policies="windmill-secrets" \
   ttl="1h"
From 1abfe9de393c532e80468556f01c9b9101ab7bca Mon Sep 17 00:00:00 2001 From: Samuel Wilk <34423885+da-wilky@users.noreply.github.com> Date: Tue, 12 May 2026 00:03:04 +0200 Subject: [PATCH 34/50] Add max-iterations to OpenAPI spec for AI Agent (#9103) --- openflow.openapi.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 824243b2af..630ce5fddf 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1053,6 +1053,12 @@ components: - 0.0 = deterministic, focused responses - 0.7 = balanced (common default) - 1.0+ = more creative/random + max_iterations: + allOf: + - $ref: '#/components/schemas/InputTransform' + description: | + Number. Limits how many times the agent can loop through reasoning and tool use. + Range: 1-1000. required: - provider - user_message From 9c6cd8c852ee544848bb04fc4a4f52a111f11fda Mon Sep 17 00:00:00 2001 From: hugocasa Date: Tue, 12 May 2026 00:09:21 +0200 Subject: [PATCH 35/50] offline (URL-bound) license keys (#9089) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] feat(license): offline (URL-bound) license keys Offline keys are a 4-segment variant for air-gapped customers — no phone-home, embedded seat/CU caps, locked to the instance's base_url. Existing 3-segment online keys are unchanged. Companion PRs: - windmill-labs/windmill-ee-private (full design + EE impl) - windmill-labs/windmill-customer-service (issuance + portal) - windmill-labs/windmill-cf-worker-keygen (signing) Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] refactor(license): bind offline keys via instance hash; simpler CU enforcement - /settings/license_status now surfaces an `instance_hash` superadmins share with support when requesting an offline key - OfflineMetadata: `hash` replaces `base_url`; OfflineCapStatus reports `current_cu` (last 2min) and drops the grace-period fields - verify_license_key now takes a db so EE can recheck the hash - InstanceSetting.svelte: hash copy-block + simpler status panel - Bump ee-repo-ref Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] chore(license): bump ee-repo-ref Pulls in the current_cu clamp + prod public key restoration. Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] refactor(license): split instance_hash endpoint; minimal cap UI; restore workers expiry toast - `instance_hash` is no longer part of /settings/license_status responses; it lives at GET /settings/instance_hash (super-admin only) so it isn't re-emitted on every status poll. The UI doesn't show it — admins fetch it explicitly when requesting a key from support. - InstanceSetting offline cap UI is now two compact green/red status lines (Seats X.X/Y and CUs X.X/Y) placed above the action buttons, matching the existing "Latest key renewal" badge style. The block-panel is gone. - "Latest key renewal" line and the "Renew key" button are now hidden when an offline key is loaded (renewal is server-disabled for offline keys). - Restore parseLicenseKey + checkLicenseExpiration toast on /workers (works for both 3- and 4-segment keys). Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] chore(license): bump ee-repo-ref Pulls in the plain-SHA256 instance hash + stats_ee revert. Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] chore(license): bump ee-repo-ref Picks up the alert wording change. Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] chore(license): bump ee-repo-ref Picks up the instance_uid cache so the periodic verify_license_key cycle no longer hits global_settings. Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] refactor(license): rename /settings/license_status → /offline_license_status The endpoint was only used by the offline-license UI; the other fields it returned (license_key_id, license_key_valid, kind, offline metadata) were unused. Rename to clarify scope and flatten the response — it now returns just the OfflineCapStatus (or null when no offline license is loaded). Frontend uses `offlineCapStatus != null` as the "is offline" check. Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] fix(ci): regenerate sqlx cache for the inline worker_ping query After reverting unused stats_ee helpers (fetch_worker_pings*), the inline `sqlx::query_as!(WorkerPingRecord, ...)` in get_stats_payload lost its cache entry — CI's check_ee_full + cargo_test were failing under SQLX_OFFLINE=true with E0282 type-inference errors. Re-running update_sqlx.sh regenerates the cache file under its current hash and prunes a couple of stale entries. Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] fix(license): address cubic-bot review - get_offline_license_status: propagate enforce_offline_caps errors as 500 instead of swallowing into a "no offline license" (Option::None) response - canonical_base_url: rewrite the doc to match the actual fallback behavior (lowercase + trailing-slash strip on URL parse failure); the original cross-service contract is gone since the customer-service no longer canonicalizes (treats the instance hash as opaque) - check_seat_cap_for_new_user: take an email and short-circuit when the email is already in `usr ∪ workspace_invite` so net-zero invite upserts and invite→user transitions aren't spuriously blocked at cap. Mirrors the dedup rule the count itself uses. - Bump ee-repo-ref to pull in the EE-side change Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] chore(license): bump ee-repo-ref Picks up the exact-delta seat-cap check (replaces the simple existence short-circuit). Regenerates the new sqlx cache for the bool_and query. Co-Authored-By: Claude Opus 4.7 (1M context) * [ee] fix(license): propagate get_instance_hash errors; bump ee-repo-ref - get_instance_hash: replace `.ok().flatten()` with map_err+? so DB errors during instance_uid lookup surface as 500 instead of silently returning `{"instance_hash": null}` (same pattern get_offline_license_status already uses) - Bump ee-repo-ref to pull in the enforce_offline_caps cached-state preservation Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to c6cd1afe2d9e04809b30751cd1687b28a65e62b1 This commit updates the EE repository reference after PR #566 was merged in windmill-ee-private. Previous ee-repo-ref: a6d91016ae0d43c46604313aecae3aa9c778c8e0 New ee-repo-ref: c6cd1afe2d9e04809b30751cd1687b28a65e62b1 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Ruben Fiszel Co-authored-by: windmill-internal-app[bot] --- ...1f7f387f5055c47f493271d26731336257384.json | 10 +-- ...c1ed508d83695ef6d62ca06cfb612fd332b87.json | 28 --------- ...49b8082c0052d626bf67e08317e56ab9ad026.json | 58 ------------------ ...153c43903f929ae5d62fbba12610f89c36d55.json | 2 +- ...320bb3eff65255a44bd66799ae14288312ba4.json | 23 ------- ...c31e10f89481908c479b4039af5e94fa0f8ac.json | 28 --------- ...cf6b4946e1fd337f00d243f518283783833c9.json | 22 +++++++ ...69657fd73742b6ee8289b1e9736e381314cfb.json | 23 ------- ...7ad51a4cd5cbdc2fa34038530070d6a579455.json | 26 ++++++++ ...d8c74cdc672a903d566baf5ac5ef50a4da1bd.json | 32 ++++++++++ backend/ee-repo-ref.txt | 2 +- backend/src/ee_oss.rs | 2 +- backend/src/main.rs | 2 +- backend/src/monitor.rs | 20 +++++- backend/windmill-api-settings/src/ee_oss.rs | 6 +- backend/windmill-api-settings/src/lib.rs | 53 +++++++++++++++- .../windmill-api-workspaces/src/workspaces.rs | 14 +++++ backend/windmill-api/openapi.yaml | 57 +++++++++++++++++ backend/windmill-api/src/ee_oss.rs | 6 +- backend/windmill-common/src/ee_oss.rs | 54 ++++++++++++++++ backend/windmill-common/src/lib.rs | 30 +++++++++ backend/windmill-common/src/utils.rs | 4 +- .../src/lib/components/InstanceSetting.svelte | 61 +++++++++++++++++-- .../(root)/(logged)/workers/+page.svelte | 29 ++++----- 24 files changed, 396 insertions(+), 196 deletions(-) delete mode 100644 backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json delete mode 100644 backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json delete mode 100644 backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json delete mode 100644 backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json create mode 100644 backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json delete mode 100644 backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json create mode 100644 backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json create mode 100644 backend/.sqlx/query-f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd.json diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index d29a18c691..e7ed0aee65 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, true, true ] diff --git a/backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json b/backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json deleted file mode 100644 index 7de0416a12..0000000000 --- a/backend/.sqlx/query-0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT item_kind, path FROM ws_specific WHERE workspace_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "item_kind", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87" -} diff --git a/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json b/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json deleted file mode 100644 index 2b5b68dfae..0000000000 --- a/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "label", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "scopes", - "type_info": "TextArray" - }, - { - "ordinal": 3, - "name": "workspace_id", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "super_admin", - "type_info": "Bool" - }, - { - "ordinal": 5, - "name": "owner", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "expiration", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true, - true, - true, - true, - false, - true, - true - ] - }, - "hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026" -} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 713ccb9dd3..36ddb8ab9f 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - null + true ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json b/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json deleted file mode 100644 index 0a39db6822..0000000000 --- a/backend/.sqlx/query-6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4" -} diff --git a/backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json b/backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json deleted file mode 100644 index 415544ece9..0000000000 --- a/backend/.sqlx/query-8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT s.item_kind, s.path\n FROM ws_specific s\n WHERE s.workspace_id = $1\n AND (\n (s.item_kind = 'resource' AND EXISTS (\n SELECT 1 FROM resource r\n WHERE r.workspace_id = s.workspace_id AND r.path = s.path\n ))\n OR (s.item_kind = 'variable' AND EXISTS (\n SELECT 1 FROM variable v\n WHERE v.workspace_id = s.workspace_id AND v.path = s.path\n ))\n )\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "item_kind", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "path", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac" -} diff --git a/backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json b/backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json new file mode 100644 index 0000000000..fce125c6d5 --- /dev/null +++ b/backend/.sqlx/query-9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bool_and", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9" +} diff --git a/backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json b/backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json deleted file mode 100644 index 327032afb5..0000000000 --- a/backend/.sqlx/query-b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $2)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "exists", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb" -} diff --git a/backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json b/backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json new file mode 100644 index 0000000000..f38c023cb3 --- /dev/null +++ b/backend/.sqlx/query-e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH potential AS (\n SELECT email, operator FROM usr\n UNION\n SELECT email, operator FROM workspace_invite\n ),\n per_user AS (\n SELECT email, bool_and(operator) AS only_operator FROM potential GROUP BY email\n )\n SELECT\n COUNT(*) FILTER (WHERE NOT only_operator) AS \"authors!\",\n COUNT(*) FILTER (WHERE only_operator) AS \"operators!\"\n FROM per_user", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "authors!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "operators!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null + ] + }, + "hash": "e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455" +} diff --git a/backend/.sqlx/query-f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd.json b/backend/.sqlx/query-f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd.json new file mode 100644 index 0000000000..e4125ddab9 --- /dev/null +++ b/backend/.sqlx/query-f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT memory, worker, native_mode FROM worker_ping WHERE ping_at > now() - interval '2 minutes'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "memory", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "worker", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "native_mode", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + true, + false, + false + ] + }, + "hash": "f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd" +} diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index ad6df82501..91d8581950 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a1cd60b54e8595b4e5ce6b654e675e4bbe2253b2 +c6cd1afe2d9e04809b30751cd1687b28a65e62b1 diff --git a/backend/src/ee_oss.rs b/backend/src/ee_oss.rs index 2aefaf7431..4dcaf2437b 100644 --- a/backend/src/ee_oss.rs +++ b/backend/src/ee_oss.rs @@ -8,6 +8,6 @@ pub async fn set_license_key(_license_key: String, _db: Option<&windmill_common: } #[cfg(all(feature = "enterprise", not(feature = "private")))] -pub async fn verify_license_key() -> () { +pub async fn verify_license_key(_db: Option<&windmill_common::db::DB>) -> () { // Implementation is not open source } diff --git a/backend/src/main.rs b/backend/src/main.rs index 3049064624..9a2709e9a9 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1441,7 +1441,7 @@ Windmill Community Edition {GIT_VERSION} tracing::error!("Failed to reload license key on agent: {e:#}"); } #[cfg(feature = "enterprise")] - ee_oss::verify_license_key().await; + ee_oss::verify_license_key(conn.as_sql()).await; } // update min version explicitly. diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index 59a2f47ed0..0ddc02b256 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -2373,7 +2373,19 @@ pub async fn monitor_db( let verify_license_key_f = async { #[cfg(feature = "enterprise")] if !initial_load { - verify_license_key().await; + verify_license_key(conn.as_sql()).await; + } + }; + + let enforce_offline_caps_f = async { + #[cfg(feature = "enterprise")] + if server_mode && !initial_load { + if let Some(db) = conn.as_sql() { + // Cheap: one query for workers active in the last 2 minutes. + if let Err(e) = windmill_common::ee_oss::enforce_offline_caps(db).await { + tracing::error!("Failed to enforce offline license caps: {e:#}"); + } + } } }; @@ -2522,6 +2534,7 @@ pub async fn monitor_db( vacuum_queue_f, expose_queue_metrics_f, verify_license_key_f, + enforce_offline_caps_f, worker_groups_alerts_f, jobs_waiting_alerts_f, low_disk_alerts_f, @@ -2853,6 +2866,11 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> { IS_SECURE.store(is_secure, Ordering::Relaxed); + #[cfg(feature = "enterprise")] + { + crate::ee_oss::verify_license_key(conn.as_sql()).await; + } + Ok(()) } diff --git a/backend/windmill-api-settings/src/ee_oss.rs b/backend/windmill-api-settings/src/ee_oss.rs index d0e0f69b62..92b3d6ad53 100644 --- a/backend/windmill-api-settings/src/ee_oss.rs +++ b/backend/windmill-api-settings/src/ee_oss.rs @@ -8,7 +8,11 @@ use anyhow::anyhow; pub async fn validate_license_key( _license_key: String, _db: Option<&windmill_common::DB>, -) -> anyhow::Result<(String, bool)> { +) -> anyhow::Result<( + String, + bool, + Option, +)> { // Implementation is not open source Err(anyhow!("License can't be validated in Windmill CE")) } diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index f027046808..09b52a3d22 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -37,13 +37,13 @@ use axum::{ use serde_json::json; use serde::{Deserialize, Serialize}; +use windmill_ai::ai_cache::bump_instance_ai_config_revision; #[cfg(feature = "enterprise")] use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel}; #[cfg(all(feature = "private", feature = "enterprise"))] use windmill_common::secret_backend::{ AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, }; -use windmill_ai::ai_cache::bump_instance_ai_config_revision; use windmill_common::{ email_oss::send_email_plain_text, error::{self, JsonResult, Result}, @@ -118,6 +118,8 @@ pub fn global_service() -> Router { get(get_latest_key_renewal_attempt), ) .route("/renew_license_key", post(renew_license_key)) + .route("/offline_license_status", get(get_offline_license_status)) + .route("/instance_hash", get(get_instance_hash)) .route("/customer_portal", post(create_customer_portal_session)) .route("/test_critical_channels", post(test_critical_channels)) .route("/critical_alerts", get(get_critical_alerts)) @@ -340,7 +342,7 @@ pub async fn test_license_key( Json(TestKey { license_key }): Json, ) -> error::Result { require_super_admin(&db, &authed.email).await?; - let (_, expired) = validate_license_key(license_key, Some(&db)).await?; + let (_, expired, _offline_meta) = validate_license_key(license_key, Some(&db)).await?; if expired { Err(error::Error::BadRequest("Expired license key".to_string())) @@ -349,6 +351,53 @@ pub async fn test_license_key( } } +#[derive(serde::Serialize)] +pub struct InstanceHash { + pub instance_hash: Option, +} + +/// Returns the live cap status for an offline license, or `null` when no +/// offline license is loaded. Used by the superadmin settings panel. +pub async fn get_offline_license_status( + Extension(db): Extension, + authed: ApiAuthed, +) -> error::JsonResult> { + require_super_admin(&db, &authed.email).await?; + + let offline = (**windmill_common::ee_oss::LICENSE_OFFLINE_METADATA.load()).clone(); + let is_offline = matches!(&offline, Some(m) if m.is_offline()); + + if !is_offline { + return Ok(Json(None)); + } + + #[cfg(feature = "enterprise")] + let cap = windmill_common::ee_oss::enforce_offline_caps(&db) + .await + .map_err(|e| error::Error::internal_err(format!("enforce_offline_caps: {e:#}")))?; + #[cfg(not(feature = "enterprise"))] + let cap: Option = None; + + Ok(Json(cap)) +} + +/// Returns the per-instance binding hash that goes into offline license keys. +/// Admin invokes via `curl` with their personal token when requesting a key +/// from support. +pub async fn get_instance_hash( + Extension(db): Extension, + authed: ApiAuthed, +) -> error::JsonResult { + require_super_admin(&db, &authed.email).await?; + #[cfg(feature = "enterprise")] + let hash = windmill_common::ee_oss::compute_instance_hash(&db) + .await + .map_err(|e| error::Error::internal_err(format!("compute_instance_hash: {e:#}")))?; + #[cfg(not(feature = "enterprise"))] + let hash: Option = None; + Ok(Json(InstanceHash { instance_hash: hash })) +} + pub async fn get_local_settings( Extension(db): Extension, authed: ApiAuthed, diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 94e2026ad3..95c7157d61 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -5233,6 +5233,13 @@ async fn invite_user( nu.email = nu.email.to_lowercase(); + #[cfg(feature = "enterprise")] + if let Some(msg) = + windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? + { + return Err(Error::BadRequest(msg)); + } + let mut tx = db.begin().await?; let already_in_workspace = sqlx::query_scalar!( @@ -5306,6 +5313,13 @@ async fn add_user( nu.email = nu.email.to_lowercase(); + #[cfg(feature = "enterprise")] + if let Some(msg) = + windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await? + { + return Err(Error::BadRequest(msg)); + } + let mut tx = db.begin().await?; let already_exists_email = sqlx::query_scalar!( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 82c8b06cf7..16448c622b 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1779,6 +1779,63 @@ paths: schema: type: string + /settings/offline_license_status: + get: + summary: get cap-usage status for the currently-loaded offline license + description: | + Returns the live cap status (seats used vs cap, current CU vs cap) for + the offline license key currently in use. Returns `null` if no offline + license is loaded. Super-admin only. + operationId: getOfflineLicenseStatus + tags: + - setting + responses: + "200": + description: cap status (or null when no offline license) + content: + application/json: + schema: + type: object + nullable: true + properties: + seats_used: + type: number + description: Author-equivalent seats consumed (authors + 0.5 × operators) + seats_cap: + type: integer + author_count: + type: integer + operator_count: + type: integer + current_cu: + type: number + description: Sum of CU rate across workers that pinged in the last 2 minutes. + cu_cap: + type: number + cu_over_cap: + type: boolean + + /settings/instance_hash: + get: + summary: per-instance binding hash for offline license issuance + description: | + Returns the hash a superadmin shares with Windmill support when + requesting an offline license. Super-admin only. + operationId: getInstanceHash + tags: + - setting + responses: + '200': + description: instance hash + content: + application/json: + schema: + type: object + properties: + instance_hash: + type: string + nullable: true + /settings/customer_portal: post: summary: create customer portal session diff --git a/backend/windmill-api/src/ee_oss.rs b/backend/windmill-api/src/ee_oss.rs index a1f7a54d20..e5a7f71cbc 100644 --- a/backend/windmill-api/src/ee_oss.rs +++ b/backend/windmill-api/src/ee_oss.rs @@ -10,7 +10,11 @@ use anyhow::anyhow; pub async fn validate_license_key( _license_key: String, _db: Option<&crate::db::DB>, -) -> anyhow::Result<(String, bool)> { +) -> anyhow::Result<( + String, + bool, + Option, +)> { // Implementation is not open source Err(anyhow!("License can't be validated in Windmill CE")) } diff --git a/backend/windmill-common/src/ee_oss.rs b/backend/windmill-common/src/ee_oss.rs index 15d9e7cb5e..e68bc00c6e 100644 --- a/backend/windmill-common/src/ee_oss.rs +++ b/backend/windmill-common/src/ee_oss.rs @@ -18,6 +18,60 @@ lazy_static::lazy_static! { pub static ref LICENSE_KEY_VALID: AtomicBool = AtomicBool::new(true); pub static ref LICENSE_KEY_ID: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee("".to_string()); pub static ref LICENSE_KEY: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee("".to_string()); + pub static ref LICENSE_OFFLINE_METADATA: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); + pub static ref LICENSE_OFFLINE_OVER_CU_CAP: AtomicBool = AtomicBool::new(false); + pub static ref LICENSE_OFFLINE_LAST_STATUS: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); + pub static ref LICENSE_OFFLINE_LAST_CHECKED_AT: arc_swap::ArcSwap>> = arc_swap::ArcSwap::from_pointee(None); +} + +#[cfg(not(feature = "private"))] +#[derive(Clone, Debug, Deserialize, serde::Serialize)] +pub struct OfflineMetadata { + pub v: u32, + pub kind: String, + pub hash: String, + pub seats: i64, + pub cu_limit: f64, +} + +#[cfg(not(feature = "private"))] +impl OfflineMetadata { + pub fn is_offline(&self) -> bool { + self.kind == "offline" + } +} + +#[cfg(not(feature = "private"))] +#[derive(Clone, Debug, serde::Serialize)] +pub struct OfflineCapStatus { + pub seats_used: f64, + pub seats_cap: i64, + pub author_count: i64, + pub operator_count: i64, + pub current_cu: f64, + pub cu_cap: f64, + pub cu_over_cap: bool, +} + +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn check_seat_cap_for_new_user( + _db: &DB, + _email: &str, + _new_user_is_operator: bool, +) -> anyhow::Result> { + Ok(None) +} + +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result> { + // Implementation is not open source + Ok(None) +} + +#[cfg(all(feature = "enterprise", not(feature = "private")))] +pub async fn enforce_offline_caps(_db: &DB) -> anyhow::Result> { + // Implementation is not open source + Ok(None) } #[cfg(not(feature = "private"))] diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 7c7e3f79ed..39884e3d35 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -121,6 +121,36 @@ pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000; pub const SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers"; +/// Canonical form of a base URL, used as one of the inputs to the offline-license +/// instance hash (`compute_instance_hash`). +/// +/// Rules: lowercase scheme and host, drop default ports (80/443), strip path/query/fragment, +/// strip trailing slash. If URL parsing fails, falls back to a best-effort lowercase + +/// trailing-slash strip so two semantically-equivalent inputs still produce the same +/// canonical form. +pub fn canonical_base_url(input: &str) -> String { + let trimmed = input.trim(); + if trimmed.is_empty() { + return String::new(); + } + match url::Url::parse(trimmed) { + Ok(u) => { + let scheme = u.scheme().to_ascii_lowercase(); + let host = u + .host_str() + .map(|h| h.to_ascii_lowercase()) + .unwrap_or_default(); + let port = match (u.port(), scheme.as_str()) { + (Some(80), "http") | (Some(443), "https") => String::new(), + (Some(p), _) => format!(":{p}"), + (None, _) => String::new(), + }; + format!("{scheme}://{host}{port}") + } + Err(_) => trimmed.trim_end_matches('/').to_ascii_lowercase(), + } +} + /// Checks if the user is allowed to preserve on_behalf_of values (admin or deployer). pub fn can_preserve_on_behalf_of(authed: &impl db::Authable) -> bool { authed.is_admin() || authed.groups().iter().any(|g| g == &WM_DEPLOYERS_GROUP) diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index efe5481e1d..17d9cfd804 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -447,7 +447,9 @@ pub async fn get_license_id_or_uid<'c, E: sqlx::Executor<'c, Database = Postgres } } -async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>(db: E) -> Result { +pub async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, +) -> Result { let uid_value = sqlx::query_scalar!( "SELECT value FROM global_settings WHERE name = $1", UNIQUE_ID_SETTING diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index f0097b877a..4964fb9c1d 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -56,6 +56,16 @@ attempted_at: string } | null = $state(null) + let offlineCapStatus: { + seats_used: number + seats_cap: number + author_count: number + operator_count: number + current_cu: number + cu_cap: number + cu_over_cap: boolean + } | null = $state(null) + function showSetting(setting: string, values: Record) { if (setting == 'dev_instance') { if (values['license_key'] == undefined) { @@ -72,6 +82,14 @@ latestKeyRenewalAttempt = await SettingService.getLatestKeyRenewalAttempt() } + async function reloadLicenseStatus() { + try { + offlineCapStatus = (await SettingService.getOfflineLicenseStatus()) as any + } catch { + offlineCapStatus = null + } + } + async function reloadLicenseKey() { $values['license_key'] = await SettingService.getGlobal({ key: 'license_key' @@ -80,7 +98,10 @@ $effect(() => { if (setting.key == 'license_key') { - untrack(() => reloadKeyrenewalAttemptInfo()) + untrack(() => { + reloadKeyrenewalAttemptInfo() + reloadLicenseStatus() + }) } }) @@ -430,7 +451,7 @@
{/if} {/if} - {#if latestKeyRenewalAttempt} + {#if latestKeyRenewalAttempt && !offlineCapStatus} {@const attemptedAt = new Date(latestKeyRenewalAttempt.attempted_at).toLocaleString()} {@const isTrial = latestKeyRenewalAttempt.result.startsWith('error: trial:')}
@@ -500,11 +521,41 @@
{/if} + {#if offlineCapStatus} + {@const cap = offlineCapStatus} + {@const seatsOver = cap.seats_used > cap.seats_cap} + {@const cuOver = cap.cu_over_cap} +
+
+ {#if seatsOver} + + {:else} + + {/if} + + Seats: {cap.seats_used.toFixed(1)} / {cap.seats_cap} + +
+
+ {#if cuOver} + + {:else} + + {/if} + + CUs: {cap.current_cu.toFixed(2)} / {cap.cu_cap.toFixed(2)} + +
+
+ {/if} + {#if valid || expiration}
- + {#if !offlineCapStatus} + + {/if} diff --git a/frontend/src/routes/(root)/(logged)/workers/+page.svelte b/frontend/src/routes/(root)/(logged)/workers/+page.svelte index 9bc4d1d710..c2de0b81d8 100644 --- a/frontend/src/routes/(root)/(logged)/workers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workers/+page.svelte @@ -194,19 +194,6 @@ } } - let defaultTagPerWorkspace: boolean | undefined = $state(undefined) - let defaultTagWorkspaces: string[] = $state([]) - async function loadDefaultTagsPerWorkspace() { - try { - defaultTagPerWorkspace = await WorkerService.isDefaultTagsPerWorkspace() - defaultTagWorkspaces = (await SettingService.getGlobal({ - key: DEFAULT_TAGS_WORKSPACES_SETTING - })) as any - } catch (err) { - sendUserToast(`Could not load default tag per workspace setting: ${err}`, true) - } - } - function parseLicenseKey(key: string): { valid: boolean expiration?: Date @@ -247,13 +234,11 @@ const { valid, expiration } = parseLicenseKey(licenseKey) if (!valid && expiration) { - // License is expired sendUserToast( `Enterprise license key expired on ${expiration.toLocaleDateString()}. Please renew your license key to continue using Windmill.`, true ) } else if (expiration) { - // Check if expires within 7 days const daysUntilExpiration = Math.floor( (expiration.getTime() - Date.now()) / (1000 * 60 * 60 * 24) ) @@ -266,11 +251,23 @@ } } } catch (err) { - // Silently fail - don't show errors for license check console.error('Failed to check license expiration:', err) } } + let defaultTagPerWorkspace: boolean | undefined = $state(undefined) + let defaultTagWorkspaces: string[] = $state([]) + async function loadDefaultTagsPerWorkspace() { + try { + defaultTagPerWorkspace = await WorkerService.isDefaultTagsPerWorkspace() + defaultTagWorkspaces = (await SettingService.getGlobal({ + key: DEFAULT_TAGS_WORKSPACES_SETTING + })) as any + } catch (err) { + sendUserToast(`Could not load default tag per workspace setting: ${err}`, true) + } + } + onMount(() => { intervalId = setInterval(() => { loadWorkers() From 07d3ffbf3434738e53b54720b2006fec154b2466 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 11 May 2026 22:13:58 +0000 Subject: [PATCH 36/50] system prompts refresh --- system_prompts/auto-generated/prompts.d.ts | 26 +++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/system_prompts/auto-generated/prompts.d.ts b/system_prompts/auto-generated/prompts.d.ts index e9e6136470..fe4156afd4 100644 --- a/system_prompts/auto-generated/prompts.d.ts +++ b/system_prompts/auto-generated/prompts.d.ts @@ -1,6 +1,6 @@ export declare const SCRIPT_BASE = "# Windmill Script Writing Guide\n\n## General Principles\n\n- Scripts must export a main function (do not call it)\n- Libraries are installed automatically - do not show installation instructions\n- Credentials and configuration are stored in resources and passed as parameters\n- The windmill client (`wmill`) provides APIs for interacting with the platform\n\n## Function Naming\n\n- Main function: `main` (or `preprocessor` for preprocessor scripts)\n- Must be async for TypeScript variants\n\n## Return Values\n\n- Scripts can return any JSON-serializable value\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, Postgres, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\nThe preprocessor receives a single parameter called `event`.\n"; -export declare const FLOW_BASE = "# Windmill Flow Building Guide\n\n## Creating a Flow\n\n**You \u2014 the AI agent \u2014 scaffold the flow yourself by running `wmill flow new ` with the right flags. Do NOT hand-create the folder + `flow.yaml`, and do NOT tell the user to \"run `wmill flow new` and follow the prompts\".**\n\n`wmill flow new` creates the folder with the correct suffix (`__flow` or `.flow` depending on the workspace's `nonDottedPaths` setting), writes a minimal `flow.yaml` shell, and prints Claude-specific next-step hints. Scaffolding by hand skips all of that and often picks the wrong suffix.\n\n### Step 1 \u2014 Gather path + summary by asking the user\n\nYou need two things:\n\n1. **path** \u2014 the windmill path, e.g. `f/folder/my_flow` or `u/username/my_flow`.\n2. **summary** \u2014 a short description of the flow.\n\nIf the user's request didn't supply both, ask for both in a single round-trip. Use whichever interactive question facility your runtime provides \u2014 a structured multi-choice tool if available, otherwise plain chat \u2014 and provide one or two example values for each (with an \"Other\" / free-form fallback). Do not guess paths or summaries.\n\n### Step 2 \u2014 Run the command yourself\n\n```bash\nwmill flow new f/folder/my_flow --summary \"Short description\"\n```\n\nAdd `--description \"...\"` when the user provided a longer explanation worth preserving separately from the summary.\n\n### Step 3 \u2014 Fill in `flow.yaml`\n\nOpen the generated `flow.yaml` (under the folder the command just created) and replace the empty `value.modules` + `schema` with the real flow definition.\n\nFor rawscript modules, use `!inline path/to/script.ts` for the content key. Inline script files should NOT include `.inline_script.` in their names (e.g. use `a.ts`, not `a.inline_script.ts`).\n\nOnce the flow has real content, **offer** to open the visual preview as a one-sentence next step (e.g. \"Want me to open the visual preview?\"). Don't auto-open \u2014 opening the dev page has side effects (browser window, possibly a `launch.json` entry) and the user should consent.\n\n### Anti-patterns to avoid\n\n- \u274C Hand-creating the `__flow` folder + `flow.yaml` instead of running `wmill flow new`. You'll miss the suffix-setting resolution, the default shape, and the Claude hints.\n- \u274C Telling the user to \"run `wmill flow new `\" \u2014 you can and should run it yourself.\n- \u274C Inventing a path/summary instead of asking the user.\n\n## CLI Commands \u2014 running, previewing, deploying\n\nAfter writing, tell the user which command fits what they want to do:\n\n- `wmill flow preview ` \u2014 **default when iterating on a local flow.** Runs the local `flow.yaml` against local inline scripts without deploying. Add `--remote` to use deployed workspace scripts for PathScript steps instead of local files.\n- `wmill flow run ` \u2014 runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.\n- `wmill generate-metadata` \u2014 regenerate stale `.lock` and `.script.yaml` files. By default it scans **scripts, flows, and apps** across the workspace; pass `--skip-flows --skip-apps` (or run from a subdirectory) to limit the scope when you only care about the flow you edited.\n- `wmill sync push` \u2014 deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push \u2014 not when they say \"run\", \"try\", or \"test\".\n\n### Preview vs run \u2014 choose by intent, not habit\n\nIf the user says \"run the flow\", \"try it\", \"test it\", \"does it work\" while there are **local edits to a `flow.yaml`**, use `flow preview`. Do NOT push the flow to then `flow run` it \u2014 pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes.\n\nOnly use `flow run` when:\n- The user explicitly says \"run the deployed version\" / \"run what's on the server\".\n- There is no local `flow.yaml` being edited (you're just invoking an existing flow).\n\nOnly use `sync push` when:\n- The user explicitly asks to deploy, publish, push, or ship.\n- The preview has already validated the change and the user wants it in the workspace.\n\n### After writing \u2014 offer to run, don't wait passively\n\nThis is about **programmatic execution** (`wmill flow preview -d ''`), which actually runs the flow and has side effects. Visual preview (the `preview` skill) is offered separately \u2014 see \"Visual preview\" below.\n\nIf the user hasn't already told you to run/test the flow, offer it as a one-sentence next step (e.g. \"Want me to run `wmill flow preview` with sample args?\"). Do not present a multi-option menu.\n\nIf the user already asked to test/run/try the flow in their original request, skip the offer and just execute `wmill flow preview -d ''` directly \u2014 pick plausible args from the flow's input schema.\n\n`wmill flow preview` is safe to run yourself (it does not deploy). `wmill sync push` and `wmill generate-metadata` modify workspace state or local files \u2014 only run these when the user explicitly asks; otherwise tell them which to run.\n\n### Visual preview\n\nTo open the flow visually in the dev page (graph + live reload), use the `preview` skill. Always **offer** it as a one-sentence next step (e.g. \"Want me to open the visual preview?\") rather than opening it automatically \u2014 opening the dev page has side effects (browser window, possibly a `launch.json` entry under MCP-preview branches) the user should consent to. If the user already asked to see/preview/visualize the flow in their original request, skip the offer and just invoke the skill.\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Hard Structural Rules\n\nThese are strict Windmill schema rules. Follow them exactly.\n\n- `value.modules` is only for normal sequential steps\n- `value.preprocessor_module` and `value.failure_module` are special top-level fields inside `value`, not entries in `value.modules`\n- If a flow needs a preprocessor, create `value.preprocessor_module` with `id: preprocessor`\n- If a flow needs a failure handler, create `value.failure_module` with `id: failure`\n- Do NOT create regular modules inside `value.modules` named `preprocessor` or `failure`\n- `preprocessor_module` and `failure_module` only support `script` or `rawscript`\n- `preprocessor_module` runs before normal modules and cannot reference `results.*`\n- `failure_module` can use the `error` object with `error.message`, `error.step_id`, `error.name`, and `error.stack`\n\nCorrect shape:\n\n```yaml\nvalue:\n preprocessor_module:\n id: preprocessor\n value:\n type: rawscript\n ...\n failure_module:\n id: failure\n value:\n type: rawscript\n ...\n modules:\n - id: process_event\n value:\n type: rawscript\n ...\n```\n\nIncorrect shape:\n\n```yaml\nvalue:\n modules:\n - id: preprocessor\n ...\n - id: process_event\n ...\n - id: failure\n ...\n```\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step only when that step result is in scope\n- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope\n- `flow_input.iter.value` - Current iteration value when inside a loop (`forloopflow` or `whileloopflow`)\n- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)\n\n## Loop Structure Rules\n\n- For `whileloopflow`, use module-level `stop_after_if` on the loop module itself when the loop should stop after an iteration result\n- Do NOT put `stop_after_if` inside `value` of a `whileloopflow`\n- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition\n- When a `whileloopflow` carries state forward between iterations, use `flow_input.iter.value` as the current loop value and provide an explicit first-iteration fallback when needed\n- Use `flow_input.iter.index` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value\n- If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array\n\nCorrect `whileloopflow` shape:\n\n```yaml\n- id: loop_until_done\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n value:\n type: rawscript\n input_transforms:\n state:\n type: javascript\n expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state\n- id: return_final_state\n value:\n type: rawscript\n input_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done[results.loop_until_done.length - 1]\n```\n\nIncorrect `whileloopflow` patterns:\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n stop_after_if:\n expr: result.done === true\n```\n\n```yaml\ninput_transforms:\n state:\n type: javascript\n expr: flow_input.iter.index\n```\n\n```yaml\ninput_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done\n```\n\n## Approval / Suspend Structure\n\n- `suspend` belongs on the flow module object itself, as a sibling of `id` and `value`\n- Never put `suspend` inside `value`\n\nCorrect shape:\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n resume_form:\n schema:\n type: object\n properties:\n comment:\n type: string\n required: [comment]\n value:\n type: identity\n```\n\nIncorrect shape:\n\n```yaml\n- id: request_approval\n value:\n type: rawscript\n suspend:\n required_events: 1\n```\n\n## Branch Result Scope Rules\n\n- Inside a branch, you may reference earlier outer steps and earlier steps in the same branch\n- Outside a `branchone`, do NOT reference ids of steps that only exist inside its branches or default branch. Use `results.` instead\n- Outside a `branchall`, do NOT reference ids of steps inside its branches. Use `results.` instead\n- If downstream steps need a stable shape after a branch, make each branch return the same fields\n- When needed, add a normalization step immediately after the branch and consume `results.` there\n\nCorrect after `branchone`:\n\n```yaml\n- id: route_order\n value:\n type: branchone\n ...\n- id: send_confirmation\n value:\n input_transforms:\n routed:\n type: javascript\n expr: results.route_order\n```\n\nIncorrect after `branchone`:\n\n```yaml\nexpr: results.create_shipment\nexpr: results.create_backorder\n```\n\nCorrect after `branchall`:\n\n```yaml\n- id: enrich_parallel\n value:\n type: branchall\n parallel: true\n ...\n- id: combine_data\n value:\n input_transforms:\n enrichments:\n type: javascript\n expr: results.enrich_parallel\n```\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Final Structural Self-Check\n\nBefore finalizing a flow, verify:\n\n- any preprocessor is in `value.preprocessor_module`\n- any failure handler is in `value.failure_module`\n- any approval step has module-level `suspend`\n- no downstream step references inner branch step ids from outside the branch\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n"; -export declare const WORKFLOW_AS_CODE_BASE = "# Windmill Workflow-as-Code Writing Guide\n\n## Scope\n\nUse this guide when writing or modifying Windmill Workflow-as-Code (WAC) scripts.\nWAC is authored as a Windmill script and deployed with the normal script workflow. It is not an OpenFlow YAML flow.\n\nSupported WAC authoring targets:\n- TypeScript scripts that import from `windmill-client`\n- Python 3 scripts that import from `wmill`\n\n## File Shape\n\nTypeScript:\n\n```typescript\nimport {\n task,\n taskScript,\n taskFlow,\n step,\n sleep,\n waitForApproval,\n getResumeUrls,\n parallel,\n workflow,\n} from \"windmill-client\";\n\nconst process = task(async (x: string): Promise => {\n return `processed: ${x}`;\n});\n\nexport const main = workflow(async (x: string) => {\n const result = await process(x);\n return { result };\n});\n```\n\nPython:\n\n```python\nfrom wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow\n\n@task()\nasync def process(x: str) -> str:\n return f\"processed: {x}\"\n\n@workflow\nasync def main(x: str):\n result = await process(x)\n return {\"result\": result}\n```\n\nRules:\n- Do not call `main`.\n- TypeScript should export the workflow entrypoint, preferably `export const main = workflow(async (...) => { ... })`.\n- Python must use `@workflow` on an async top-level function, usually `main`.\n- Define task functions and `taskScript`/`task_script` or `taskFlow`/`task_flow` assignments at module top level with stable names.\n- Use the exact SDK names. Do not alias `workflow`, `task`, `taskScript`, `taskFlow`, `step`, `sleep`, `waitForApproval`, `task_script`, `task_flow`, or `wait_for_approval`; the WAC parser recognizes these names directly.\n\n## Checkpoint And Replay Model\n\nThe parent workflow may rerun from the top after any suspension, retry, approval, or child task completion. Completed durable steps are replayed from the checkpoint.\n\nPut every side effect or non-deterministic value behind a durable WAC boundary:\n- Use `task()` / `@task()` for substantial work that should run as its own child job.\n- Use `taskScript()` / `task_script()` for an existing script or a relative module file.\n- Use `taskFlow()` / `task_flow()` for an existing Windmill flow.\n- Use `step(name, fn)` for lightweight inline work whose result must be checkpointed.\n- Use `sleep(seconds)` for server-side sleeps that do not hold a worker.\n- Use `waitForApproval()` / `wait_for_approval()` for external approval suspension.\n\nNever put API calls, database writes, notifications, random values, timestamps, or irreversible changes directly in the top-level workflow body. The workflow body can be rerun. Put those operations in a task or in `step()`.\n\nBranching on task or step results is safe because those results are checkpointed. Branching on current time, random data, environment reads, or external state is unsafe unless the value is first captured with `step()`.\n\n## Tasks\n\nUse `task()` / `@task()` for inline functions that become workflow steps:\n\n```typescript\nconst enrich = task(async (customerId: string) => {\n return await fetchCustomer(customerId);\n});\n```\n\n```python\n@task(timeout=600, tag=\"etl\")\nasync def enrich(customer_id: str):\n return await fetch_customer(customer_id)\n```\n\nIn TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`.\n\nFor existing scripts:\n\n```typescript\nconst helper = taskScript(\"./helper.ts\");\nconst existing = taskScript(\"f/data/extract\", { timeout: 600 });\nconst value = await helper({ input: x });\n```\n\n```python\nhelper = task_script(\"./helper.py\")\nexisting = task_script(\"f/data/extract\", timeout=600)\nvalue = await helper(input=x)\n```\n\nFor existing flows:\n\n```typescript\nconst pipeline = taskFlow(\"f/etl/pipeline\");\nconst output = await pipeline({ input: data });\n```\n\n```python\npipeline = task_flow(\"f/etl/pipeline\")\noutput = await pipeline(input=data)\n```\n\n## Inline Steps\n\nUse `step()` for lightweight inline values that must not change during replay:\n\n```typescript\nconst urls = await step(\"get_urls\", () => getResumeUrls());\nconst startedAt = await step(\"started_at\", () => new Date().toISOString());\n```\n\n```python\nurls = await step(\"get_urls\", lambda: get_resume_urls())\n```\n\nUse stable, descriptive step names. Do not generate step names dynamically.\n\n## Parallelism\n\nTo run independent work in parallel, start task promises/coroutines before awaiting them together:\n\n```typescript\nconst [a, b] = await Promise.all([process(\"a\"), process(\"b\")]);\nconst many = await parallel(items, process, { concurrency: 5 });\n```\n\n```python\nimport asyncio\n\na, b = await asyncio.gather(process(\"a\"), process(\"b\"))\nmany = await parallel(items, process, concurrency=5)\n```\n\nOnly parallelize independent steps. Do not read the result of a task before it is awaited.\n\n## Approvals\n\nGenerate resume URLs inside `step()` before sending them:\n\n```typescript\nconst urls = await step(\"get_urls\", () => getResumeUrls());\nawait step(\"notify\", () => sendApprovalEmail(urls.approvalPage));\nconst approval = await waitForApproval({ timeout: 3600 });\n```\n\n```python\nurls = await step(\"get_urls\", lambda: get_resume_urls())\nawait step(\"notify\", lambda: send_approval_email(urls[\"approvalPage\"]))\napproval = await wait_for_approval(timeout=3600)\n```\n\n`selfApproval: false` and `self_approval=False` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.\n\n## Error Handling\n\nLet task errors fail the workflow unless the user asks for recovery logic.\n\nPython: `except Exception` is safe around WAC calls because internal suspension inherits from `BaseException`. Avoid bare `except:` in workflow code. If the user asks for recovery logic around failed child work, catch `TaskError` from `wmill` for task failures.\n\nTypeScript: avoid broad `try/catch` around WAC SDK calls. The SDK uses an internal suspension error during initial dispatch; catching it can break workflow suspension. If a broad catch is unavoidable, rethrow internal suspension errors before handling business errors.\n"; +export declare const FLOW_BASE = "# Windmill Flow Building Guide\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Hard Structural Rules\n\nThese are strict Windmill schema rules. Follow them exactly.\n\n- `value.modules` is only for normal sequential steps\n- `value.preprocessor_module` and `value.failure_module` are special top-level fields inside `value`, not entries in `value.modules`\n- If a flow needs a preprocessor, create `value.preprocessor_module` with `id: preprocessor`\n- If a flow needs a failure handler, create `value.failure_module` with `id: failure`\n- Do NOT create regular modules inside `value.modules` named `preprocessor` or `failure`\n- `preprocessor_module` and `failure_module` only support `script` or `rawscript`\n- `preprocessor_module` runs before normal modules and cannot reference `results.*`\n- `failure_module` can use the `error` object with `error.message`, `error.step_id`, `error.name`, and `error.stack`\n\nCorrect shape:\n\n```yaml\nvalue:\n preprocessor_module:\n id: preprocessor\n value:\n type: rawscript\n ...\n failure_module:\n id: failure\n value:\n type: rawscript\n ...\n modules:\n - id: process_event\n value:\n type: rawscript\n ...\n```\n\nIncorrect shape:\n\n```yaml\nvalue:\n modules:\n - id: preprocessor\n ...\n - id: process_event\n ...\n - id: failure\n ...\n```\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step only when that step result is in scope\n- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope\n- `flow_input.iter.value` - Current iteration value when inside a loop (`forloopflow` or `whileloopflow`)\n- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)\n\n## Loop Structure Rules\n\n- For `whileloopflow`, use module-level `stop_after_if` on the loop module itself when the loop should stop after an iteration result\n- Do NOT put `stop_after_if` inside `value` of a `whileloopflow`\n- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition\n- When a `whileloopflow` carries state forward between iterations, use `flow_input.iter.value` as the current loop value and provide an explicit first-iteration fallback when needed\n- Use `flow_input.iter.index` only when the loop logic is truly based on the iteration index, not as a replacement for the current loop value\n- If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array\n\nCorrect `whileloopflow` shape:\n\n```yaml\n- id: loop_until_done\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n value:\n type: rawscript\n input_transforms:\n state:\n type: javascript\n expr: flow_input.iter && flow_input.iter.value !== undefined ? flow_input.iter.value : flow_input.initial_state\n- id: return_final_state\n value:\n type: rawscript\n input_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done[results.loop_until_done.length - 1]\n```\n\nIncorrect `whileloopflow` patterns:\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n stop_after_if:\n expr: result.done === true\n```\n\n```yaml\ninput_transforms:\n state:\n type: javascript\n expr: flow_input.iter.index\n```\n\n```yaml\ninput_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done\n```\n\n## Approval / Suspend Structure\n\n- `suspend` belongs on the flow module object itself, as a sibling of `id` and `value`\n- Never put `suspend` inside `value`\n\nCorrect shape:\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n resume_form:\n schema:\n type: object\n properties:\n comment:\n type: string\n required: [comment]\n value:\n type: identity\n```\n\nIncorrect shape:\n\n```yaml\n- id: request_approval\n value:\n type: rawscript\n suspend:\n required_events: 1\n```\n\n## Branch Result Scope Rules\n\n- Inside a branch, you may reference earlier outer steps and earlier steps in the same branch\n- Outside a `branchone`, do NOT reference ids of steps that only exist inside its branches or default branch. Use `results.` instead\n- Outside a `branchall`, do NOT reference ids of steps inside its branches. Use `results.` instead\n- If downstream steps need a stable shape after a branch, make each branch return the same fields\n- When needed, add a normalization step immediately after the branch and consume `results.` there\n\nCorrect after `branchone`:\n\n```yaml\n- id: route_order\n value:\n type: branchone\n ...\n- id: send_confirmation\n value:\n input_transforms:\n routed:\n type: javascript\n expr: results.route_order\n```\n\nIncorrect after `branchone`:\n\n```yaml\nexpr: results.create_shipment\nexpr: results.create_backorder\n```\n\nCorrect after `branchall`:\n\n```yaml\n- id: enrich_parallel\n value:\n type: branchall\n parallel: true\n ...\n- id: combine_data\n value:\n input_transforms:\n enrichments:\n type: javascript\n expr: results.enrich_parallel\n```\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Final Structural Self-Check\n\nBefore finalizing a flow, verify:\n\n- any preprocessor is in `value.preprocessor_module`\n- any failure handler is in `value.failure_module`\n- any approval step has module-level `suspend`\n- no downstream step references inner branch step ids from outside the branch\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n"; +export declare const WORKFLOW_AS_CODE_BASE = "# Windmill Workflow-as-Code Writing Guide\n\n## Scope\n\nUse this guide when writing or modifying Windmill Workflow-as-Code (WAC) scripts.\nWAC is authored as a Windmill script and deployed with the normal script workflow. It is not an OpenFlow YAML flow.\n\nSupported WAC authoring targets:\n- Bun TypeScript scripts that import from `windmill-client`\n- Python 3 scripts that import from `wmill`\n\n## File Shape\n\nBun TypeScript:\n\n```typescript\nimport {\n task,\n taskScript,\n taskFlow,\n step,\n sleep,\n waitForApproval,\n getResumeUrls,\n parallel,\n workflow,\n} from \"windmill-client\";\n\nconst process = task(async (x: string): Promise => {\n return `processed: ${x}`;\n});\n\nexport const main = workflow(async (x: string) => {\n const result = await process(x);\n return { result };\n});\n```\n\nPython:\n\n```python\nfrom wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow\n\n@task()\nasync def process(x: str) -> str:\n return f\"processed: {x}\"\n\n@workflow\nasync def main(x: str):\n result = await process(x)\n return {\"result\": result}\n```\n\nRules:\n- Do not call `main`.\n- Bun TypeScript should export the workflow entrypoint, preferably `export const main = workflow(async (...) => { ... })`.\n- Python must use `@workflow` on an async top-level function, usually `main`.\n- Define task functions and `taskScript`/`task_script` or `taskFlow`/`task_flow` assignments at module top level with stable names.\n- Use the exact SDK names. Do not alias `workflow`, `task`, `taskScript`, `taskFlow`, `step`, `sleep`, `waitForApproval`, `task_script`, `task_flow`, or `wait_for_approval`; the WAC parser recognizes these names directly.\n\n## Checkpoint And Replay Model\n\nThe parent workflow may rerun from the top after any suspension, retry, approval, or child task completion. Completed durable steps are replayed from the checkpoint.\n\nPut every side effect or non-deterministic value behind a durable WAC boundary:\n- Use `task()` / `@task()` for substantial work that should run as its own child job.\n- Use `taskScript()` / `task_script()` for an existing script or a relative module file.\n- Use `taskFlow()` / `task_flow()` for an existing Windmill flow.\n- Use `step(name, fn)` for lightweight inline work whose result must be checkpointed.\n- Use `sleep(seconds)` for server-side sleeps that do not hold a worker.\n- Use `waitForApproval()` / `wait_for_approval()` for external approval suspension.\n\nNever put API calls, database writes, notifications, random values, timestamps, or irreversible changes directly in the top-level workflow body. The workflow body can be rerun. Put those operations in a task or in `step()`.\n\nBranching on task or step results is safe because those results are checkpointed. Branching on current time, random data, environment reads, or external state is unsafe unless the value is first captured with `step()`.\n\n## Tasks\n\nUse `task()` / `@task()` for inline functions that become workflow steps:\n\n```typescript\nconst enrich = task(async (customerId: string) => {\n return await fetchCustomer(customerId);\n});\n```\n\n```python\n@task(timeout=600, tag=\"etl\")\nasync def enrich(customer_id: str):\n return await fetch_customer(customer_id)\n```\n\nIn TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`.\n\nFor existing scripts:\n\n```typescript\nconst helper = taskScript(\"./helper.ts\");\nconst existing = taskScript(\"f/data/extract\", { timeout: 600 });\nconst value = await helper({ input: x });\n```\n\n```python\nhelper = task_script(\"./helper.py\")\nexisting = task_script(\"f/data/extract\", timeout=600)\nvalue = await helper(input=x)\n```\n\nFor existing flows:\n\n```typescript\nconst pipeline = taskFlow(\"f/etl/pipeline\");\nconst output = await pipeline({ input: data });\n```\n\n```python\npipeline = task_flow(\"f/etl/pipeline\")\noutput = await pipeline(input=data)\n```\n\n## Inline Steps\n\nUse `step()` for lightweight inline values that must not change during replay:\n\n```typescript\nconst urls = await step(\"get_urls\", () => getResumeUrls());\nconst startedAt = await step(\"started_at\", () => new Date().toISOString());\n```\n\n```python\nurls = await step(\"get_urls\", lambda: get_resume_urls())\n```\n\nUse stable, descriptive step names. Do not generate step names dynamically.\n\n## Parallelism\n\nTo run independent work in parallel, start task promises/coroutines before awaiting them together:\n\n```typescript\nconst [a, b] = await Promise.all([process(\"a\"), process(\"b\")]);\nconst many = await parallel(items, process, { concurrency: 5 });\n```\n\n```python\nimport asyncio\n\na, b = await asyncio.gather(process(\"a\"), process(\"b\"))\nmany = await parallel(items, process, concurrency=5)\n```\n\nOnly parallelize independent steps. Do not read the result of a task before it is awaited.\n\n## Approvals\n\nGenerate resume URLs inside `step()` before sending them:\n\n```typescript\nconst urls = await step(\"get_urls\", () => getResumeUrls());\nawait step(\"notify\", () => sendApprovalEmail(urls.approvalPage));\nconst approval = await waitForApproval({ timeout: 3600 });\n```\n\n```python\nurls = await step(\"get_urls\", lambda: get_resume_urls())\nawait step(\"notify\", lambda: send_approval_email(urls[\"approvalPage\"]))\napproval = await wait_for_approval(timeout=3600)\n```\n\n`selfApproval: false` and `self_approval=False` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.\n\n## Error Handling\n\nLet task errors fail the workflow unless the user asks for recovery logic.\n\nPython: `except Exception` is safe around WAC calls because internal suspension inherits from `BaseException`. Avoid bare `except:` in workflow code. If the user asks for recovery logic around failed child work, catch `TaskError` from `wmill` for task failures.\n\nTypeScript: avoid broad `try/catch` around WAC SDK calls. The SDK uses an internal suspension error during initial dispatch; catching it can break workflow suspension. If a broad catch is unavoidable, rethrow internal suspension errors before handling business errors.\n"; export declare const FLOW_CHAT_SPECIAL_MODULES = "## Special Modules\n\n- Use `set_preprocessor_module` to add, replace, or remove the top-level `value.preprocessor_module`\n- Use `set_failure_module` to add, replace, or remove the top-level `value.failure_module`\n- Use `set_flow_json` only when you are replacing the whole flow, including normal modules and optional special modules\n\n**Example - Update only the special modules:**\n```javascript\nset_preprocessor_module({\n module: JSON.stringify({\n id: \"preprocessor\",\n value: {\n type: \"rawscript\",\n language: \"bun\",\n content: \"export async function preprocessor(payload: string) { const trimmed = payload.trim(); if (!trimmed) { throw new Error('payload must not be empty'); } return { payload: trimmed }; }\",\n input_transforms: {\n payload: { type: \"javascript\", expr: \"flow_input.payload\" }\n }\n }\n })\n})\n\nset_failure_module({\n module: JSON.stringify({\n id: \"failure\",\n value: {\n type: \"rawscript\",\n language: \"bun\",\n content: \"export async function main(message: string, name: string, step_id: string) { return { message, name, step_id }; }\",\n input_transforms: {\n message: { type: \"javascript\", expr: \"error.message\" },\n name: { type: \"javascript\", expr: \"error.name\" },\n step_id: { type: \"javascript\", expr: \"error.step_id\" }\n }\n }\n })\n})\n```\n"; export declare const SDK_TYPESCRIPT = "# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * @deprecated Use runScriptByPath or runScriptByHash instead\n */\nasync runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead\n */\nasync runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @deprecated use setState instead\n */\nasync setInternalState(state: any): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the internal state\n * @deprecated use getState instead\n */\nasync getInternalState(): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[]): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * @deprecated use getResumeUrls instead\n */\ngetResumeEndpoints(approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Get email from workspace username\n * This method is particularly useful for apps that require the email address of the viewer.\n * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app.\n * @param username\n * @returns email address\n */\nasync usernameToEmail(username: string): Promise\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (s3://storage/key) or record\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\nasync step(name: string, fn: () => T | Promise): Promise\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage\n * URLs before calling this function.\n * \n * @example\n * const urls = await step(\"urls\", () => getResumeUrls());\n * await step(\"notify\", () => sendEmail(urls.approvalPage));\n * const { value, approver } = await waitForApproval({ timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n */\ndatatable(name: string = \"main\"): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n"; export declare const SDK_PYTHON = "# Python SDK (wmill)\n\nImport: import wmill\n\ndef worker_has_internal_server() -> bool\n\ndef get_mocked_api() -> Optional[dict]\n\n# Get the HTTP client instance.\n# \n# Returns:\n# Configured httpx.Client for API requests\ndef get_client() -> httpx.Client\n\n# Make an HTTP GET request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.get\n# \n# Returns:\n# HTTP response object\ndef get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Make an HTTP POST request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.post\n# \n# Returns:\n# HTTP response object\ndef post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Create a new authentication token.\n# \n# Args:\n# duration: Token validity duration (default: 1 day)\n# \n# Returns:\n# New authentication token string\ndef create_token(duration = dt.timedelta(days=1)) -> str\n\n# Create a script job and return its job id.\n# \n# .. deprecated:: Use run_script_by_path_async or run_script_by_hash_async instead.\ndef run_script_async(path: str = None, hash_: str = None, args: dict = None, scheduled_in_secs: int = None) -> str\n\n# Create a script job by path and return its job id.\ndef run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None) -> str\n\n# Create a script job by hash and return its job id.\ndef run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None) -> str\n\n# Create a flow job and return its job id.\ndef run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True) -> str\n\n# Run script synchronously and return its result.\n# \n# .. deprecated:: Use run_script_by_path or run_script_by_hash instead.\ndef run_script(path: str = None, hash_: str = None, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any\n\n# Run script by path synchronously and return its result.\ndef run_script_by_path(path: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any\n\n# Run script by hash synchronously and return its result.\ndef run_script_by_hash(hash_: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False) -> Any\n\n# Run a script on the current worker without creating a job.\n# \n# On agent workers (no internal server), falls back to running a normal\n# preview job and waiting for the result.\ndef run_inline_script_preview(content: str, language: str, args: dict = None) -> Any\n\n# Wait for a job to complete and return its result.\n# \n# Args:\n# job_id: ID of the job to wait for\n# timeout: Maximum time to wait (seconds or timedelta)\n# verbose: Enable verbose logging\n# cleanup: Register cleanup handler to cancel job on exit\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result when completed\n# \n# Raises:\n# TimeoutError: If timeout is reached\n# Exception: If job fails\ndef wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)\n\n# Cancel a specific job by ID.\n# \n# Args:\n# job_id: UUID of the job to cancel\n# reason: Optional reason for cancellation\n# \n# Returns:\n# Response message from the cancel endpoint\ndef cancel_job(job_id: str, reason: str = None) -> str\n\n# Cancel currently running executions of the same script.\ndef cancel_running() -> dict\n\n# Get job details by ID.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job details dictionary\ndef get_job(job_id: str) -> dict\n\n# Get the root job ID for a flow hierarchy.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Root job ID\ndef get_root_job_id(job_id: str | None = None) -> dict\n\n# Get an OIDC JWT token for authentication to external services.\n# \n# Args:\n# audience: Token audience (e.g., \"vault\", \"aws\")\n# expires_in: Optional expiration time in seconds\n# \n# Returns:\n# JWT token string\ndef get_id_token(audience: str, expires_in: int | None = None) -> str\n\n# Get the status of a job.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job status: \"RUNNING\", \"WAITING\", or \"COMPLETED\"\ndef get_job_status(job_id: str) -> JobStatus\n\n# Get the result of a completed job.\n# \n# Args:\n# job_id: UUID of the completed job\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result\ndef get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any\n\n# Get a variable value by path.\n# \n# Args:\n# path: Variable path in Windmill\n# \n# Returns:\n# Variable value as string\ndef get_variable(path: str) -> str\n\n# Set a variable value by path, creating it if it doesn't exist.\n# \n# Args:\n# path: Variable path in Windmill\n# value: Variable value to set\n# is_secret: Whether the variable should be secret (default: False)\ndef set_variable(path: str, value: str, is_secret: bool = False) -> None\n\n# Get a resource value by path.\n# \n# Args:\n# path: Resource path in Windmill\n# none_if_undefined: Return None instead of raising if not found\n# interpolated: if variables and resources are fully unrolled\n# \n# Returns:\n# Resource value dictionary or None\ndef get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None\n\n# Set a resource value by path, creating it if it doesn't exist.\n# \n# Args:\n# value: Resource value to set\n# path: Resource path in Windmill\n# resource_type: Resource type for creation\ndef set_resource(value: Any, path: str, resource_type: str)\n\n# List resources from Windmill workspace.\n# \n# Args:\n# resource_type: Optional resource type to filter by (e.g., \"postgresql\", \"mysql\", \"s3\")\n# page: Optional page number for pagination\n# per_page: Optional number of results per page\n# \n# Returns:\n# List of resource dictionaries\ndef list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]\n\n# Set the workflow state.\n# \n# Args:\n# value: State value to set\n# path: Optional state resource path override.\ndef set_state(value: Any, path: str | None = None) -> None\n\n# Get the workflow state.\n# \n# Args:\n# path: Optional state resource path override.\n# \n# Returns:\n# State value or None if not set\ndef get_state(path: str | None = None) -> Any\n\n# Set job progress percentage (0-99).\n# \n# Args:\n# value: Progress percentage\n# job_id: Job ID (defaults to current WM_JOB_ID)\ndef set_progress(value: int, job_id: Optional[str] = None)\n\n# Get job progress percentage.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Progress value (0-100) or None if not set\ndef get_progress(job_id: Optional[str] = None) -> Any\n\n# Set the user state of a flow at a given key\ndef set_flow_user_state(key: str, value: Any) -> None\n\n# Get the user state of a flow at a given key\ndef get_flow_user_state(key: str) -> Any\n\n# Get the Windmill server version.\n# \n# Returns:\n# Version string\ndef version()\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Load a file from the workspace s3 bucket and returns its content as bytes.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# my_obj_content = client.load_s3_file(s3_obj)\n# file_content = my_obj_content.decode(\"utf-8\")\n# '''\ndef load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes\n\n# Load a file from the workspace s3 bucket and returns the bytes stream.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# with wmill.load_s3_file_reader(s3object, s3_resource_path) as file_reader:\n# print(file_reader.read())\n# '''\ndef load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader\n\n# Write a file to the workspace S3 bucket\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# \n# # for an in memory bytes array:\n# file_content = b'Hello Windmill!'\n# client.write_s3_file(s3_obj, file_content)\n# \n# # for a file:\n# with open(\"my_file.txt\", \"rb\") as my_file:\n# client.write_s3_file(s3_obj, my_file)\n# '''\ndef write_s3_file(s3object: S3Object | str | None, file_content: BufferedReader | bytes, s3_resource_path: str | None, content_type: str | None = None, content_disposition: str | None = None) -> S3Object\n\n# Permanently delete a file from the workspace S3 bucket.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# client.delete_s3_object(s3_obj)\n# '''\ndef delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None\n\n# Sign S3 objects for use by anonymous users in public apps.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# \n# Returns:\n# List of signed S3 objects\ndef sign_s3_objects(s3_objects: list[S3Object | str]) -> list[S3Object]\n\n# Sign a single S3 object for use by anonymous users in public apps.\n# \n# Args:\n# s3_object: S3 object to sign\n# \n# Returns:\n# Signed S3 object\ndef sign_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Generate presigned public URLs for an array of S3 objects.\n# If an S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)\n# \n# Returns:\n# List of signed public URLs\n# \n# Example:\n# >>> s3_objs = [S3Object(s3=\"/path/to/file1.txt\"), S3Object(s3=\"/path/to/file2.txt\")]\n# >>> urls = client.get_presigned_s3_public_urls(s3_objs)\ndef get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None) -> list[str]\n\n# Generate a presigned public URL for an S3 object.\n# If the S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_object: S3 object to sign\n# base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)\n# \n# Returns:\n# Signed public URL\n# \n# Example:\n# >>> s3_obj = S3Object(s3=\"/path/to/file.txt\")\n# >>> url = client.get_presigned_s3_public_url(s3_obj)\ndef get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None) -> str\n\n# Get the current user information.\n# \n# Returns:\n# User details dictionary\ndef whoami() -> dict\n\n# Get the current user information (alias for whoami).\n# \n# Returns:\n# User details dictionary\ndef user() -> dict\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef state_path() -> str\n\n# Get the workflow state.\n# \n# Returns:\n# State value or None if not set\ndef state() -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state_pickle(path: str = 'state.pickle') -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state(value: Any, path: str = 'state.json') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state(path: str = 'state.json') -> None\n\n# Get URLs needed for resuming a flow after suspension.\n# \n# Args:\n# approver: Optional approver name\n# flow_level: If True, generate resume URLs for the parent flow instead of the\n# specific step. This allows pre-approvals that can be consumed by any later\n# suspend step in the same flow.\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_resume_urls(approver: str = None, flow_level: bool = None) -> dict\n\n# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n# \n# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the \"Advanced -> Suspend -> Form\" functionality.\n# Learn more at: https://www.windmill.dev/docs/flows/flow_approval#form\n# \n# :param slack_resource_path: The path to the Slack resource in Windmill.\n# :type slack_resource_path: str\n# :param channel_id: The Slack channel ID where the approval request will be sent.\n# :type channel_id: str\n# :param message: Optional custom message to include in the Slack approval request.\n# :type message: str, optional\n# :param approver: Optional user ID or name of the approver for the request.\n# :type approver: str, optional\n# :param default_args_json: Optional dictionary defining or overriding the default arguments for form fields.\n# :type default_args_json: dict, optional\n# :param dynamic_enums_json: Optional dictionary overriding the enum default values of enum form fields.\n# :type dynamic_enums_json: dict, optional\n# \n# :raises Exception: If the function is not called within a flow or flow preview.\n# :raises Exception: If the required flow job or flow step environment variables are not set.\n# \n# :return: None\n# \n# **Usage Example:**\n# >>> client.request_interactive_slack_approval(\n# ... slack_resource_path=\"/u/alex/my_slack_resource\",\n# ... channel_id=\"admins-slack-channel\",\n# ... message=\"Please approve this request\",\n# ... approver=\"approver123\",\n# ... default_args_json={\"key1\": \"value1\", \"key2\": 42},\n# ... dynamic_enums_json={\"foo\": [\"choice1\", \"choice2\"], \"bar\": [\"optionA\", \"optionB\"]},\n# ... )\n# \n# **Notes:**\n# - This function must be executed within a Windmill flow or flow preview.\n# - The function checks for required environment variables (`WM_FLOW_JOB_ID`, `WM_FLOW_STEP_ID`) to ensure it is run in the appropriate context.\ndef request_interactive_slack_approval(slack_resource_path: str, channel_id: str, message: str = None, approver: str = None, default_args_json: dict = None, dynamic_enums_json: dict = None) -> None\n\n# Get email from workspace username\n# This method is particularly useful for apps that require the email address of the viewer.\n# Indeed, in the viewer context WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app.\ndef username_to_email(username: str) -> str\n\n# Send a message to a Microsoft Teams conversation with conversation_id, where success is used to style the message\ndef send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main')\n\n# Get a DuckLake client for DuckDB queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DucklakeClient instance\ndef ducklake(name: str = 'main')\n\ndef init_global_client(f)\n\ndef deprecate(in_favor_of: str)\n\n# Get the current workspace ID.\n# \n# Returns:\n# Workspace ID string\ndef get_workspace() -> str\n\ndef get_version() -> str\n\n# Run a script synchronously by hash and return its result.\n# \n# Args:\n# hash: Script hash\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# \n# Returns:\n# Script result\ndef run_script_sync(hash: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None) -> Any\n\n# Run a script synchronously by path and return its result.\n# \n# Args:\n# path: Script path\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# \n# Returns:\n# Script result\ndef run_script_by_path_sync(path: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None) -> Any\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef get_state_path() -> str\n\n# Parse resource syntax from string.\ndef parse_resource_syntax(s: str) -> Optional[str]\n\n# Parse S3 object from string or S3Object format.\ndef parse_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Parse variable syntax from string.\ndef parse_variable_syntax(s: str) -> Optional[str]\n\n# Append a text to the result stream.\n# \n# Args:\n# text: text to append to the result stream\ndef append_to_result_stream(text: str) -> None\n\n# Stream to the result stream.\n# \n# Args:\n# stream: stream to stream to the result stream\ndef stream_result(stream) -> None\n\n# Execute a SQL query against the DataTable.\n# \n# Args:\n# sql: SQL query string with $1, $2, etc. placeholders\n# *args: Positional arguments to bind to query placeholders\n# \n# Returns:\n# SqlQuery instance for fetching results\ndef query(sql: str, *args) -> SqlQuery\n\n# Execute query and fetch results.\n# \n# Args:\n# result_collection: Optional result collection mode\n# \n# Returns:\n# Query results\ndef fetch(result_collection: str | None = None)\n\n# Execute query and fetch first row of results.\n# \n# Returns:\n# First row of query results\ndef fetch_one()\n\n# Execute query and fetch first row of results. Return result as a scalar value.\n# \n# Returns:\n# First row of query result as a scalar value\ndef fetch_one_scalar()\n\n# Execute query and don't return any results.\n# \ndef execute()\n\n# DuckDB executor requires explicit argument types at declaration\n# These types exist in both DuckDB and Postgres\n# Check that the types exist if you plan to extend this function for other SQL engines.\ndef infer_sql_type(value) -> str\n\ndef parse_sql_client_name(name: str) -> tuple[str, Optional[str]]\n\n# Decorator that marks a function as a workflow task.\n# \n# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2\n# (async, checkpoint/replay) modes:\n# \n# - **v2 (inside @workflow)**: dispatches as a checkpoint step.\n# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.\n# - **Standalone**: executes the function body directly.\n# \n# Usage::\n# \n# @task\n# async def extract_data(url: str): ...\n# \n# @task(path=\"f/external_script\", timeout=600, tag=\"gpu\")\n# async def run_external(x: int): ...\ndef task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Create a task that dispatches to a separate Windmill script.\n# \n# Usage::\n# \n# extract = task_script(\"f/data/extract\", timeout=600)\n# \n# @workflow\n# async def main():\n# data = await extract(url=\"https://...\")\ndef task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Create a task that dispatches to a separate Windmill flow.\n# \n# Usage::\n# \n# pipeline = task_flow(\"f/etl/pipeline\", priority=10)\n# \n# @workflow\n# async def main():\n# result = await pipeline(input=data)\ndef task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)\n\n# Decorator marking an async function as a workflow-as-code entry point.\n# \n# The function must be **deterministic**: given the same inputs it must call\n# tasks in the same order on every replay. Branching on task results is fine\n# (results are replayed from checkpoint), but branching on external state\n# (current time, random values, external API calls) must use ``step()`` to\n# checkpoint the value so replays see the same result.\ndef workflow(func)\n\n# Execute ``fn`` inline and checkpoint the result.\n# \n# On replay the cached value is returned without re-executing ``fn``.\n# Use for lightweight deterministic operations (timestamps, random IDs,\n# config reads) that should not incur the overhead of a child job.\nasync def step(name: str, fn)\n\n# Server-side sleep \u2014 suspend the workflow for the given duration without holding a worker.\n# \n# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.\n# Outside a workflow, falls back to ``asyncio.sleep``.\nasync def sleep(seconds: int)\n\n# Suspend the workflow and wait for an external approval.\n# \n# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain\n# resume/cancel/approval URLs before calling this function.\n# \n# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.\n# \n# Args:\n# timeout: Approval timeout in seconds (default 1800).\n# form: Optional form schema for the approval page.\n# self_approval: Whether the user who triggered the flow can approve it (default True).\n# \n# Example::\n# \n# urls = await step(\"urls\", lambda: get_resume_urls())\n# await step(\"notify\", lambda: send_email(urls[\"approvalPage\"]))\n# result = await wait_for_approval(timeout=3600)\nasync def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict\n\n# Process items in parallel with optional concurrency control.\n# \n# Each item is processed by calling ``fn(item)``, which should be a @task.\n# Items are dispatched in batches of ``concurrency`` (default: all at once).\n# \n# Example::\n# \n# @task\n# async def process(item: str):\n# ...\n# \n# results = await parallel(items, process, concurrency=5)\nasync def parallel(items, fn, concurrency: Optional[int] = None)\n\n# Commit Kafka offsets for a trigger with auto_commit disabled.\n# \n# Args:\n# trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path'])\n# topic: Kafka topic name (from event['topic'])\n# partition: Partition number (from event['partition'])\n# offset: Message offset to commit (from event['offset'])\ndef commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None\n\n"; @@ -9,24 +9,24 @@ export declare const WAC_SDK_PYTHON = "## Python Workflow-as-Code API (wmill)\n\ export declare const DATATABLE_SDK_TYPESCRIPT = "## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch(\n params?: FetchParams // The union is for auto-completion\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit, \"resultCollection\">\n ): Promise>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit, \"resultCollection\">\n ): Promise;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n (strings: TemplateStringsArray, ...values: any[]): SqlStatement;\n query(sql: string, ...params: any[]): SqlStatement;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n```typescript\nfunction datatable(name: string = \"main\"): DatatableSqlTemplateFunction\n```\n"; export declare const DATATABLE_SDK_PYTHON = "## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main') -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n def __init__(client: Windmill, name: str)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n"; export declare const OPENFLOW_SCHEMA = "## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"The flow will be run with the permissions of the user with this email.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"JavaScript expression to return early from the flow\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff (delay doubles each time)\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Initial delay in seconds\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note (e.g., \\\"yellow\\\", \\\"#ffff00\\\")\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryAuto\":{\"type\":\"object\",\"description\":\"Automatic context management\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"description\":\"Explicit message history\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Conversation memory configuration\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration. Use stop_after_if to control when the loop ends\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this tool does (shown to the AI)\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax.\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"}},\"required\":[\"provider\",\"user_message\",\"output_type\"]},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"tools\",\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}"; -export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push ` - push a local app \n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.\n - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.\n - `--framework ` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.\n - `--datatable ` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.\n - `--schema ` - Schema to use with --datatable. Created (CREATE SCHEMA IF NOT EXISTS) if it doesn't already exist.\n - `--overwrite` - Overwrite the target directory if it already exists, without prompting.\n - `--no-open-in-desktop` - Do not prompt to open the new app in Claude Desktop.\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app set-permissioned-as ` - Set the on_behalf_of_email for an app (requires admin or wm_deployers group)\n\n### audit\n\nView audit logs (requires admin)\n\n**Subcommands:**\n\n- `audit list` - List audit log entries\n- `audit get ` - Get a specific audit log entry\n - `--json` - Output as JSON (for piping to jq)\n\n### config\n\nShow all available wmill.yaml configuration options\n\n**Options:**\n- `--json` - Output as JSON for programmatic consumption\n\n**Subcommands:**\n\n- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nWatch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace \u2014 use wmill sync push for that.\n\n**Options:**\n- `--includes ` - Filter paths given a glob pattern or path\n- `--proxy-port ` - Port for a localhost reverse proxy to the remote Windmill server\n- `--path ` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)\n- `--no-open` - Do not open the browser automatically\n\n### docs\n\nSearch Windmill documentation.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n - `--message ` - Deployment message\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new)\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow history ` - Show version history for a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow show-version ` - Show a specific version of a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow set-permissioned-as ` - Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n- `folder show-rules ` - Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path.\n - `--test-path ` - Test which rule matches this item path (e.g. f/prod/jobs/my_script)\n - `--json` - Output as JSON\n\n### generate-metadata\n\nGenerate metadata (locks, schemas) for all scripts, flows, and apps\n\n**Arguments:** `[folder:string]`\n\n**Options:**\n- `--yes` - Skip confirmation prompt\n- `--dry-run` - Show what would be updated without making changes\n- `--lock-only` - Re-generate only the lock files\n- `--schema-only` - Re-generate only script schemas (skips flows and apps)\n- `--skip-scripts` - Skip processing scripts\n- `--skip-flows` - Skip processing flows\n- `--skip-apps` - Skip processing apps\n- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument)\n- `-i --includes ` - Comma separated patterns to specify which files to include\n- `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### group\n\nManage workspace groups\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `group list` - List all groups in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `group get ` - Get group details and members\n - `--json` - Output as JSON (for piping to jq)\n- `group create ` - Create a new group\n - `--summary ` - Group summary/description\n- `group delete ` - Delete a group\n- `group add-user ` - Add a user to a group\n- `group remove-user ` - Remove a user from a group\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting\n - `--instance ` - Name of the instance, override the active instance\n- `instance connect-slack`\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n - `--instance ` - Instance profile to connect against (defaults to the active instance)\n\n### job\n\nManage jobs (list, inspect, cancel)\n\n**Subcommands:**\n\n- `job list` - List recent jobs\n- `job get ` - Get job details. For flows: shows step tree with sub-job IDs\n - `--json` - Output as JSON (for piping to jq)\n- `job result ` - Get the result of a completed job (machine-friendly)\n- `job logs ` - Get job logs. For flows: aggregates all step logs\n- `job cancel ` - Cancel a running or queued job\n - `--reason ` - Reason for cancellation\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n- `-w, --watch` - Watch for file changes and re-lint automatically\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n- `schedule enable ` - Enable a schedule\n- `schedule disable ` - Disable a schedule\n- `schedule set-permissioned-as ` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Show archived scripts instead of active ones\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Show archived scripts instead of active ones\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)\n - `--message ` - Deployment message\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get)\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new)\n - `--summary ` - script summary\n - `--description ` - script description\n- `script set-permissioned-as ` - Set the on_behalf_of_email for a script (requires admin or wm_deployers group)\n- `script history ` - show version history for a script\n - `--json` - Output as JSON (for piping to jq)\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing\n - `--accept-overriding-permissioned-as-with-self` - Accept that items with a different permissioned_as will be updated with your own user\n\n### token\n\nManage API tokens\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `token list` - List API tokens\n - `--json` - Output as JSON (for piping to jq)\n- `token create` - Create a new API token\n - `--label ` - Token label\n - `--expiration ` - Token expiration (ISO 8601 timestamp)\n- `token delete ` - Delete a token by its prefix\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n- `trigger set-permissioned-as ` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group)\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token` - Create a new API token for the authenticated user\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push worker groups (similar to `wmill instance push --skip-users --skip-settings --skip-groups`)\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n- `workspace list-forks` - List forked workspaces on the remote server\n- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile\n - `--workspace ` - Workspace name (default: current branch or workspaceId)\n - `--branch ` - Git branch to associate (default: workspace name)\n- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry\n - `--workspace ` - Workspace to unbind\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--color ` - Workspace color (hex code, e.g. #ff0000)\n - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)\n - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n- `workspace merge` - Compare and deploy changes between a fork and its parent workspace\n - `--direction ` - Deploy direction: to-parent or to-fork\n - `--all` - Deploy all changed items including conflicts\n - `--skip-conflicts` - Skip items modified in both workspaces\n - `--include ` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)\n - `--exclude ` - Comma-separated kind:path items to exclude\n - `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values\n - `-y --yes` - Non-interactive mode (deploy without prompts)\n- `workspace connect-slack` - Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token.\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n- `workspace disconnect-slack`\n\n"; +export declare const CLI_COMMANDS = "# Windmill CLI Commands\n\nThe Windmill CLI (`wmill`) provides commands for managing scripts, flows, apps, and other resources.\n\n## Global Options\n\n- `--workspace ` - Specify the target workspace. This overrides the default workspace.\n- `--debug --verbose` - Show debug/verbose logs\n- `--show-diffs` - Show diff informations when syncing (may show sensitive informations)\n- `--token ` - Specify an API token. This will override any stored token.\n- `--base-url ` - Specify the base URL of the API. If used, --token and --workspace are required and no local remote/workspace already set will be used.\n- `--config-dir ` - Specify a custom config directory. Overrides WMILL_CONFIG_DIR environment variable and default ~/.config location.\n\n## Commands\n\n### app\n\napp related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `app list` - list all apps\n - `--json` - Output as JSON (for piping to jq)\n- `app get ` - get an app's details\n - `--json` - Output as JSON (for piping to jq)\n- `app push [file_path:string] [remote_path:string]` - push a local app. With no args, infers the app from the current directory and the remote path from its location relative to wmill.yaml.\n- `app dev [app_folder:string]` - Start a development server for building apps with live reload and hot module replacement\n - `--port ` - Port to run the dev server on (will find next available port if occupied)\n - `--host ` - Host to bind the dev server to\n - `--entry ` - Entry point file (default: index.ts for Svelte/Vue, index.tsx otherwise)\n - `--no-open` - Don't automatically open the browser\n- `app lint [app_folder:string]` - Lint a raw app folder to validate structure and buildability\n - `--fix` - Attempt to fix common issues (not implemented yet)\n- `app new` - create a new raw app from a template\n - `--summary ` - App summary (short description). Skips the prompt when provided. Triggers non-interactive mode.\n - `--path ` - App path (e.g., f/folder/my_app or u/username/my_app). Skips the prompt when provided. Triggers non-interactive mode.\n - `--framework ` - Framework template: react19 | react18 | svelte5 | vue. Skips the prompt when provided. Triggers non-interactive mode.\n - `--datatable ` - Datatable to wire up. Without this flag in non-interactive mode, no datatable is configured.\n - `--schema ` - Schema to use with --datatable. Created (CREATE SCHEMA IF NOT EXISTS) if it doesn't already exist.\n - `--overwrite` - Overwrite the target directory if it already exists, without prompting.\n - `--no-open-in-desktop` - Do not prompt to open the new app in Claude Desktop.\n- `app generate-agents [app_folder:string]` - regenerate AGENTS.md and DATATABLES.md from remote workspace\n- `app set-permissioned-as ` - Set the on_behalf_of_email for an app (requires admin or wm_deployers group)\n\n### audit\n\nView audit logs (requires admin)\n\n**Subcommands:**\n\n- `audit list` - List audit log entries\n- `audit get ` - Get a specific audit log entry\n - `--json` - Output as JSON (for piping to jq)\n\n### config\n\nShow all available wmill.yaml configuration options\n\n**Options:**\n- `--json` - Output as JSON for programmatic consumption\n\n**Subcommands:**\n\n- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format\n\n### dependencies\n\nworkspace dependencies related commands\n\n**Alias:** `deps`\n\n**Subcommands:**\n\n- `dependencies push ` - Push workspace dependencies from a local file\n\n### dev\n\nWatch local file changes and live-reload the dev page for preview. Does NOT deploy to the remote workspace \u2014 use wmill sync push for that.\n\n**Options:**\n- `--includes ` - Filter paths given a glob pattern or path\n- `--proxy-port ` - Port for a localhost reverse proxy to the remote Windmill server\n- `--path ` - Watch a specific windmill path (e.g., u/admin/my_script or f/my_flow)\n- `--no-open` - Do not open the browser automatically\n\n### docs\n\nSearch Windmill documentation.\n\n**Arguments:** ``\n\n**Options:**\n- `--json` - Output results as JSON.\n\n### flow\n\nflow related commands\n\n**Options:**\n- `--show-archived` - Enable archived flows in output\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `flow list` - list all flows\n - `--show-archived` - Enable archived flows in output\n - `--json` - Output as JSON (for piping to jq)\n- `flow get ` - get a flow's details\n - `--json` - Output as JSON (for piping to jq)\n- `flow push ` - push a local flow spec. This overrides any remote versions.\n - `--message ` - Deployment message\n- `flow run ` - run a flow by path.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not ouput anything other then the final output. Useful for scripting.\n- `flow preview ` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n - `--remote` - Use deployed workspace scripts for PathScript steps instead of local files.\n- `flow new ` - create a new empty flow\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow bootstrap ` - create a new empty flow (alias for new)\n - `--summary ` - flow summary\n - `--description ` - flow description\n- `flow history ` - Show version history for a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow show-version ` - Show a specific version of a flow\n - `--json` - Output as JSON (for piping to jq)\n- `flow set-permissioned-as ` - Set the on_behalf_of_email for a flow (requires admin or wm_deployers group)\n\n### folder\n\nfolder related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `folder list` - list all folders\n - `--json` - Output as JSON (for piping to jq)\n- `folder get ` - get a folder's details\n - `--json` - Output as JSON (for piping to jq)\n- `folder new ` - create a new folder locally\n - `--summary ` - folder summary\n- `folder push ` - push a local folder to the remote by name. This overrides any remote versions.\n- `folder add-missing` - create default folder.meta.yaml for all subdirectories of f/ that are missing one\n - `-y, --yes` - skip confirmation prompt\n- `folder show-rules ` - Show default_permissioned_as rules for a folder. Use --test-path to see which rule matches a given item path.\n - `--test-path ` - Test which rule matches this item path (e.g. f/prod/jobs/my_script)\n - `--json` - Output as JSON\n\n### generate-metadata\n\nGenerate metadata (locks, schemas) for all scripts, flows, and apps\n\n**Arguments:** `[folder:string]`\n\n**Options:**\n- `--yes` - Skip confirmation prompt\n- `--dry-run` - Show what would be updated without making changes\n- `--lock-only` - Re-generate only the lock files\n- `--schema-only` - Re-generate only script schemas (skips flows and apps)\n- `--skip-scripts` - Skip processing scripts\n- `--skip-flows` - Skip processing flows\n- `--skip-apps` - Skip processing apps\n- `--strict-folder-boundaries` - Only update items inside the specified folder (requires folder argument)\n- `--parallel ` - Number of items to process in parallel\n- `-i --includes ` - Comma separated patterns to specify which files to include\n- `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n**Subcommands:**\n\n- `generate-metadata rehash [folder:string]`\n - `--skip-scripts` - Skip processing scripts\n - `--skip-flows` - Skip processing flows\n - `--skip-apps` - Skip processing apps\n - `--parallel ` - Number of items to process in parallel\n - `-i --includes ` - Comma separated patterns to specify which files to include\n - `-e --excludes ` - Comma separated patterns to specify which files to exclude\n\n### gitsync-settings\n\nManage git-sync settings between local wmill.yaml and Windmill backend\n\n**Subcommands:**\n\n- `gitsync-settings pull` - Pull git-sync settings from Windmill backend to local wmill.yaml\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--default` - Write settings to top-level defaults instead of overrides\n - `--replace` - Replace existing settings (non-interactive mode)\n - `--override` - Add branch-specific override (non-interactive mode)\n - `--diff` - Show differences without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n- `gitsync-settings push` - Push git-sync settings from local wmill.yaml to Windmill backend\n - `--repository ` - Specify repository path (e.g., u/user/repo)\n - `--diff` - Show what would be pushed without applying changes\n - `--json-output` - Output in JSON format\n - `--with-backend-settings ` - Use provided JSON settings instead of querying backend (for testing)\n - `--yes` - Skip interactive prompts and use default behavior\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n\n### group\n\nManage workspace groups\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `group list` - List all groups in the workspace\n - `--json` - Output as JSON (for piping to jq)\n- `group get ` - Get group details and members\n - `--json` - Output as JSON (for piping to jq)\n- `group create ` - Create a new group\n - `--summary ` - Group summary/description\n- `group delete ` - Delete a group\n- `group add-user ` - Add a user to a group\n- `group remove-user ` - Remove a user from a group\n\n### hub\n\nHub related commands. EXPERIMENTAL. INTERNAL USE ONLY.\n\n**Subcommands:**\n\n- `hub pull` - pull any supported definitions. EXPERIMENTAL.\n\n### init\n\nBootstrap a windmill project with a wmill.yaml file\n\n**Options:**\n- `--use-default` - Use default settings without checking backend\n- `--use-backend` - Use backend git-sync settings if available\n- `--repository ` - Specify repository path (e.g., u/user/repo) when using backend settings\n- `--bind-profile` - Automatically bind active workspace profile to current Git branch\n- `--no-bind-profile` - Skip workspace profile binding prompt\n\n### instance\n\nsync local with a remote instance or the opposite (push or pull)\n\n**Subcommands:**\n\n- `instance add [instance_name:string] [remote:string] [token:string]` - Add a new instance\n- `instance remove ` - Remove an instance\n- `instance switch ` - Switch the current instance\n- `instance pull` - Pull instance settings, users, configs, instance groups and overwrite local\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pulling users\n - `--skip-settings` - Skip pulling settings\n - `--skip-configs` - Skip pulling configs (worker groups)\n - `--skip-groups` - Skip pulling instance groups\n - `--include-workspaces` - Also pull workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to pull from, override the active instance\n - `--prefix ` - Prefix of the local workspaces to pull, used to create the folders when using --include-workspaces\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance push` - Push instance settings, users, configs, group and overwrite remote\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Perform a dry run without making changes\n - `--skip-users` - Skip pushing users\n - `--skip-settings` - Skip pushing settings\n - `--skip-configs` - Skip pushing configs (worker groups)\n - `--skip-groups` - Skip pushing instance groups\n - `--include-workspaces` - Also push workspaces\n - `--folder-per-instance` - Create a folder per instance\n - `--instance ` - Name of the instance to push to, override the active instance\n - `--prefix ` - Prefix of the local workspaces folders to push\n - `--prefix-settings` - Store instance yamls inside prefixed folders when using --prefix and --folder-per-instance\n- `instance whoami` - Display information about the currently logged-in user\n- `instance get-config` - Dump the current instance config (global settings + worker configs) as YAML\n - `-o, --output-file ` - Write YAML to a file instead of stdout\n - `--show-secrets` - Include sensitive fields (license key, JWT secret) without prompting\n - `--instance ` - Name of the instance, override the active instance\n- `instance connect-slack`\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n - `--instance ` - Instance profile to connect against (defaults to the active instance)\n\n### job\n\nManage jobs (list, inspect, cancel)\n\n**Subcommands:**\n\n- `job list` - List recent jobs\n- `job get ` - Get job details. For flows: shows step tree with sub-job IDs\n - `--json` - Output as JSON (for piping to jq)\n- `job result ` - Get the result of a completed job (machine-friendly)\n- `job logs ` - Get job logs. For flows: aggregates all step logs\n- `job cancel ` - Cancel a running or queued job\n - `--reason ` - Reason for cancellation\n\n### jobs\n\nPull completed and queued jobs from workspace\n\n**Arguments:** `[workspace:string]`\n\n**Options:**\n- `-c, --completed-output ` - Completed jobs output file (default: completed_jobs.json)\n- `-q, --queued-output ` - Queued jobs output file (default: queued_jobs.json)\n- `--skip-worker-check` - Skip checking for active workers before export\n\n**Subcommands:**\n\n- `jobs pull`\n- `jobs push`\n\n### lint\n\nValidate Windmill flow, schedule, and trigger YAML files in a directory\n\n**Arguments:** `[directory:string]`\n\n**Options:**\n- `--json` - Output results in JSON format\n- `--fail-on-warn` - Exit with code 1 when warnings are emitted\n- `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n- `-w, --watch` - Watch for file changes and re-lint automatically\n\n### queues\n\nList all queues with their metrics\n\n**Arguments:** `[workspace:string] the optional workspace to filter by (default to all workspaces)`\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### resource\n\nresource related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource list` - list all resources\n - `--json` - Output as JSON (for piping to jq)\n- `resource get ` - get a resource's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource new ` - create a new resource locally\n- `resource push ` - push a local resource spec. This overrides any remote versions.\n\n### resource-type\n\nresource type related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `resource-type list` - list all resource types\n - `--schema` - Show schema in the output\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type get ` - get a resource type's details\n - `--json` - Output as JSON (for piping to jq)\n- `resource-type new ` - create a new resource type locally\n- `resource-type push ` - push a local resource spec. This overrides any remote versions.\n- `resource-type generate-namespace` - Create a TypeScript definition file with the RT namespace generated from the resource types\n\n### schedule\n\nschedule related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `schedule list` - list all schedules\n - `--json` - Output as JSON (for piping to jq)\n- `schedule get ` - get a schedule's details\n - `--json` - Output as JSON (for piping to jq)\n- `schedule new ` - create a new schedule locally\n- `schedule push ` - push a local schedule spec. This overrides any remote versions.\n- `schedule enable ` - Enable a schedule\n - `--force` - Bypass the fork-conflict warning when the parent workspace has the same schedule (acknowledges that both crons will fire)\n- `schedule disable ` - Disable a schedule\n- `schedule set-permissioned-as ` - Set the email (run-as user) for a schedule (requires admin or wm_deployers group)\n\n### script\n\nscript related commands\n\n**Options:**\n- `--show-archived` - Show archived scripts instead of active ones\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `script list` - list all scripts\n - `--show-archived` - Show archived scripts instead of active ones\n - `--json` - Output as JSON (for piping to jq)\n- `script push ` - push a local script spec. This overrides any remote versions. Use the script file (.ts, .js, .py, .sh)\n - `--message ` - Deployment message\n- `script get ` - get a script's details\n - `--json` - Output as JSON (for piping to jq)\n- `script show ` - show a script's content (alias for get)\n- `script run ` - run a script by path\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other then the final output. Useful for scripting.\n- `script preview ` - preview a local script without deploying it. Supports both regular and codebase scripts.\n - `-d --data ` - Inputs specified as a JSON string or a file using @ or stdin using @-.\n - `-s --silent` - Do not output anything other than the final output. Useful for scripting.\n- `script new ` - create a new script\n - `--summary ` - script summary\n - `--description ` - script description\n- `script bootstrap ` - create a new script (alias for new)\n - `--summary ` - script summary\n - `--description ` - script description\n- `script set-permissioned-as ` - Set the on_behalf_of_email for a script (requires admin or wm_deployers group)\n- `script history ` - show version history for a script\n - `--json` - Output as JSON (for piping to jq)\n\n### sync\n\nsync local with a remote workspaces or the opposite (push or pull)\n\n**Subcommands:**\n\n- `sync pull` - Pull any remote changes and apply them locally.\n - `--yes` - Pull without needing confirmation\n - `--dry-run` - Show changes that would be pulled without actually pushing\n - `--plain-secrets` - Pull secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Overrides wmill.yaml includes\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account. Overrides wmill.yaml excludes\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n- `sync push` - Push any local changes and apply them remotely.\n - `--yes` - Push without needing confirmation\n - `--dry-run` - Show changes that would be pushed without actually pushing\n - `--plain-secrets` - Push secrets as plain text\n - `--json` - Use JSON instead of YAML\n - `--skip-variables` - Skip syncing variables (including secrets)\n - `--skip-secrets` - Skip syncing only secrets variables\n - `--include-secrets` - Include secrets in sync (overrides skipSecrets in wmill.yaml)\n - `--skip-resources` - Skip syncing resources\n - `--skip-resource-types` - Skip syncing resource types\n - `--skip-scripts` - Skip syncing scripts\n - `--skip-flows` - Skip syncing flows\n - `--skip-apps` - Skip syncing apps\n - `--skip-folders` - Skip syncing folders\n - `--skip-workspace-dependencies` - Skip syncing workspace dependencies\n - `--skip-scripts-metadata` - Skip syncing scripts metadata, focus solely on logic\n - `--include-schedules` - Include syncing schedules\n - `--include-triggers` - Include syncing triggers\n - `--include-users` - Include syncing users\n - `--include-groups` - Include syncing groups\n - `--include-settings` - Include syncing workspace settings\n - `--include-key` - Include workspace encryption key\n - `--skip-branch-validation` - Skip git branch validation and prompts\n - `--json-output` - Output results in JSON format\n - `-i --includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string)\n - `-e --excludes ` - Comma separated patterns to specify which file to NOT take into account.\n - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy\n - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push\n - `--parallel ` - Number of changes to process in parallel\n - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist\n - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment\n - `--lint` - Run lint validation before pushing\n - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks\n - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing\n - `--accept-overriding-permissioned-as-with-self` - Accept that items with a different permissioned_as will be updated with your own user\n\n### token\n\nManage API tokens\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `token list` - List API tokens\n - `--json` - Output as JSON (for piping to jq)\n- `token create` - Create a new API token\n - `--label ` - Token label\n - `--expiration ` - Token expiration (ISO 8601 timestamp)\n- `token delete ` - Delete a token by its prefix\n\n### trigger\n\ntrigger related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `trigger list` - list all triggers\n - `--json` - Output as JSON (for piping to jq)\n- `trigger get ` - get a trigger's details\n - `--json` - Output as JSON (for piping to jq)\n - `--kind ` - Trigger kind (http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email). Recommended for faster lookup\n- `trigger new ` - create a new trigger locally\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n- `trigger push ` - push a local trigger spec. This overrides any remote versions.\n- `trigger set-permissioned-as ` - Set the email (run-as user) for a trigger (requires admin or wm_deployers group)\n - `--kind ` - Trigger kind (required: http, websocket, kafka, nats, postgres, mqtt, sqs, gcp, azure, email)\n\n### user\n\nuser related commands\n\n**Subcommands:**\n\n- `user add [password:string]` - Create a user\n - `--superadmin` - Specify to make the new user superadmin.\n - `--company ` - Specify to set the company of the new user.\n - `--name ` - Specify to set the name of the new user.\n- `user remove ` - Delete a user\n- `user create-token` - Create a new API token for the authenticated user\n - `--email ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n - `--password ` - Specify credentials to use for authentication. This will not be stored. It will only be used to exchange for a token with the API server, which will not be stored either.\n\n### variable\n\nvariable related commands\n\n**Options:**\n- `--json` - Output as JSON (for piping to jq)\n\n**Subcommands:**\n\n- `variable list` - list all variables\n - `--json` - Output as JSON (for piping to jq)\n- `variable get ` - get a variable's details\n - `--json` - Output as JSON (for piping to jq)\n- `variable new ` - create a new variable locally\n- `variable push ` - Push a local variable spec. This overrides any remote versions.\n - `--plain-secrets` - Push secrets as plain text\n- `variable add ` - Create a new variable on the remote. This will update the variable if it already exists.\n - `--plain-secrets` - Push secrets as plain text\n - `--public` - Legacy option, use --plain-secrets instead\n\n### version\n\nShow version information\n\n### worker-groups\n\ndisplay worker groups, pull and push worker groups configs\n\n**Subcommands:**\n\n- `worker-groups pull` - Pull worker groups (similar to `wmill instance pull --skip-users --skip-settings --skip-groups`)\n - `--instance` - Name of the instance to push to, override the active instance\n - `--base-url` - Base url to be passed to the instance settings instead of the local one\n - `--yes` - Pull without needing confirmation\n- `worker-groups push` - Push worker groups (similar to `wmill instance push --skip-users --skip-settings --skip-groups`)\n - `--instance [instance]` - Name of the instance to push to, override the active instance\n - `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n - `--yes` - Push without needing confirmation\n\n### workers\n\nList all workers grouped by worker groups\n\n**Options:**\n- `--instance [instance]` - Name of the instance to push to, override the active instance\n- `--base-url [baseUrl]` - If used with --token, will be used as the base url for the instance\n\n### workspace\n\nworkspace related commands\n\n**Alias:** `profile`\n\n**Subcommands:**\n\n- `workspace switch ` - Switch to another workspace\n- `workspace add [workspace_name:string] [workspace_id:string] [remote:string]` - Add a workspace\n - `-c --create` - Create the workspace if it does not exist\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--create-username ` - Specify your own username in the newly created workspace. Ignored if --create is not specified, the workspace already exists or automatic username creation is enabled on the instance.\n- `workspace remove ` - Remove a workspace\n- `workspace whoami` - Show the currently active user\n- `workspace list` - List local workspace profiles\n- `workspace list-remote` - List workspaces on the remote server that you have access to\n - `--as-superadmin` - List ALL workspaces on the instance (requires the token to belong to a superadmin/devops user)\n- `workspace list-forks` - List forked workspaces on the remote server\n- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile\n - `--workspace ` - Workspace name (default: current branch or workspaceId)\n - `--branch ` - Git branch to associate (default: workspace name)\n- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry\n - `--workspace ` - Workspace to unbind\n- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace\n - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.\n - `--color ` - Workspace color (hex code, e.g. #ff0000)\n - `--datatable-behavior ` - How to handle datatables: skip, schema_only, or schema_and_data (default: interactive prompt)\n - `-y --yes` - Skip interactive prompts (defaults datatable behavior to 'skip')\n- `workspace delete-fork ` - Delete a forked workspace and git branch\n - `-y --yes` - Skip confirmation prompt\n- `workspace merge` - Compare and deploy changes between a fork and its parent workspace\n - `--direction ` - Deploy direction: to-parent or to-fork\n - `--all` - Deploy all changed items including conflicts\n - `--skip-conflicts` - Skip items modified in both workspaces\n - `--include ` - Comma-separated kind:path items to include (e.g. script:f/test/main,flow:f/my/flow)\n - `--exclude ` - Comma-separated kind:path items to exclude\n - `--preserve-on-behalf-of` - Preserve original on_behalf_of/permissioned_as values\n - `-y --yes` - Non-interactive mode (deploy without prompts)\n- `workspace connect-slack` - Non-interactively connect Slack to the active workspace using a pre-minted bot token (xoxb-...). Produces the same artifacts as the UI OAuth flow: workspace_settings fields, g/slack group, f/slack_bot folder, and the encrypted bot token variable + resource at f/slack_bot/bot_token.\n - `--bot-token ` - Slack bot token (xoxb-...)\n - `--team-id ` - Slack team id\n - `--team-name ` - Slack team name\n- `workspace disconnect-slack`\n\n"; export declare const LANG_BASH = "# Bash\n\n## Structure\n\nDo not include `#!/bin/bash`. Arguments are obtained as positional parameters:\n\n```bash\n# Get arguments\nvar1=\"$1\"\nvar2=\"$2\"\n\necho \"Processing $var1 and $var2\"\n\n# Return JSON by echoing to stdout\necho \"{\\\"result\\\": \\\"$var1\\\", \\\"count\\\": $var2}\"\n```\n\n**Important:**\n- Do not include shebang (`#!/bin/bash`)\n- Arguments are always strings\n- Access with `$1`, `$2`, etc.\n\n## Output\n\nThe script output is captured as the result. For structured data, output valid JSON:\n\n```bash\nname=\"$1\"\ncount=\"$2\"\n\n# Output JSON result\ncat << EOF\n{\n \"name\": \"$name\",\n \"count\": $count,\n \"timestamp\": \"$(date -Iseconds)\"\n}\nEOF\n```\n\n## Environment Variables\n\nEnvironment variables set in Windmill are available:\n\n```bash\n# Access environment variable\necho \"Workspace: $WM_WORKSPACE\"\necho \"Job ID: $WM_JOB_ID\"\n```\n"; -export declare const LANG_BIGQUERY = "# BigQuery\n\nArguments use `@name` syntax.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @name1 (string)\n-- @name2 (int64) = 0\nSELECT * FROM users WHERE name = @name1 AND age > @name2;\n```\n"; -export declare const LANG_BUN = "# TypeScript (Bun)\n\nBun runtime with full npm ecosystem and fastest execution.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\n### S3Object Type\n\nThe S3Object type represents a file in S3 storage:\n\n```typescript\ntype S3Object = {\n s3: string; // Path within the bucket\n};\n```\n\n## TypeScript Operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; -export declare const LANG_BUNNATIVE = "# TypeScript (Bun Native)\n\nNative TypeScript execution with fetch only - no external imports allowed.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n**No imports allowed.** Use the globally available `fetch` function:\n\n```typescript\nexport async function main(url: string) {\n const response = await fetch(url);\n return await response.json();\n}\n```\n\n## Windmill Client\n\nThe windmill client is not available in native TypeScript mode. Use fetch to call APIs directly.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\n### S3Object Type\n\nThe S3Object type represents a file in S3 storage:\n\n```typescript\ntype S3Object = {\n s3: string; // Path within the bucket\n};\n```\n\n## TypeScript Operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; +export declare const LANG_BIGQUERY = "# BigQuery\n\nArguments use `@name` syntax.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @name1 (string)\n-- @name2 (int64) = 0\nSELECT * FROM users WHERE name = @name1 AND age > @name2;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as a `STRING` JSON parameter \u2014 Parquet/CSV\nfiles are decoded server-side into a JSON array of records, JSON/JSONL pass\nthrough. Consume with `JSON_EXTRACT_ARRAY` / `JSON_VALUE`:\n\n```sql\n-- @file (s3object)\nSELECT\n CAST(JSON_VALUE(row, '$.id') AS INT64) AS id,\n JSON_VALUE(row, '$.name') AS name\nFROM UNNEST(JSON_EXTRACT_ARRAY(@file)) AS row;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage \u2014\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets \u2014 rows stream directly to S3 instead of\nbeing buffered, bypassing the 10000-row return cap.\n"; +export declare const LANG_BUN = "# TypeScript (Bun)\n\nBun runtime with full npm ecosystem and fastest execution.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n```typescript\nimport Stripe from \"stripe\";\nimport { someFunction } from \"some-package\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form \u2014 always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; +export declare const LANG_BUNNATIVE = "# TypeScript (Bun Native)\n\nNative TypeScript execution with fetch only - no external imports allowed.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n**No imports allowed.** Use the globally available `fetch` function:\n\n```typescript\nexport async function main(url: string) {\n const response = await fetch(url);\n return await response.json();\n}\n```\n\n## Windmill Client\n\nThe windmill client is not available in native TypeScript mode. Use fetch to call APIs directly.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form \u2014 always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; export declare const LANG_CSHARP = "# C#\n\nThe script must contain a public static `Main` method inside a class:\n\n```csharp\npublic class Script\n{\n public static object Main(string name, int count)\n {\n return new { Name = name, Count = count };\n }\n}\n```\n\n**Important:**\n- Class name is irrelevant\n- Method must be `public static`\n- Return type can be `object` or specific type\n\n## NuGet Packages\n\nAdd packages using the `#r` directive at the top:\n\n```csharp\n#r \"nuget: Newtonsoft.Json, 13.0.3\"\n#r \"nuget: RestSharp, 110.2.0\"\n\nusing Newtonsoft.Json;\nusing RestSharp;\n\npublic class Script\n{\n public static object Main(string url)\n {\n var client = new RestClient(url);\n var request = new RestRequest();\n var response = client.Get(request);\n return JsonConvert.DeserializeObject(response.Content);\n }\n}\n```\n"; -export declare const LANG_DENO = "# TypeScript (Deno)\n\nDeno runtime with npm support via `npm:` prefix and native Deno libraries.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n```typescript\n// npm packages use npm: prefix\nimport Stripe from \"npm:stripe\";\nimport { someFunction } from \"npm:some-package\";\n\n// Deno standard library\nimport { serve } from \"https://deno.land/std/http/server.ts\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\n### S3Object Type\n\nThe S3Object type represents a file in S3 storage:\n\n```typescript\ntype S3Object = {\n s3: string; // Path within the bucket\n};\n```\n\n## TypeScript Operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; -export declare const LANG_DUCKDB = "# DuckDB\n\nArguments are defined with comments and used with `$name` syntax:\n\n```sql\n-- $name (text) = default\n-- $age (integer)\nSELECT * FROM users WHERE name = $name AND age > $age;\n```\n\n## Ducklake Integration\n\nAttach Ducklake for data lake operations:\n\n```sql\n-- Main ducklake\nATTACH 'ducklake' AS dl;\n\n-- Named ducklake\nATTACH 'ducklake://my_lake' AS dl;\n\n-- Then query\nSELECT * FROM dl.schema.table;\n```\n\n## External Database Connections\n\nConnect to external databases using resources:\n\n```sql\nATTACH '$res:path/to/resource' AS db (TYPE postgres);\nSELECT * FROM db.schema.table;\n```\n\n## S3 File Operations\n\nRead files from S3 storage:\n\n```sql\n-- Default storage\nSELECT * FROM read_csv('s3:///path/to/file.csv');\n\n-- Named storage\nSELECT * FROM read_csv('s3://storage_name/path/to/file.csv');\n\n-- Parquet files\nSELECT * FROM read_parquet('s3:///path/to/file.parquet');\n\n-- JSON files\nSELECT * FROM read_json('s3:///path/to/file.json');\n```\n"; +export declare const LANG_DENO = "# TypeScript (Deno)\n\nDeno runtime with npm support via `npm:` prefix and native Deno libraries.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n```typescript\n// npm packages use npm: prefix\nimport Stripe from \"npm:stripe\";\nimport { someFunction } from \"npm:some-package\";\n\n// Deno standard library\nimport { serve } from \"https://deno.land/std/http/server.ts\";\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```typescript\nimport * as wmill from \"windmill-client\";\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form \u2014 always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n"; +export declare const LANG_DUCKDB = "# DuckDB\n\nArguments are defined with comments and used with `$name` syntax:\n\n```sql\n-- $name (text) = default\n-- $age (integer)\nSELECT * FROM users WHERE name = $name AND age > $age;\n```\n\n## Ducklake Integration\n\nAttach Ducklake for data lake operations:\n\n```sql\n-- Main ducklake\nATTACH 'ducklake' AS dl;\n\n-- Named ducklake\nATTACH 'ducklake://my_lake' AS dl;\n\n-- Then query\nSELECT * FROM dl.schema.table;\n```\n\n## External Database Connections\n\nConnect to external databases using resources:\n\n```sql\nATTACH '$res:path/to/resource' AS db (TYPE postgres);\nSELECT * FROM db.schema.table;\n```\n\n## S3 File Operations\n\nRead files from S3 storage:\n\n```sql\n-- Default storage\nSELECT * FROM read_csv('s3:///path/to/file.csv');\n\n-- Named storage\nSELECT * FROM read_csv('s3://storage_name/path/to/file.csv');\n\n-- Parquet files\nSELECT * FROM read_parquet('s3:///path/to/file.parquet');\n\n-- JSON files\nSELECT * FROM read_json('s3:///path/to/file.json');\n```\n\n### Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for it\nand binds the arg as the bare `s3://storage/key` URI, which DuckDB's reader\nfunctions consume directly:\n\n```sql\n-- $file (s3object)\nSELECT * FROM read_parquet($file);\n```\n\nWorks with any DuckDB reader: `read_csv($file)`, `read_json($file)`, etc.\n\n### Writing query results to S3\n\nDuckDB writes to S3 natively via `COPY ... TO`:\n\n```sql\nCOPY (SELECT * FROM users) TO 's3:///exports/users.parquet' (FORMAT PARQUET);\n```\n\nUse this instead of the `-- s3` streaming directive supported by the other SQL\ndialects \u2014 that directive is not available in DuckDB.\n"; export declare const LANG_GO = "# Go\n\n## Structure\n\nThe file package must be `inner` and export a function called `main`:\n\n```go\npackage inner\n\nfunc main(param1 string, param2 int) (map[string]interface{}, error) {\n return map[string]interface{}{\n \"result\": param1,\n \"count\": param2,\n }, nil\n}\n```\n\n**Important:**\n- Package must be `inner`\n- Return type must be `({return_type}, error)`\n- Function name is `main` (lowercase)\n\n## Return Types\n\nThe return type can be any Go type that can be serialized to JSON:\n\n```go\npackage inner\n\ntype Result struct {\n Name string `json:\"name\"`\n Count int `json:\"count\"`\n}\n\nfunc main(name string, count int) (Result, error) {\n return Result{\n Name: name,\n Count: count,\n }, nil\n}\n```\n\n## Error Handling\n\nReturn errors as the second return value:\n\n```go\npackage inner\n\nimport \"errors\"\n\nfunc main(value int) (string, error) {\n if value < 0 {\n return \"\", errors.New(\"value must be positive\")\n }\n return \"success\", nil\n}\n```\n"; export declare const LANG_GRAPHQL = "# GraphQL\n\n## Structure\n\nWrite GraphQL queries or mutations. Arguments can be added as query parameters:\n\n```graphql\nquery GetUser($id: ID!) {\n user(id: $id) {\n id\n name\n email\n }\n}\n```\n\n## Variables\n\nVariables are passed as script arguments and automatically bound to the query:\n\n```graphql\nquery SearchProducts($query: String!, $limit: Int = 10) {\n products(search: $query, first: $limit) {\n edges {\n node {\n id\n name\n price\n }\n }\n }\n}\n```\n\n## Mutations\n\n```graphql\nmutation CreateUser($input: CreateUserInput!) {\n createUser(input: $input) {\n id\n name\n createdAt\n }\n}\n```\n"; export declare const LANG_JAVA = "# Java\n\nThe script must contain a Main public class with a `public static main()` method:\n\n```java\npublic class Main {\n public static Object main(String name, int count) {\n java.util.Map result = new java.util.HashMap<>();\n result.put(\"name\", name);\n result.put(\"count\", count);\n return result;\n }\n}\n```\n\n**Important:**\n- Class must be named `Main`\n- Method must be `public static Object main(...)`\n- Return type is `Object` or `void`\n\n## Maven Dependencies\n\nAdd dependencies using comments at the top:\n\n```java\n//requirements:\n//com.google.code.gson:gson:2.10.1\n//org.apache.httpcomponents:httpclient:4.5.14\n\nimport com.google.gson.Gson;\n\npublic class Main {\n public static Object main(String input) {\n Gson gson = new Gson();\n return gson.fromJson(input, Object.class);\n }\n}\n```\n"; -export declare const LANG_MSSQL = "# Microsoft SQL Server (MSSQL)\n\nArguments use `@P1`, `@P2`, etc.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @P1 name1 (varchar)\n-- @P2 name2 (int) = 0\nSELECT * FROM users WHERE name = @P1 AND age > @P2;\n```\n"; -export declare const LANG_MYSQL = "# MySQL\n\nArguments use `?` placeholders.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- ? name1 (text)\n-- ? name2 (int) = 0\nSELECT * FROM users WHERE name = ? AND age > ?;\n```\n"; +export declare const LANG_MSSQL = "# Microsoft SQL Server (MSSQL)\n\nArguments use `@P1`, `@P2`, etc.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- @P1 name1 (varchar)\n-- @P2 name2 (int) = 0\nSELECT * FROM users WHERE name = @P1 AND age > @P2;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as `nvarchar(max)` JSON text \u2014 Parquet/CSV\nfiles are decoded server-side into a JSON array of records, JSON/JSONL pass\nthrough. Consume with `OPENJSON`:\n\n```sql\n-- @P1 file (s3object)\nSELECT id, name\nFROM OPENJSON(@P1)\nWITH (id INT, name NVARCHAR(200));\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage \u2014\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets \u2014 rows stream directly to S3 instead of\nbeing buffered as the script return value.\n"; +export declare const LANG_MYSQL = "# MySQL\n\nArguments use `?` placeholders.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- ? name1 (text)\n-- ? name2 (int) = 0\nSELECT * FROM users WHERE name = ? AND age > ?;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as JSON text \u2014 Parquet/CSV files are\ndecoded server-side into a JSON array of records, JSON/JSONL pass through.\nConsume with `JSON_TABLE`:\n\n```sql\n-- ? file (s3object)\nSELECT id, name\nFROM JSON_TABLE(?, '$[*]'\n COLUMNS (id INT PATH '$.id', name VARCHAR(200) PATH '$.name')\n) AS r;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage \u2014\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets \u2014 rows stream directly to S3 instead of\nbeing buffered as the script return value.\n"; export declare const LANG_NATIVETS = "# TypeScript (Native)\n\nNative TypeScript execution with fetch only - no external imports allowed.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\nBefore using a resource type, check the `rt.d.ts` file in the project root to see all available resource types and their fields. This file is generated by `wmill resource-type generate-namespace`.\n\n## Imports\n\n**No imports allowed.** Use the globally available `fetch` function:\n\n```typescript\nexport async function main(url: string) {\n const response = await fetch(url);\n return await response.json();\n}\n```\n\n## Windmill Client\n\nThe windmill client is not available in native TypeScript mode. Use fetch to call APIs directly.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"gcp\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id\n };\n}\n```\n"; export declare const LANG_PHP = "# PHP\n\n## Structure\n\nThe script must start with ` $param1, \"count\" => $param2];\n}\n```\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nYou need to **redefine** the type of the resources that are needed before the main function. Always check if the class already exists using `class_exists`:\n\n```php\n $2::INT;\n```\n"; +export declare const LANG_POSTGRESQL = "# PostgreSQL\n\nArguments are obtained directly in the statement with `$1::{type}`, `$2::{type}`, etc.\n\nName the parameters by adding comments at the beginning of the script (without specifying the type):\n\n```sql\n-- $1 name1\n-- $2 name2 = default_value\nSELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as a `jsonb` parameter \u2014 Parquet/CSV files\nare decoded server-side into a JSON array of records, JSON/JSONL pass through.\nConsume with `jsonb_to_recordset` (or any `jsonb` API):\n\n```sql\n-- $1 file (s3object)\nSELECT *\nFROM jsonb_to_recordset($1::jsonb) AS r(id INT, name TEXT);\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage \u2014\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets \u2014 rows stream directly to S3 instead of\nbeing buffered as the script return value.\n"; export declare const LANG_POWERSHELL = "# PowerShell\n\n## Structure\n\nArguments are obtained by calling the `param` function on the first line:\n\n```powershell\nparam($Name, $Count = 0, [int]$Age)\n\n# Your code here\nWrite-Output \"Processing $Name, count: $Count, age: $Age\"\n\n# Return object\n@{\n name = $Name\n count = $Count\n age = $Age\n}\n```\n\n## Parameter Types\n\nYou can specify types for parameters:\n\n```powershell\nparam(\n [string]$Name,\n [int]$Count = 0,\n [bool]$Enabled = $true,\n [array]$Items\n)\n\n@{\n name = $Name\n count = $Count\n enabled = $Enabled\n items = $Items\n}\n```\n\n## Return Values\n\nReturn values by outputting them at the end of the script:\n\n```powershell\nparam($Input)\n\n$result = @{\n processed = $true\n data = $Input\n timestamp = Get-Date -Format \"o\"\n}\n\n$result\n```\n"; -export declare const LANG_PYTHON3 = "# Python\n\n## Structure\n\nThe script must contain at least one function called `main`:\n\n```python\ndef main(param1: str, param2: int):\n # Your code here\n return {\"result\": param1, \"count\": param2}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nYou need to **redefine** the type of the resources that are needed before the main function as TypedDict:\n\n```python\nfrom typing import TypedDict\n\nclass postgresql(TypedDict):\n host: str\n port: int\n user: str\n password: str\n dbname: str\n\ndef main(db: postgresql):\n # db contains the database connection details\n pass\n```\n\n**Important rules:**\n\n- The resource type name must be **IN LOWERCASE**\n- Only include resource types if they are actually needed\n- If an import conflicts with a resource type name, **rename the imported object, not the type name**\n- Make sure to import TypedDict from typing **if you're using it**\n\n## Imports\n\nLibraries are installed automatically. Do not show installation instructions.\n\n```python\nimport requests\nimport pandas as pd\nfrom datetime import datetime\n```\n\nIf an import name conflicts with a resource type:\n\n```python\n# Wrong - don't rename the type\nimport stripe as stripe_lib\nclass stripe_type(TypedDict): ...\n\n# Correct - rename the import\nimport stripe as stripe_sdk\nclass stripe(TypedDict):\n api_key: str\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```python\nimport wmill\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```python\nfrom typing import TypedDict, Literal, Any\n\nclass Event(TypedDict):\n kind: Literal[\"webhook\", \"http\", \"websocket\", \"kafka\", \"email\", \"nats\", \"postgres\", \"sqs\", \"mqtt\", \"gcp\"]\n body: Any\n headers: dict[str, str]\n query: dict[str, str]\n\ndef preprocessor(event: Event):\n # Transform the event into flow input parameters\n return {\n \"param1\": event[\"body\"][\"field1\"],\n \"param2\": event[\"query\"][\"id\"]\n }\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\n```python\nimport wmill\n\n# Load file content from S3\ncontent: bytes = wmill.load_s3_file(s3object)\n\n# Load file as stream reader\nreader: BufferedReader = wmill.load_s3_file_reader(s3object)\n\n# Write file to S3\nresult: S3Object = wmill.write_s3_file(\n s3object, # Target path (or None to auto-generate)\n file_content, # bytes or BufferedReader\n s3_resource_path, # Optional: specific S3 resource\n content_type, # Optional: MIME type\n content_disposition # Optional: Content-Disposition header\n)\n```\n"; +export declare const LANG_PYTHON3 = "# Python\n\n## Structure\n\nThe script must contain at least one function called `main`:\n\n```python\ndef main(param1: str, param2: int):\n # Your code here\n return {\"result\": param1, \"count\": param2}\n```\n\nDo not call the main function. Libraries are installed automatically.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nYou need to **redefine** the type of the resources that are needed before the main function as TypedDict:\n\n```python\nfrom typing import TypedDict\n\nclass postgresql(TypedDict):\n host: str\n port: int\n user: str\n password: str\n dbname: str\n\ndef main(db: postgresql):\n # db contains the database connection details\n pass\n```\n\n**Important rules:**\n\n- The resource type name must be **IN LOWERCASE**\n- Only include resource types if they are actually needed\n- If an import conflicts with a resource type name, **rename the imported object, not the type name**\n- Make sure to import TypedDict from typing **if you're using it**\n\n## Imports\n\nLibraries are installed automatically. Do not show installation instructions.\n\n```python\nimport requests\nimport pandas as pd\nfrom datetime import datetime\n```\n\nIf an import name conflicts with a resource type:\n\n```python\n# Wrong - don't rename the type\nimport stripe as stripe_lib\nclass stripe_type(TypedDict): ...\n\n# Correct - rename the import\nimport stripe as stripe_sdk\nclass stripe(TypedDict):\n api_key: str\n```\n\n## Windmill Client\n\nImport the windmill client for platform interactions:\n\n```python\nimport wmill\n```\n\nSee the SDK documentation for available methods.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```python\nfrom typing import TypedDict, Literal, Any\n\nclass Event(TypedDict):\n kind: Literal[\"webhook\", \"http\", \"websocket\", \"kafka\", \"email\", \"nats\", \"postgres\", \"sqs\", \"mqtt\", \"gcp\"]\n body: Any\n headers: dict[str, str]\n query: dict[str, str]\n\ndef preprocessor(event: Event):\n # Transform the event into flow input parameters\n return {\n \"param1\": event[\"body\"][\"field1\"],\n \"param2\": event[\"query\"][\"id\"]\n }\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\n### Receiving an S3Object as a script parameter\n\nTo accept a file from S3 as input to a script, type the parameter with `S3Object` (imported from `wmill`):\n\n```python\nimport wmill\nfrom wmill import S3Object\n\ndef main(file: S3Object):\n content = wmill.load_s3_file(file)\n # ...\n```\n\n### S3 operations\n\n```python\nimport wmill\n\n# Load file content from S3\ncontent: bytes = wmill.load_s3_file(s3object)\n\n# Load file as stream reader\nreader: BufferedReader = wmill.load_s3_file_reader(s3object)\n\n# Write file to S3\nresult: S3Object = wmill.write_s3_file(\n s3object, # Target path (or None to auto-generate)\n file_content, # bytes or BufferedReader\n s3_resource_path, # Optional: specific S3 resource\n content_type, # Optional: MIME type\n content_disposition # Optional: Content-Disposition header\n)\n```\n"; export declare const LANG_RLANG = "# R\n\n## Structure\n\nDefine a `main` function using `<-` or `=` assignment. Parameters become the script inputs:\n\n```r\nlibrary(dplyr)\nlibrary(jsonlite)\n\nmain <- function(x, name = \"default\", flag = TRUE) {\n df <- tibble(x = x, name = name)\n result <- df %>% mutate(greeting = paste(\"Hello\", name))\n return(toJSON(result, auto_unbox = TRUE))\n}\n```\n\n**Important:**\n- The `main` function is required\n- Use `library()` to load packages \u2014 they are resolved and installed automatically\n- `jsonlite` is always available (used internally for argument parsing)\n- Return values must be JSON-serializable\n\n## Parameters\n\nR types map to Windmill types:\n- `numeric` \u2192 float/int\n- `character` \u2192 string\n- `logical` \u2192 bool (use `TRUE`/`FALSE`)\n- `list` \u2192 object/dict\n- `NULL` \u2192 null\n\nDefault values are inferred from the function signature:\n\n```r\nmain <- function(\n name, # required string\n count = 10, # optional int, default 10\n verbose = FALSE # optional bool, default FALSE\n) {\n # ...\n}\n```\n\n## Resources and Variables\n\nUse the built-in Windmill helpers (no import needed):\n\n```r\nmain <- function() {\n # Get a variable\n api_key <- get_variable(\"f/my_folder/api_key\")\n\n # Get a resource (returns a list)\n db <- get_resource(\"f/my_folder/postgres_config\")\n host <- db$host\n port <- db$port\n\n return(list(host = host, port = port))\n}\n```\n\n## Output\n\nReturn any JSON-serializable value from `main`. The return value becomes the step result:\n\n```r\nmain <- function(x) {\n # Return a scalar\n return(x + 1)\n\n # Or a list (becomes JSON object)\n return(list(result = x + 1, status = \"ok\"))\n}\n```\n\n## Annotations\n\nControl execution behavior with comment annotations:\n\n```r\n#renv_verbose = true # Show verbose renv output during resolution\n#renv_install_verbose = true # Show verbose output during package installation\n#sandbox = true # Run in nsjail sandbox (requires nsjail)\n```\n"; export declare const LANG_RUST = "# Rust\n\n## Structure\n\nThe script must contain a function called `main` with proper return type:\n\n```rust\nuse anyhow::anyhow;\nuse serde::Serialize;\n\n#[derive(Serialize, Debug)]\nstruct ReturnType {\n result: String,\n count: i32,\n}\n\nfn main(param1: String, param2: i32) -> anyhow::Result {\n Ok(ReturnType {\n result: param1,\n count: param2,\n })\n}\n```\n\n**Important:**\n- Arguments should be owned types\n- Return type must be serializable (`#[derive(Serialize)]`)\n- Return type is `anyhow::Result`\n\n## Dependencies\n\nPackages must be specified with a partial cargo.toml at the beginning of the script:\n\n```rust\n//! ```cargo\n//! [dependencies]\n//! anyhow = \"1.0.86\"\n//! reqwest = { version = \"0.11\", features = [\"json\"] }\n//! tokio = { version = \"1\", features = [\"full\"] }\n//! ```\n\nuse anyhow::anyhow;\n// ... rest of the code\n```\n\n**Note:** Serde is already included, no need to add it again.\n\n## Async Functions\n\nIf you need to handle async functions (e.g., using tokio), keep the main function sync and create the runtime inside:\n\n```rust\n//! ```cargo\n//! [dependencies]\n//! anyhow = \"1.0.86\"\n//! tokio = { version = \"1\", features = [\"full\"] }\n//! reqwest = { version = \"0.11\", features = [\"json\"] }\n//! ```\n\nuse anyhow::anyhow;\nuse serde::Serialize;\n\n#[derive(Serialize, Debug)]\nstruct Response {\n data: String,\n}\n\nfn main(url: String) -> anyhow::Result {\n let rt = tokio::runtime::Runtime::new()?;\n rt.block_on(async {\n let resp = reqwest::get(&url).await?.text().await?;\n Ok(Response { data: resp })\n })\n}\n```\n"; -export declare const LANG_SNOWFLAKE = "# Snowflake\n\nArguments use `?` placeholders.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- ? name1 (text)\n-- ? name2 (number) = 0\nSELECT * FROM users WHERE name = ? AND age > ?;\n```\n"; +export declare const LANG_SNOWFLAKE = "# Snowflake\n\nArguments use `?` placeholders.\n\nName the parameters by adding comments before the statement:\n\n```sql\n-- ? name1 (text)\n-- ? name2 (number) = 0\nSELECT * FROM users WHERE name = ? AND age > ?;\n```\n\n## Receiving an S3Object as a script parameter\n\nDeclare the arg with type `(s3object)`. Windmill renders an S3 file picker for\nit, downloads the file, and binds it as JSON text \u2014 Parquet/CSV files are\ndecoded server-side into a JSON array of records, JSON/JSONL pass through.\nWrap the bind with `PARSE_JSON(?)` and walk it with `LATERAL FLATTEN`:\n\n```sql\n-- ? file (s3object)\nSELECT\n v.value:id::NUMBER AS id,\n v.value:name::STRING AS name\nFROM LATERAL FLATTEN(input => PARSE_JSON(?)) v;\n```\n\n## Streaming query results to S3\n\nAdd a `-- s3` directive at the top of the script to stream the result set to S3\ninstead of returning rows. Windmill writes the file and returns its `S3Object`\nas the script result.\n\n```sql\n-- s3 prefix=exports/users format=parquet\nSELECT id, name FROM users;\n```\n\nAll keys are optional: `prefix` (object key prefix), `storage` (named storage \u2014\nomit to use the workspace default), `format` (`json` (default), `parquet`, or\n`csv`). Use this for large result sets \u2014 rows stream directly to S3 instead of\nbeing buffered, bypassing the 10000-row return cap.\n"; From ac3c155541eb5ca20d65c38ad13dca6c10a572c9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 11 May 2026 22:30:15 +0000 Subject: [PATCH 37/50] fix: mask oauth client secret in instance settings (#9112) * feat: mask oauth client secret in instance settings * fix: address ci review - migrate nextcloud + use Password small prop * fix: associate client secret labels with input via for/id --- .../src/lib/components/Auth0Setting.svelte | 12 ++++++---- .../src/lib/components/AuthSettings.svelte | 17 ++++++++----- .../src/lib/components/AutheliaSetting.svelte | 20 ++++++++-------- .../lib/components/AuthentikSetting.svelte | 24 +++++++++++-------- .../src/lib/components/KanidmSetting.svelte | 20 ++++++++-------- .../src/lib/components/KeycloakSetting.svelte | 10 ++++---- .../lib/components/NextcloudSetting.svelte | 10 ++++---- .../src/lib/components/OAuthSetting.svelte | 10 ++++---- .../src/lib/components/OktaSetting.svelte | 9 +++++-- .../src/lib/components/PocketIdSetting.svelte | 10 ++++---- .../src/lib/components/ZitadelSetting.svelte | 20 ++++++++-------- 11 files changed, 93 insertions(+), 69 deletions(-) diff --git a/frontend/src/lib/components/Auth0Setting.svelte b/frontend/src/lib/components/Auth0Setting.svelte index beb9ec03f9..a246ae196d 100644 --- a/frontend/src/lib/components/Auth0Setting.svelte +++ b/frontend/src/lib/components/Auth0Setting.svelte @@ -7,6 +7,7 @@ import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import TextInput from './text_input/TextInput.svelte' + import Password from './Password.svelte' import SettingCard from './instanceSettings/SettingCard.svelte' interface Props { @@ -103,14 +104,15 @@ class="max-w-lg" /> - - -