From 056ebdb03543a93094c80ca354c117236cd8d6c8 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Mon, 6 Jul 2026 11:34:08 +0200 Subject: [PATCH 01/14] fix: read chat drafts via own-draft route so drawer-kind drafts deploy (#9913) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: read chat drafts via own-draft route so drawer-kind drafts deploy Co-Authored-By: Claude Fable 5 * test: cover trigger and resource chat-draft read/deploy regressions Co-Authored-By: Claude Fable 5 * test: cover non-secret variable chat-draft read/deploy regression Completes the drawer-kind matrix from the review notes on #9913: schedule, trigger, and resource already had full write→read→deploy regressions; this adds the variable one (non-secret — the secret flow deploys through the ephemeral in-memory value and is pinned by the existing ephemeral tests). Co-Authored-By: Claude Fable 5 * fix(ai_evals): mock getOwnDraft so eval draft hydration stays in-memory The frontend eval adapter intercepts DraftService for benchmark workspaces, but only updateDraft/getDraftForUser/listDrafts. Global eval output collection hydrates draft values through getGlobalDraft, which reads via getOwnDraft — so draft-producing global cases fell through to the real generated client instead of the in-memory benchmark store. Adds a getBenchmarkOwnDraft helper (null on miss, mirroring the 200/null route semantics), wires it into the adapter mock, and pins it in mockBackendDrafts.test.ts. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 Co-authored-by: Ruben Fiszel --- ai_evals/adapters/frontend/mockBackend.ts | 19 +- .../frontend/mockBackendDrafts.test.ts | 22 ++ .../adapters/frontend/vitestAdapter.test.ts | 5 + .../copilot/chat/global/core.test.ts | 199 +++++++++++++++++- .../copilot/chat/global/userDraftAdapter.ts | 35 ++- 5 files changed, 249 insertions(+), 31 deletions(-) diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 379eb6d447..f9023449a2 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -11,6 +11,7 @@ import type { DataTableTables, DataTableTableSchema, GetDraftForUserResponse, + GetOwnDraftResponse, ListDraftsResponse, ScriptLang, UpdateDraftResponse, @@ -294,8 +295,8 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string { /** * In-memory stand-in for the per-user draft backend (`DraftService`). The global * AI chat now persists and reads drafts through the backend DB instead of an - * in-tab `UserDraft` cell, so the eval mocks the three draft endpoints it - * exercises (`updateDraft` / `getDraftForUser` / `listDrafts`) and keeps the + * in-tab `UserDraft` cell, so the eval mocks the draft endpoints it exercises + * (`updateDraft` / `getOwnDraft` / `getDraftForUser` / `listDrafts`) and keeps the * saved values here, keyed by workspace + draft kind + storage path. Mirrors the * semantics of the production unit test's mock in * `frontend/src/lib/components/copilot/chat/global/core.test.ts`. @@ -379,6 +380,20 @@ export function getBenchmarkDraftForUser(input: { return { value: entry.value, created_at: BENCHMARK_DRAFT_TIMESTAMP } } +/** Mirror `DraftService.getOwnDraft`: `null` (200) when absent — unlike + * `getDraftForUser`, absence is not an error on this route. */ +export function getBenchmarkOwnDraft(input: { + workspace: string + kind: UserDraftItemKind + path: string +}): GetOwnDraftResponse { + const entry = benchmarkDrafts.get(benchmarkDraftKey(input.workspace, input.kind, input.path)) + if (!entry) { + return null + } + return { value: entry.value, created_at: BENCHMARK_DRAFT_TIMESTAMP } +} + /** Mirror `DraftService.listDrafts`: metadata rows (no value) for a workspace. */ export function listBenchmarkDrafts(workspace: string): ListDraftsResponse { return [...benchmarkDrafts.values()] diff --git a/ai_evals/adapters/frontend/mockBackendDrafts.test.ts b/ai_evals/adapters/frontend/mockBackendDrafts.test.ts index a720de5e43..0ab79d216e 100644 --- a/ai_evals/adapters/frontend/mockBackendDrafts.test.ts +++ b/ai_evals/adapters/frontend/mockBackendDrafts.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { clearBenchmarkDrafts, getBenchmarkDraftForUser, + getBenchmarkOwnDraft, listBenchmarkDrafts, resetBenchmarkMockBackend, seedBenchmarkDraft, @@ -55,6 +56,27 @@ describe('mockBackend drafts', () => { expect(() => getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'variable', path: 'f/evals/token' })).toThrow() }) + it('returns null from getOwnDraft when no draft exists', () => { + expect( + getBenchmarkOwnDraft({ workspace: WORKSPACE, kind: 'trigger_schedule', path: 'u/evals/missing' }) + ).toBeNull() + }) + + // The global chat hydrates drawer-kind drafts (schedule/trigger/resource/variable) + // through getOwnDraft — getDraftForUser rejects those kinds as private. + it('hydrates a saved drawer-kind draft through getOwnDraft', () => { + const value = { path: 'u/evals/nightly', schedule: '0 0 9 * * *' } + updateBenchmarkDraft({ + workspace: WORKSPACE, + kind: 'trigger_schedule', + path: 'u/evals/nightly', + requestBody: { value } + }) + expect( + getBenchmarkOwnDraft({ workspace: WORKSPACE, kind: 'trigger_schedule', path: 'u/evals/nightly' })?.value + ).toEqual(value) + }) + it('throws a 404-shaped error when no draft exists', () => { try { getBenchmarkDraftForUser({ workspace: WORKSPACE, kind: 'script', path: 'f/evals/missing' }) diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index dcaa1d2ca4..92e33414df 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -40,6 +40,7 @@ vi.mock('$lib/gen', async () => { getBenchmarkDraftForUser, getBenchmarkFlowByPath, getBenchmarkJobLogs, + getBenchmarkOwnDraft, getBenchmarkScriptByHash, getBenchmarkScriptByPath, hasBenchmarkWorkspace, @@ -86,6 +87,10 @@ vi.mock('$lib/gen', async () => { hasBenchmarkWorkspace(data.workspace) ? getBenchmarkDraftForUser(data) : actual.DraftService.getDraftForUser(data), + getOwnDraft: async (data: { workspace: string; kind: any; path: string }) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkOwnDraft(data) + : actual.DraftService.getOwnDraft(data), listDrafts: async (data: { workspace: string }) => hasBenchmarkWorkspace(data.workspace) ? listBenchmarkDrafts(data.workspace) diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index a3b9c22499..0f30076bda 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -36,7 +36,7 @@ const { backendDrafts, serverTimestamps, failingWrites, failingReads } = vi.hois // concurrent writer advancing the row; otherwise empty, so the conflict // branch in `updateDraft` stays inert for every pre-existing test. serverTimestamps: new Map(), - // Keys whose `updateDraft` / `getDraftForUser` throw a non-404 (network/5xx); + // Keys whose `updateDraft` / draft reads throw a non-404 (network/5xx); // only set by the error-handling tests, empty otherwise. failingWrites: new Set(), failingReads: new Set() @@ -132,13 +132,17 @@ vi.mock('$lib/gen', async () => { existsSchedule: vi.fn(async () => false), getSchedule: vi.fn(async () => { throw new Error('getSchedule mock not configured') - }) + }), + createSchedule: vi.fn(async () => 'created'), + updateSchedule: vi.fn(async () => 'updated') }), HttpTriggerService: wrapService(actual.HttpTriggerService, { existsHttpTrigger: vi.fn(async () => false), getHttpTrigger: vi.fn(async () => { throw new Error('getHttpTrigger mock not configured') - }) + }), + createHttpTrigger: vi.fn(async () => 'created'), + updateHttpTrigger: vi.fn(async () => 'updated') }), AppService: wrapService(actual.AppService, { existsApp: vi.fn(async () => false), @@ -156,7 +160,9 @@ vi.mock('$lib/gen', async () => { existsResource: vi.fn(async () => false), getResource: vi.fn(async () => { throw new Error('getResource mock not configured') - }) + }), + createResource: vi.fn(async () => 'created'), + updateResource: vi.fn(async () => 'updated') }), VariableService: wrapService(actual.VariableService, { existsVariable: vi.fn(async () => false), @@ -190,14 +196,27 @@ vi.mock('$lib/gen', async () => { return { status: 'saved', current_timestamp: '2026-06-15T00:00:00Z' } }), getDraftForUser: vi.fn(async ({ kind, path }: any) => { + // The real endpoint rejects drawer kinds up front (drafts for + // schedule/trigger/resource/variable are private to their owner) — + // mirror it so a caller regressing to this route for those kinds + // fails in tests the same way it does against the backend. + if (!['script', 'flow', 'app', 'raw_app'].includes(kind)) + throw Object.assign(new Error('drafts for this item kind are private to their owner'), { + status: 404 + }) const key = `${kind}:${path}` if (failingReads.has(key)) throw Object.assign(new Error('server error'), { status: 500 }) - // 404-shaped (status) like the real ApiError, so the adapter's - // narrowed catch treats it as "no draft" rather than re-throwing. if (!backendDrafts.has(key)) throw Object.assign(new Error('no draft for that owner at that path'), { status: 404 }) return { value: backendDrafts.get(key), created_at: '2026-06-15T00:00:00Z' } }), + getOwnDraft: vi.fn(async ({ kind, path }: any) => { + const key = `${kind}:${path}` + if (failingReads.has(key)) throw Object.assign(new Error('server error'), { status: 500 }) + // The real endpoint returns 200 with null when the user has no draft. + if (!backendDrafts.has(key)) return null + return { value: backendDrafts.get(key), created_at: '2026-06-15T00:00:00Z' } + }), listDrafts: vi.fn(async () => Array.from(backendDrafts.entries()).map(([key, value]) => { const idx = key.indexOf(':') @@ -1163,6 +1182,170 @@ describe('global AI tools', () => { expect(draft).not.toHaveProperty('override') }) + // Schedule drafts (like all drawer kinds) are private to their owner, so the + // cross-user draft route 404s on them. Reading them back must go through the + // own-draft route, else a freshly written schedule draft is listed but can + // never be read or deployed. + it('reads and deploys a schedule draft written by the chat', async () => { + await callGlobalTool('write_schedule', { + path: 'u/admin/test_schedule_greet', + schedule: '0 0 9 * * *', + timezone: 'UTC', + script_path: 'f/scripts/greet', + is_flow: false, + args: {} + }) + + const readRaw = await callGlobalTool('read_workspace_item', { + type: 'schedule', + path: 'u/admin/test_schedule_greet' + }) + expect(JSON.parse(readRaw)).toMatchObject({ + type: 'schedule', + path: 'u/admin/test_schedule_greet', + isDraft: true + }) + + await callGlobalTool('deploy_workspace_item', { + type: 'schedule', + path: 'u/admin/test_schedule_greet' + }) + expect(ScheduleService.createSchedule).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'u/admin/test_schedule_greet', + schedule: '0 0 9 * * *', + script_path: 'f/scripts/greet' + }) + }) + // The draft is consumed by the deploy. + expect( + getBackendDraft('trigger_schedule', 'u/admin/test_schedule_greet', { + workspace: WORKSPACE + }) + ).toBeUndefined() + }) + + // Same private-owner read path as schedules, for the trigger drawer kinds. + it('reads and deploys a trigger draft written by the chat', async () => { + await callGlobalTool('write_trigger', { + kind: 'http', + config: { + path: 'u/admin/fresh_route', + script_path: 'f/scripts/handler', + is_flow: false, + route_path: 'api/fresh', + http_method: 'get', + authentication_method: 'none', + is_static_website: false + } + }) + + const readRaw = await callGlobalTool('read_workspace_item', { + type: 'trigger', + trigger_kind: 'http', + path: 'u/admin/fresh_route' + }) + expect(JSON.parse(readRaw)).toMatchObject({ + type: 'trigger', + triggerKind: 'http', + path: 'u/admin/fresh_route', + isDraft: true + }) + + await callGlobalTool('deploy_workspace_item', { + type: 'trigger', + trigger_kind: 'http', + path: 'u/admin/fresh_route' + }) + expect(HttpTriggerService.createHttpTrigger).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'u/admin/fresh_route', + route_path: 'api/fresh', + script_path: 'f/scripts/handler' + }) + }) + expect( + getBackendDraft('trigger_http', 'u/admin/fresh_route', { workspace: WORKSPACE }) + ).toBeUndefined() + }) + + // Same private-owner read path as schedules, for the resource drawer kind. + it('reads and deploys a resource draft written by the chat', async () => { + await callGlobalTool('write_resource', { + path: 'u/admin/fresh_db', + value: { host: 'db.example.com', port: 5432 }, + resource_type: 'postgresql', + description: 'fresh database' + }) + + const readRaw = await callGlobalTool('read_workspace_item', { + type: 'resource', + path: 'u/admin/fresh_db' + }) + expect(JSON.parse(readRaw)).toMatchObject({ + type: 'resource', + path: 'u/admin/fresh_db', + isDraft: true + }) + + await callGlobalTool('deploy_workspace_item', { + type: 'resource', + path: 'u/admin/fresh_db' + }) + expect(ResourceService.createResource).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'u/admin/fresh_db', + resource_type: 'postgresql', + value: { host: 'db.example.com', port: 5432 } + }) + }) + expect( + getBackendDraft('resource', 'u/admin/fresh_db', { workspace: WORKSPACE }) + ).toBeUndefined() + }) + + // Same private-owner read path as schedules, for the variable drawer kind. + // Secret variables deploy through the ephemeral in-memory value instead + // (see the ephemeral-value tests above); this pins the plain-value cycle. + it('reads and deploys a non-secret variable draft written by the chat', async () => { + await callGlobalTool('write_variable', { + path: 'u/admin/fresh_config', + value: 'plain-value', + is_secret: false, + description: 'fresh config' + }) + + const readRaw = await callGlobalTool('read_workspace_item', { + type: 'variable', + path: 'u/admin/fresh_config' + }) + expect(JSON.parse(readRaw)).toMatchObject({ + type: 'variable', + path: 'u/admin/fresh_config', + isDraft: true + }) + + await callGlobalTool('deploy_workspace_item', { + type: 'variable', + path: 'u/admin/fresh_config' + }) + expect(VariableService.createVariable).toHaveBeenCalledWith({ + workspace: WORKSPACE, + requestBody: expect.objectContaining({ + path: 'u/admin/fresh_config', + value: 'plain-value', + is_secret: false, + description: 'fresh config' + }) + }) + expect( + getBackendDraft('variable', 'u/admin/fresh_config', { workspace: WORKSPACE }) + ).toBeUndefined() + }) + it('requires trigger_kind when discarding a trigger draft', async () => { await expect( callGlobalTool('discard_local_draft', { @@ -3086,9 +3269,7 @@ describe('folder tools', () => { }) it('create_folder surfaces a backend error (e.g. name conflict)', async () => { - vi.mocked(FolderService.createFolder).mockRejectedValueOnce( - new Error('Folder already exists') - ) + vi.mocked(FolderService.createFolder).mockRejectedValueOnce(new Error('Folder already exists')) const raw = await callGlobalTool('create_folder', { name: 'taken' }) const parsed = JSON.parse(raw) expect(parsed.success).toBe(false) diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index 2717d41608..342717f26e 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -1,7 +1,5 @@ import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen' import { DraftService } from '$lib/gen' -import { get } from 'svelte/store' -import { userStore } from '$lib/stores' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { UserDraft, type UserDraftEntry, type UserDraftItemKind } from '$lib/userDraft.svelte' @@ -336,29 +334,26 @@ function getGlobalDraftSlot( } // Current user's persisted draft value (+ records the sync baseline so a later -// save detects external conflicts). undefined on 404 (no draft at that path). +// save detects external conflicts). undefined when no draft exists at that path. +// Uses `getOwnDraft` (not `getDraftForUser`): the latter rejects drawer kinds +// (schedule/trigger/resource/variable drafts are private to their owner), which +// would make those drafts write-only here — listed but never readable/deployable. +// Errors (403/500/network) MUST propagate: swallowing one would make the write +// merge fall through to the deployed item instead of the user's in-progress +// draft, silently overwriting their draft-only changes. async function fetchBackendDraftValue( workspace: string, itemKind: UserDraftItemKind, storagePath: string ): Promise { - try { - const resp = await DraftService.getDraftForUser({ - workspace, - kind: itemKind as any, - path: storagePath, - username: get(userStore)?.username - }) - UserDraftDbSyncer.recordRemoteSync({ workspace, itemKind, path: storagePath }, resp.created_at) - return resp.value ?? undefined - } catch (e) { - // 404 = no draft for this owner at that path (the intended empty case). - // Anything else (403/500/network) MUST propagate: swallowing it would make - // the write merge fall through to the deployed item instead of the user's - // in-progress draft, silently overwriting their draft-only changes. - if ((e as { status?: number } | null | undefined)?.status === 404) return undefined - throw e - } + const resp = await DraftService.getOwnDraft({ + workspace, + kind: itemKind, + path: storagePath + }) + if (!resp) return undefined + UserDraftDbSyncer.recordRemoteSync({ workspace, itemKind, path: storagePath }, resp.created_at) + return resp.value ?? undefined } // Draft VALUE for a write merge: cell-if-present (the user's freshest in-tab From 5fe7e1f3e8f3f76651f15f75f3766b61ce96d3a9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 6 Jul 2026 11:39:57 +0200 Subject: [PATCH 02/14] chore(main): release 1.750.0 (#9952) * chore(main): release 1.750.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 18 ++ backend/Cargo.lock | 162 +++++++++--------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +++--- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/core/constants.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 139 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 365084fbfb..069b259d4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.750.0](https://github.com/windmill-labs/windmill/compare/v1.749.0...v1.750.0) (2026-07-06) + + +### Features + +* chat-scoped session changes bar + unified diff drawer ([#9762](https://github.com/windmill-labs/windmill/issues/9762)) ([a6c0b37](https://github.com/windmill-labs/windmill/commit/a6c0b3756be78ca3fadc7bad6bae98c0887fd538)) +* **pipelines:** require data uploads before running a pipeline ([#9953](https://github.com/windmill-labs/windmill/issues/9953)) ([a1c5b7a](https://github.com/windmill-labs/windmill/commit/a1c5b7aa3ed2841f09f4f148ded5c5b5ef10fd3d)) +* **pipelines:** wm_partition macro for grain-agnostic partition filters ([#9950](https://github.com/windmill-labs/windmill/issues/9950)) ([43044c2](https://github.com/windmill-labs/windmill/commit/43044c2e28139b1dbde6844c781a821f8de68f58)) + + +### Bug Fixes + +* **ai:** test key routes Azure Foundry Claude models via Anthropic Messages API ([#9956](https://github.com/windmill-labs/windmill/issues/9956)) ([ea19cc9](https://github.com/windmill-labs/windmill/commit/ea19cc9dc459bd259e27f7fcc29601a010c5f8f0)) +* **cli:** HD-1 test_edges + HD-2 scd2 _current write in --local pipeline graph ([#9947](https://github.com/windmill-labs/windmill/issues/9947)) ([ad6f23d](https://github.com/windmill-labs/windmill/commit/ad6f23d6bfcf1056bcb6d8c6b552114e88177328)) +* **pipelines:** make node & pipeline-level run affordances always visible ([#9948](https://github.com/windmill-labs/windmill/issues/9948)) ([6eabb96](https://github.com/windmill-labs/windmill/commit/6eabb96ae78fb966f9916f907bb693d569b04c0b)) +* read chat drafts via own-draft route so drawer-kind drafts deploy ([#9913](https://github.com/windmill-labs/windmill/issues/9913)) ([056ebdb](https://github.com/windmill-labs/windmill/commit/056ebdb03543a93094c80ca354c117236cd8d6c8)) +* resolve extensionless bun relative imports on windows loader ([#9949](https://github.com/windmill-labs/windmill/issues/9949)) ([bf96621](https://github.com/windmill-labs/windmill/commit/bf9662172ad7e0ff53d39adc338fd7886672c8f9)) + ## [1.749.0](https://github.com/windmill-labs/windmill/compare/v1.748.0...v1.749.0) (2026-07-05) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index aa0707e417..dcf45d0b1c 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -6237,11 +6237,11 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] @@ -13746,7 +13746,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-nats", @@ -13828,7 +13828,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.749.0" +version = "1.750.0" dependencies = [ "async-stream", "async-trait", @@ -13861,7 +13861,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13874,7 +13874,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "argon2", @@ -14012,7 +14012,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14035,7 +14035,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14050,7 +14050,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14076,7 +14076,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.749.0" +version = "1.750.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14086,7 +14086,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14103,7 +14103,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14125,7 +14125,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14148,7 +14148,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14164,7 +14164,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14185,7 +14185,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14206,7 +14206,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14220,7 +14220,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-nats", @@ -14255,7 +14255,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14280,7 +14280,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14298,7 +14298,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14320,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14340,7 +14340,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14377,7 +14377,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14405,7 +14405,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.749.0" +version = "1.750.0" dependencies = [ "lazy_static", "serde", @@ -14417,7 +14417,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.749.0" +version = "1.750.0" dependencies = [ "argon2", "axum 0.8.9", @@ -14442,7 +14442,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14456,7 +14456,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.749.0" +version = "1.750.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14490,7 +14490,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.749.0" +version = "1.750.0" dependencies = [ "chrono", "lazy_static", @@ -14504,7 +14504,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14523,7 +14523,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.749.0" +version = "1.750.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14625,7 +14625,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.749.0" +version = "1.750.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14644,7 +14644,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.749.0" +version = "1.750.0" dependencies = [ "regex", "serde", @@ -14659,7 +14659,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14683,7 +14683,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "futures", @@ -14700,7 +14700,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.749.0" +version = "1.750.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14716,7 +14716,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -14737,7 +14737,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -14768,7 +14768,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "arc-swap", @@ -14793,7 +14793,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-stream", @@ -14827,7 +14827,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "futures", @@ -14845,7 +14845,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.749.0" +version = "1.750.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14854,7 +14854,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -14866,7 +14866,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde_json", @@ -14878,7 +14878,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "gosyn", @@ -14890,7 +14890,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -14902,7 +14902,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde_json", @@ -14914,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "nu-parser", @@ -14925,7 +14925,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14936,7 +14936,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14948,7 +14948,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14959,7 +14959,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-recursion", @@ -14981,7 +14981,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde_json", @@ -14993,7 +14993,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -15007,7 +15007,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15024,7 +15024,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -15037,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde", @@ -15049,7 +15049,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -15067,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15083,7 +15083,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15099,7 +15099,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde", @@ -15110,7 +15110,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-recursion", @@ -15149,7 +15149,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "const_format", @@ -15188,7 +15188,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.749.0" +version = "1.750.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15199,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-recursion", @@ -15233,7 +15233,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15257,7 +15257,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15290,7 +15290,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15323,7 +15323,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15343,7 +15343,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15377,7 +15377,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15413,7 +15413,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15436,7 +15436,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15460,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-nats", @@ -15484,7 +15484,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15519,7 +15519,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15547,7 +15547,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-trait", @@ -15572,7 +15572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "bitflags 2.13.0", @@ -15591,7 +15591,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-once-cell", @@ -15701,7 +15701,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.749.0" +version = "1.750.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 48e48da15d..b2b2ceff9c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.749.0" +version = "1.750.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.749.0" +version = "1.750.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 6d36c2f8f2..96a1072017 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.749.0" +version = "1.750.0" dependencies = [ "aho-corasick", "anyhow", @@ -6272,7 +6272,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.749.0" +version = "1.750.0" dependencies = [ "proc-macro2", "quote", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.749.0" +version = "1.750.0" dependencies = [ "convert_case", "serde", @@ -6293,7 +6293,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -6305,7 +6305,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde_json", @@ -6317,7 +6317,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "gosyn", @@ -6329,7 +6329,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -6341,7 +6341,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde_json", @@ -6353,7 +6353,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "nu-parser", @@ -6364,7 +6364,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6375,7 +6375,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6387,7 +6387,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6398,7 +6398,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "async-recursion", @@ -6420,7 +6420,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde_json", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -6446,7 +6446,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "convert_case", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -6476,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde", @@ -6488,7 +6488,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "lazy_static", @@ -6506,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6538,7 +6538,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6570,7 +6570,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "serde", @@ -6581,7 +6581,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.749.0" +version = "1.750.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index b8b9263a91..c718763f55 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.749.0" +version = "1.750.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 8b59c7ef08..16a1f88ffc 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.749.0 + version: 1.750.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 71ec4f546e..61a1f685d1 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.749.0"; +export const VERSION = "v1.750.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index f016b94c24..21de818497 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.749.0"; +export const VERSION = "1.750.0"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4489e6160e..1b00d570b5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.749.0", + "version": "1.750.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.749.0", + "version": "1.750.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index 42ab8ba684..fd14cb1e4e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.749.0", + "version": "1.750.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", diff --git a/lsp/Pipfile b/lsp/Pipfile index 17233bac9c..a8fa5b2b37 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.749.0" +wmill = ">=1.750.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index da3cbd2325..8adafa2827 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.749.0 + version: 1.750.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 863a7c487f..73c4e11cd6 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.749.0' + ModuleVersion = '1.750.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 16470eba60..083b342ee1 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.749.0" +version = "1.750.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 a47fa427c2..e0a4259242 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.749.0", + "version": "1.750.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 c142b1b01a..ebd48878ee 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.749.0", + "version": "1.750.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 1575293753..a5a09e0655 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.749.0 +1.750.0 From dc6b99775b550e7433fee8a159c30eaf296500c5 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 6 Jul 2026 16:56:32 +0200 Subject: [PATCH 03/14] fix(cli): quote non-identifier property names in resource-type namespace (#9964) Co-authored-by: Claude Opus 4.8 (1M context) --- cli/src/utils/resource_types.ts | 10 ++-- cli/test/resource_types_unit.test.ts | 68 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 cli/test/resource_types_unit.test.ts diff --git a/cli/src/utils/resource_types.ts b/cli/src/utils/resource_types.ts index 741cb9fb71..5bd56c9a8e 100644 --- a/cli/src/utils/resource_types.ts +++ b/cli/src/utils/resource_types.ts @@ -1,5 +1,9 @@ import { Schema, SchemaProperty } from "../../bootstrap/common.ts"; +function quotePropName(name: string): string { + return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : JSON.stringify(name); +} + export function compileResourceTypeToTsType(schema: Schema) { function rec(x: { [name: string]: SchemaProperty }, root = false) { let res = "{\n"; @@ -10,15 +14,15 @@ export function compileResourceTypeToTsType(schema: Schema) { let i = 0; for (let [name, prop] of entries) { if (prop.type == "object") { - res += ` ${name}: ${rec(prop.properties ?? {})}`; + res += ` ${quotePropName(name)}: ${rec(prop.properties ?? {})}`; } else if (prop.type == "array") { - res += ` ${name}: ${prop?.items?.type ?? "any"}[]`; + res += ` ${quotePropName(name)}: ${prop?.items?.type ?? "any"}[]`; } else { let typ = prop?.type ?? "any"; if (typ == "integer") { typ = "number"; } - res += ` ${name}: ${typ}`; + res += ` ${quotePropName(name)}: ${typ}`; } i++; if (i < entries.length) { diff --git a/cli/test/resource_types_unit.test.ts b/cli/test/resource_types_unit.test.ts new file mode 100644 index 0000000000..0966ae25bf --- /dev/null +++ b/cli/test/resource_types_unit.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from "bun:test"; + +import { compileResourceTypeToTsType } from "../src/utils/resource_types.ts"; +import type { Schema } from "../bootstrap/common.ts"; + +// ============================================================================= +// Resource-type namespace generation (WIN-2132) +// +// `compileResourceTypeToTsType` renders a JSON Schema into the body of a +// TypeScript type used in the generated `rt.d.ts` (RT namespace). JSON Schema +// property names are unconstrained, so a name with a colon, hyphen, or space +// is legal in the schema but not a valid bare TS identifier. Emitting it raw +// produced syntactically invalid output that broke `tsc`. These tests pin that +// such names are quoted while plain identifiers stay bare. +// ============================================================================= + +function schema(properties: Schema["properties"]): Schema { + return { + $schema: undefined, + type: "object", + properties, + required: [], + }; +} + +test("plain identifiers are emitted without quotes", () => { + const out = compileResourceTypeToTsType( + schema({ + host: { type: "string" }, + _port: { type: "integer" }, + $ref: { type: "boolean" }, + }) + ); + expect(out).toContain(" host: string"); + expect(out).toContain(" _port: number"); + expect(out).toContain(" $ref: boolean"); + expect(out).not.toContain('"host"'); +}); + +test("non-identifier property names are double-quoted", () => { + const out = compileResourceTypeToTsType( + schema({ + "content-type": { type: "string" }, + "x:api:key": { type: "string" }, + "with space": { type: "integer" }, + "3leading": { type: "boolean" }, + }) + ); + expect(out).toContain(' "content-type": string'); + expect(out).toContain(' "x:api:key": string'); + expect(out).toContain(' "with space": number'); + expect(out).toContain(' "3leading": boolean'); +}); + +test("nested object and array property names are quoted too", () => { + const out = compileResourceTypeToTsType( + schema({ + "nested-obj": { + type: "object", + properties: { "inner-key": { type: "string" } }, + }, + "arr-field": { type: "array", items: { type: "string" } }, + }) + ); + expect(out).toContain('"nested-obj": {'); + expect(out).toContain('"inner-key": string'); + expect(out).toContain('"arr-field": string[]'); +}); From cc2f638de6cebeffb9fee1d4835a0cfd565af86c Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 6 Jul 2026 18:36:21 +0200 Subject: [PATCH 04/14] fix(ai): centralize Anthropic Messages API routing across completion paths (#9960) Co-authored-by: Claude Opus 4.8 (1M context) --- .../copilot/lib.anthropicRouting.test.ts | 214 ++++++++++++++++++ frontend/src/lib/components/copilot/lib.ts | 165 ++++++++++---- 2 files changed, 330 insertions(+), 49 deletions(-) create mode 100644 frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts diff --git a/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts b/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts new file mode 100644 index 0000000000..2f2b0add26 --- /dev/null +++ b/frontend/src/lib/components/copilot/lib.anthropicRouting.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AIProviderModel } from '$lib/gen' +import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' + +// getCurrentModel/getMetadataModel are read per call, so a hoisted holder lets +// each test point the routing at a different provider/model. +const h = vi.hoisted(() => ({ currentModel: undefined as AIProviderModel | undefined })) + +vi.mock('monaco-editor', () => ({ editor: {} })) + +vi.mock('$lib/stores', () => ({ + workspaceStore: { subscribe: () => () => undefined } +})) + +vi.mock('$lib/components/flows/flowTree', () => ({ + findModuleInModules: () => undefined +})) + +vi.mock('$lib/gen', () => ({ + OpenAPI: { BASE: '/api', TOKEN: undefined }, + ResourceService: {}, + ScriptService: {}, + FlowService: {}, + JobService: {}, + ScheduleService: {}, + HttpTriggerService: {}, + WebsocketTriggerService: {}, + KafkaTriggerService: {}, + NatsTriggerService: {}, + PostgresTriggerService: {}, + MqttTriggerService: {}, + SqsTriggerService: {}, + GcpTriggerService: {}, + AzureTriggerService: {} +})) + +vi.mock('$lib/utils', () => ({ + emptyString: (value: string | undefined | null) => !value, + generateRandomString: () => 'generated_id' +})) + +vi.mock('$lib/scripts', () => ({ + scriptLangToEditorLang: (language: string) => language +})) + +vi.mock('$lib/aiStore', () => ({ + getCurrentModel: () => h.currentModel, + getMetadataModel: () => h.currentModel, + copilotInfo: { + subscribe: (run: (value: unknown) => void) => { + run({}) + return () => undefined + } + } +})) + +vi.mock('@leeoniya/ufuzzy', () => ({ + default: class { + search() { + return [[], [], []] + } + } +})) + +function streamOf(chunks: unknown[]): any { + return (async function* () { + for (const chunk of chunks) { + yield chunk + } + })() +} + +function textDelta(text: string) { + return { type: 'content_block_delta', delta: { type: 'text_delta', text } } +} + +const messages: ChatCompletionMessageParam[] = [{ role: 'user', content: 'hi' }] + +let anthropicCreate: ReturnType +let anthropicStream: ReturnType +let openaiCreate: ReturnType + +async function setupClients() { + const { workspaceAIClients } = await import('./lib') + + anthropicCreate = vi.fn().mockResolvedValue({ + content: [ + { type: 'text', text: 'Hel' }, + { type: 'thinking', thinking: 'ignored' }, + { type: 'text', text: 'lo' } + ] + }) + anthropicStream = vi + .fn() + .mockReturnValue( + streamOf([ + { type: 'message_start' }, + textDelta('Hel'), + { type: 'content_block_delta', delta: { type: 'input_json_delta', partial_json: '{' } }, + textDelta('lo'), + { type: 'message_stop' } + ]) + ) + openaiCreate = vi.fn().mockResolvedValue({ choices: [{ message: { content: 'openai text' } }] }) + + vi.spyOn(workspaceAIClients, 'getAnthropicClient').mockReturnValue({ + messages: { create: anthropicCreate, stream: anthropicStream } + } as any) + vi.spyOn(workspaceAIClients, 'getOpenaiClient').mockReturnValue({ + chat: { completions: { create: openaiCreate } } + } as any) +} + +beforeEach(async () => { + await setupClients() +}) + +afterEach(() => { + vi.restoreAllMocks() + h.currentModel = undefined +}) + +describe('Anthropic Messages API routing', () => { + it('getNonStreamingCompletion routes Foundry Claude through the Anthropic client', async () => { + const { getNonStreamingCompletion } = await import('./lib') + h.currentModel = { provider: 'azure_foundry', model: 'claude-sonnet-5' } + + const response = await getNonStreamingCompletion(messages, new AbortController()) + + expect(anthropicCreate).toHaveBeenCalledTimes(1) + expect(openaiCreate).not.toHaveBeenCalled() + // text blocks concatenated, non-text blocks dropped + expect(response).toBe('Hello') + + const headers = anthropicCreate.mock.calls[0][1].headers + // X-Provider must carry the real provider so the backend resolves Foundry + // credentials/URL; the SDK header selects the Messages API path. + expect(headers['X-Provider']).toBe('azure_foundry') + expect(headers['X-Anthropic-SDK']).toBe('true') + }) + + it('getNonStreamingCompletion routes native Anthropic through the Anthropic client', async () => { + const { getNonStreamingCompletion } = await import('./lib') + h.currentModel = { provider: 'anthropic', model: 'claude-opus-4-8' } + + await getNonStreamingCompletion(messages, new AbortController()) + + expect(anthropicCreate).toHaveBeenCalledTimes(1) + expect(anthropicCreate.mock.calls[0][1].headers['X-Provider']).toBe('anthropic') + }) + + it('getNonStreamingCompletion keeps non-Claude Foundry models on the OpenAI path', async () => { + const { getNonStreamingCompletion } = await import('./lib') + h.currentModel = { provider: 'azure_foundry', model: 'gpt-4o' } + + await getNonStreamingCompletion(messages, new AbortController()) + + expect(anthropicCreate).not.toHaveBeenCalled() + expect(openaiCreate).toHaveBeenCalledTimes(1) + }) + + it('getCompletion adapts the Anthropic stream into OpenAI text chunks', async () => { + const { getCompletion, getResponseFromEvent } = await import('./lib') + h.currentModel = { provider: 'azure_foundry', model: 'claude-sonnet-5' } + + const completion = await getCompletion(messages, new AbortController()) + + let text = '' + let chunks = 0 + for await (const part of completion) { + chunks++ + text += getResponseFromEvent(part) + } + + expect(anthropicStream).toHaveBeenCalledTimes(1) + // only the two text deltas surface; message_start/stop and input_json are dropped + expect(chunks).toBe(2) + expect(text).toBe('Hello') + }) + + it('testKey routes Foundry Claude through the Anthropic client', async () => { + const { testKey } = await import('./lib') + + await testKey({ + resourcePath: 'u/admin/foundry', + model: 'claude-sonnet-5', + abortController: new AbortController(), + messages, + aiProvider: 'azure_foundry' + }) + + expect(anthropicCreate).toHaveBeenCalledTimes(1) + const headers = anthropicCreate.mock.calls[0][1].headers + expect(headers['X-Provider']).toBe('azure_foundry') + expect(headers['X-Resource-Path']).toBe('u/admin/foundry') + }) + + it('getFimCompletion no-ops for Anthropic Messages API models', async () => { + const { getFimCompletion } = await import('./lib') + const fetchSpy = vi.spyOn(globalThis, 'fetch') + + for (const provider of ['anthropic', 'azure_foundry'] as const) { + const result = await getFimCompletion( + 'prefix', + 'suffix', + { provider, model: 'claude-sonnet-5' }, + new AbortController() + ) + expect(result).toBeUndefined() + } + // no autocomplete request should be issued for these models + expect(fetchSpy).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 323b552cca..808f9e0988 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -302,10 +302,10 @@ export function getModelMaxTokens(provider: AIProvider, model: string) { return 8192 } -function getModelSpecificConfig( - modelProvider: AIProviderModel, - tools?: OpenAI.Chat.Completions.ChatCompletionTool[] -) { +// Resolves the completion token cap for a model: the workspace's per-model +// override when set, otherwise the built-in default. Shared by the OpenAI and +// Anthropic request paths so both honor the same limit. +function resolveMaxTokens(modelProvider: AIProviderModel): number { const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model) const modelKey = `${modelProvider.provider}:${modelProvider.model}` let customMaxTokensStore: Record | undefined @@ -314,7 +314,14 @@ function getModelSpecificConfig( } catch { // copilotInfo store may not be initialized in vitest } - const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens + return customMaxTokensStore?.[modelKey] ?? defaultMaxTokens +} + +function getModelSpecificConfig( + modelProvider: AIProviderModel, + tools?: OpenAI.Chat.Completions.ChatCompletionTool[] +) { + const maxTokens = resolveMaxTokens(modelProvider) if ( (modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai' || @@ -466,23 +473,10 @@ export async function testKey({ throw new Error('Missing a model to test') } - // Providers served through the Anthropic Messages API (native Anthropic, and - // Claude deployments on Azure Foundry) must use the Anthropic SDK path rather - // than OpenAI chat completions. Mirrors the chat loop's routing so the test - // key exercises the same request shape the chat actually sends. - if (usesAnthropicMessagesApi(aiProvider, modelToTest)) { - await testAnthropicKey({ - apiKey, - workspace, - resourcePath, - model: modelToTest, - abortController, - messages, - aiProvider - }) - return - } - + // getNonStreamingCompletion routes Anthropic-Messages-API models (native + // Anthropic and Claude on Azure Foundry) through the Anthropic SDK and + // everything else through OpenAI chat completions, so the test exercises the + // same request shape the feature actually sends. await getNonStreamingCompletion(messages, abortController, { apiKey, workspace, @@ -494,30 +488,35 @@ export async function testKey({ }) } -async function testAnthropicKey({ - apiKey, - workspace, - resourcePath, - model, - abortController, - messages, - aiProvider -}: { +// Providers served through the Anthropic Messages API (native Anthropic, and +// Claude deployments on Azure Foundry) require the Anthropic SDK request shape: +// OpenAI chat-completions requests fail against them because the proxy forwards +// the body verbatim and, for Foundry, rewrites the URL to the /anthropic/v1 +// surface that only serves /messages. This centralizes the client/header/message +// setup so every completion entry point routes them the same way the chat does. +interface AnthropicCompletionParams { + messages: ChatCompletionMessageParam[] + modelProvider: AIProviderModel + abortController: AbortController apiKey?: string workspace?: string resourcePath?: string - model: string - abortController: AbortController - messages: ChatCompletionMessageParam[] - aiProvider: AIProvider -}) { +} + +function buildAnthropicProxyRequest({ + messages, + modelProvider, + apiKey, + workspace, + resourcePath +}: Omit) { const { system, messages: anthropicMessages } = convertOpenAIToAnthropicMessages(messages) // X-Provider must be the real provider (e.g. azure_foundry) so the backend // resolves the right credentials and Anthropic URL; the SDK headers tell it to // route through the Anthropic Messages API. const headers: Record = { - 'X-Provider': aiProvider, + 'X-Provider': modelProvider.provider, 'anthropic-version': '2023-06-01', 'X-Anthropic-SDK': 'true' } @@ -528,24 +527,65 @@ async function testAnthropicKey({ headers['X-API-Key'] = apiKey } - const anthropicClient = apiKey + const client = apiKey ? createAnthropicProxyClient(getAiProxyBaseURL()) : workspace ? workspaceAIClients.createAnthropicClient(workspace) : workspaceAIClients.getAnthropicClient() - await anthropicClient.messages.create( - { - model, - max_tokens: 100, - messages: anthropicMessages, - ...(system && { system }) - }, - { - signal: abortController.signal, - headers + const body = { + model: modelProvider.model, + max_tokens: resolveMaxTokens(modelProvider), + messages: anthropicMessages, + ...(system && { system }) + } + + return { client, headers, body } +} + +async function getAnthropicNonStreamingCompletion({ + abortController, + ...params +}: AnthropicCompletionParams): Promise { + const { client, headers, body } = buildAnthropicProxyRequest(params) + + const message = await client.messages.create(body, { + signal: abortController.signal, + headers + }) + + return message.content.map((block) => (block.type === 'text' ? block.text : '')).join('') +} + +// Adapts an Anthropic Messages stream into the OpenAI ChatCompletionChunk shape +// the completion consumers already iterate, so they need no Anthropic-specific +// handling. Only text deltas are surfaced (these paths don't use tool calls). +function getAnthropicStreamingCompletion({ + abortController, + ...params +}: AnthropicCompletionParams): Stream { + const { client, headers, body } = buildAnthropicProxyRequest(params) + + const stream = client.messages.stream(body, { + signal: abortController.signal, + headers + }) + + async function* toOpenAIChunks(): AsyncGenerator { + for await (const event of stream) { + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { + yield { + id: '', + object: 'chat.completion.chunk', + created: 0, + model: params.modelProvider.model, + choices: [{ index: 0, delta: { content: event.delta.text }, finish_reason: null }] + } + } } - ) + } + + return toOpenAIChunks() as unknown as Stream } interface BaseOptions { @@ -773,6 +813,19 @@ export async function getNonStreamingCompletion( forceModelProvider?: AIProviderModel } ) { + const modelProvider = options?.forceModelProvider ?? getCurrentModel() + + if (usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model)) { + return getAnthropicNonStreamingCompletion({ + messages, + modelProvider, + abortController, + apiKey: options?.apiKey, + workspace: options?.workspace, + resourcePath: options?.resourcePath + }) + } + let response: string | undefined = '' const { provider, config } = getProviderAndCompletionConfig({ messages, @@ -846,6 +899,14 @@ export async function getFimCompletion( providerModel: AIProviderModel, abortController: AbortController ): Promise { + // The Anthropic Messages API has no fill-in-the-middle endpoint, and Foundry + // Claude deployments don't expose the OpenAI-compatible completions surface the + // FIM proxy targets. Skip autocomplete for these models rather than issuing a + // request that can't succeed. + if (usesAnthropicMessagesApi(providerModel.provider, providerModel.model)) { + return undefined + } + const fetchOptions: { signal: AbortSignal headers: Record @@ -908,6 +969,12 @@ export async function getCompletion( reasoningEffort?: string } ): Promise> { + const modelProvider = options?.forceModelProvider ?? getCurrentModel() + + if (usesAnthropicMessagesApi(modelProvider.provider, modelProvider.model)) { + return getAnthropicStreamingCompletion({ messages, modelProvider, abortController }) + } + const { provider, config } = getProviderAndCompletionConfig({ messages, stream: true, From 91e1b087a206efb7189824b4184e1f3f4cda7211 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 6 Jul 2026 19:02:51 +0200 Subject: [PATCH 05/14] feat(auth): add runtime NO_AUTH mode for authentication bypass (#9962) * feat(auth): add runtime NO_AUTH mode for authentication bypass Adds a runtime `NO_AUTH` env flag that makes every request resolve as the `admin@windmill.dev` superadmin with no login required, so self-hosted deployments can front Windmill with their own authenticating gateway without building a dedicated `oss` (compile-time `no_auth`) binary. - `NO_AUTH` is honored in any build but is force-disabled when `CLOUD_HOSTED` is set, so the managed cloud always enforces real auth. - The existing compile-time `no_auth` feature keeps its always-on behavior (`cfg!(feature = "no_auth") || *NO_AUTH`), so `oss` builds are unchanged. - `Tokened` now yields a synthetic token in no-auth mode so handlers that require it (e.g. global_whoami, called by the frontend on load) resolve. - A loud startup banner warns when the mode is on; `HIDE_NO_AUTH_BANNER` silences it once the operator has deliberately deployed behind a gateway. Fixes WIN-2131 Co-Authored-By: Claude Opus 4.8 (1M context) * feat(auth): dismissable NO_AUTH warning banner via global setting Replaces the HIDE_NO_AUTH_BANNER env flag with a UI warning banner that can be permanently dismissed for all users from within the running instance (not exposed in instance settings). - New `no_auth_banner_dismissed` global setting, only ever written by dismissing the banner itself. - `GET /api/settings/no_auth_banner` returns whether to show the banner (true only when NO_AUTH is active and it hasn't been dismissed). - NoAuthBanner.svelte renders a top-of-app warning in NO_AUTH mode; its dismiss button opens a confirmation modal, then writes the global setting via the existing setGlobal endpoint so it stays hidden for everyone. - The server still logs the startup NO_AUTH warning unconditionally. Fixes WIN-2131 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(auth): resolve NO_AUTH in AuthCache so all_runnables works Codex/Pi review flagged that `/api/users/all_runnables` still failed in NO_AUTH mode: `get_all_runnables` extracts `Tokened` and re-validates the request token per workspace via `AuthCache::get_authed`, which rejected the fabricated `"no_auth"` token (no matching DB row) with a 400. Short-circuit `AuthCache::get_opt_job_authed` (the resolver behind `get_authed`) to the admin superadmin in no-auth mode, so any direct cache caller resolves without a real token. Single-source the mode check and the synthetic identity via `is_no_auth()` / `no_auth_admin_authed()` and reuse them across the extractor, resolver, and login paths. Co-Authored-By: Claude Opus 4.8 (1M context) * revert(auth): drop the NO_AUTH dismissable UI banner The in-app banner added a GET /api/settings/no_auth_banner request to every instance load for little benefit. The startup log warning already surfaces that auth is bypassed to operators, so drop the banner, its endpoint, and the no_auth_banner_dismissed global setting entirely. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- backend/src/main.rs | 10 +++++ backend/windmill-api-auth/src/auth.rs | 57 ++++++++++++++++++------- backend/windmill-api-auth/src/lib.rs | 4 +- backend/windmill-api-users/src/users.rs | 6 ++- backend/windmill-common/src/worker.rs | 8 ++++ 5 files changed, 66 insertions(+), 19 deletions(-) diff --git a/backend/src/main.rs b/backend/src/main.rs index 0186f271e6..4115e2a12b 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -578,6 +578,7 @@ fn print_help() { println!(" JSON_FMT = false Output logs in JSON instead of logfmt"); println!(" METRICS_ADDR = None (EE only) Prometheus metrics addr at /metrics; set \"true\" to use :8001"); println!(" SUPERADMIN_SECRET = None Virtual superadmin token (server)"); + println!(" NO_AUTH = false Bypass all auth; every request acts as the admin@windmill.dev superadmin (only behind a trusted gateway; ignored when CLOUD_HOSTED)"); println!(" LICENSE_KEY = None (EE only) Enterprise license key (workers require valid key)"); println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup"); println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool"); @@ -641,6 +642,15 @@ async fn windmill_main() -> anyhow::Result<()> { println!("Running in MCP mode"); } + if *windmill_common::worker::NO_AUTH { + println!("############################################################"); + println!("# NO_AUTH mode is ENABLED: authentication is fully #"); + println!("# bypassed and every request is treated as the #"); + println!("# admin@windmill.dev superadmin. Only run this behind a #"); + println!("# trusted authenticating gateway on a private network. #"); + println!("############################################################"); + } + #[cfg(all(not(target_env = "msvc"), feature = "jemalloc"))] println!("jemalloc enabled"); diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index dea200e417..59d9cd80b3 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -131,6 +131,13 @@ impl AuthCache { w_id: Option, token: &str, ) -> Option { + // In no-auth mode there are no real tokens: resolve directly as the + // admin superadmin so direct cache callers (e.g. get_all_runnables, + // which re-validates the request token per workspace) don't reject the + // fabricated token. + if is_no_auth() { + return Some(OptJobAuthed { authed: no_auth_admin_authed(), job_id: None }); + } let key = ( w_id.as_ref().unwrap_or(&"".to_string()).to_string(), token.to_string(), @@ -598,6 +605,12 @@ where let tokened = Self { token }; parts.extensions.insert(tokened.clone()); Ok(tokened) + } else if is_no_auth() { + // In `--no-auth` mode requests carry no token, but handlers that + // also require Tokened (e.g. global_whoami) must still resolve. + let tokened = Self { token: "no_auth".to_string() }; + parts.extensions.insert(tokened.clone()); + Ok(tokened) } else { BRUTE_FORCE_COUNTER.increment().await; Err((StatusCode::UNAUTHORIZED, "Unauthorized".to_owned())) @@ -677,6 +690,30 @@ fn maybe_get_workspace_id_from_path(path_vec: &[&str]) -> Option { workspace_id } +/// `--no-auth` mode: compiled-in `oss` builds, or the `NO_AUTH` runtime flag on +/// any build (the runtime flag is force-disabled on CLOUD_HOSTED). When on, +/// every request resolves as the admin superadmin so a fronting gateway can +/// handle authentication instead. +pub fn is_no_auth() -> bool { + cfg!(feature = "no_auth") || *windmill_common::worker::NO_AUTH +} + +/// The synthetic superadmin identity returned for every request in no-auth mode. +fn no_auth_admin_authed() -> ApiAuthed { + ApiAuthed { + email: "admin@windmill.dev".to_string(), + username: "admin".to_string(), + is_admin: true, + is_operator: false, + groups: Vec::new(), + folders: Vec::new(), + scopes: None, + username_override: None, + token_prefix: None, + read_only: false, + } +} + /// Resolves OptJobAuthed from request parts. /// Takes ownership of Parts and returns them back. #[allow(unreachable_code, unused_mut)] @@ -687,21 +724,11 @@ pub async fn resolve_opt_job_authed( return Ok((OptJobAuthed::default(), parts)); }; - #[cfg(feature = "no_auth")] - { - let authed = ApiAuthed { - email: "admin@windmill.dev".to_string(), - username: "admin".to_string(), - is_admin: true, - is_operator: false, - groups: Vec::new(), - folders: Vec::new(), - scopes: None, - username_override: None, - token_prefix: None, - read_only: false, - }; - return Ok((OptJobAuthed { authed, job_id: None }, parts)); + if is_no_auth() { + return Ok(( + OptJobAuthed { authed: no_auth_admin_authed(), job_id: None }, + parts, + )); } let already_authed = parts.extensions.get::().cloned(); diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index d2347aa22e..b1bf2d4273 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -31,8 +31,8 @@ use scopes::ScopeDefinition; // Re-export key auth types and functions pub use auth::{ - get_end_user_email, invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened, - Tokened, TruncatedTokenWithEmail, AUTH_CACHE, + get_end_user_email, invalidate_token_from_cache, is_no_auth, AuthCache, ExpiringAuthCache, + OptTokened, Tokened, TruncatedTokenWithEmail, AUTH_CACHE, }; // ------------ ApiAuthed & OptJobAuthed types ------------ diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 3fcebdc083..ff0e8e754e 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -1942,8 +1942,10 @@ async fn login( Extension(argon2): Extension>>, Json(Login { email, password }): Json, ) -> Result { - #[cfg(feature = "no_auth")] - { + // In `--no-auth` mode there is no real login; the frontend never needs a + // session cookie because every request already resolves as the admin + // superadmin (see resolve_opt_job_authed). + if windmill_api_auth::is_no_auth() { return Ok("no_auth".to_string()); } diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 3a1469d5e3..ea2b5455f3 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -273,6 +273,14 @@ lazy_static::lazy_static! { /// production `app.windmill.dev` cluster, not on staging or self-hosted. pub static ref CLOUD_PRODUCTION_HOST: &'static str = "app.windmill.dev"; + /// `--no-auth` mode: when set, every API request is treated as + /// authenticated as the `admin@windmill.dev` superadmin and no login is + /// ever required. Meant for self-hosted deployments that front Windmill + /// with their own authenticating gateway. Never honored on the managed + /// cloud (`CLOUD_HOSTED`), which must always enforce real authentication. + pub static ref NO_AUTH: bool = !*CLOUD_HOSTED + && std::env::var("NO_AUTH").ok().is_some_and(|x| x == "1" || x == "true"); + pub static ref CUSTOM_TAGS: Vec = std::env::var("CUSTOM_TAGS") .ok() .map(|x| x.split(',').map(|x| x.to_string()).collect::>()).unwrap_or_default(); From 3dcd3949a14199b106506994ea31ca3de7e636b3 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 6 Jul 2026 20:12:12 +0200 Subject: [PATCH 06/14] feat(pipelines): auto-derive cascade edges from ducklake/s3 reads (+ muted-read badge) (#9963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(pipelines): auto-derive cascade trigger edges from ducklake/s3 reads Within a `// pipeline`, a read of a ducklake table or s3 object now auto-wires its cascade trigger edge straight from the FROM clause, so `// on ` is only needed for edges inference can't see (dynamic SQL) or to carry per-edge opts. Two opt-outs: `// mute ` suppresses a single derived edge (a lookup / SCD input read every run but not cascaded on), and `// mute all` opts the script out of derivation entirely (back to explicit-`// on`-only). Explicit `// on` still wins the dedup. Scoped to ducklake + s3 reads; resource/datatable/volume stay explicit. Read-write (RW) and write inputs are excluded so a self-referential merge can't loop-trigger itself; ambiguous (None) access is skipped. - parser: `mute` / `mute_all` in PipelineAnnotations (Rust + TS mirror) - deploy: derive_pipeline_asset_trigger_refs → script_trigger rows - frontend: resolveGraph mirrors derivation for the live edit-mode canvas - tests: shared parity corpus + derive-helper units + resolveGraph overlays Co-Authored-By: Claude Opus 4.8 (1M context) * feat(pipelines): mark auto-derived cascade edges with a persisted derived flag + "auto" badge Persist script_trigger.derived (deploy: true for ducklake/s3-read derivation, false for explicit // on) and return it from the asset-graph endpoint so the canvas renders a Sparkles "auto" badge on auto-wired edges — the inference is now visible on both the deployed graph and the live edit canvas, not just implied. Dispatch (fetch_subscribers) ignores the flag, so a derived edge fires identically to an explicit // on. Also copy derived in the workspace-clone trigger copy, and backfill muteAssets/muteAll into two empty PipelineAnnotations literals the base commit left stale (check:fast). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(pipelines): derive cascade edge from effective (alt-fallback) asset access derive_pipeline_asset_trigger_refs gated on the raw parser access_type, but the persisted asset.usage_access_type and the frontend canvas both use access_type.or(alt_access_type). An ambiguous parse with a manual read override was persisted/drawn as a read yet derived no edge, so the auto edge silently vanished on deploy. Gate on the effective access type for parity. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(pipelines): badge muted reads instead of auto-derived edges Auto-derivation is the default now, so badging every derived cascade edge is noise. Drop the "auto" badge and the persisted `script_trigger.derived` flag (migration + insert param + graph field + clone copy) that only powered it, and instead badge the exception: a ducklake/s3 asset a script reads but does NOT cascade — `// mute ` / `// mute all`. `computeMutedReadKeys` marks a read-only ('r') supported read with no cascade trigger and no self-write; the canvas renders a bell-off "muted" badge on that read edge. Also fixes two review parity nits: - TS `// on` parser now strips trailing `key=value` opts (e.g. `debounce=60s`) like the Rust `split_trailing_kv_opts`, so the ref dedups against inference. - A `// materialize` producer reading its own target is upgraded to `rw` (deploy) / excluded via the materialize write refs (canvas), so it neither self-cascades nor shows as a muted read. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(pipelines): drop redundant // on for auto-derived reads; gate muted badge to pipeline scripts - Templates no longer scaffold `// on ` for a ducklake/s3 input the body reads — the read auto-wires the cascade now that derivation is the default. Kept for datatable/resource (not auto-derived) and native triggers. The discoverability hint now mentions `// mute` (the newly relevant annotation). - computeMutedReadKeys only badges reads by `// pipeline` scripts. A plain script or flow reading a ducklake/s3 asset never had an auto trigger to suppress, so it must render as ordinary lineage, not "muted" (Codex review). Co-Authored-By: Claude Opus 4.8 (1M context) * fix(pipelines): only drop template // on when the body actually reads the input The redundant-`// on` removal assumed the generated body reads the ducklake/s3 input, but postgres/bash/generic bodies (and `data_upload`, which reads the picker file) ignore `input` — dropping `// on` there left the asset-created script with no cascade at all. Gate the drop on READS_INPUT_LANGS (bun/deno/python/duckdb) so non-reading templates keep the explicit trigger. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../windmill-parser/src/asset_parser.rs | 27 +++ .../tests/fixtures/pipeline_annotations.json | 104 ++++++++++ .../tests/pipeline_annotations_parity.rs | 20 ++ backend/windmill-api-scripts/src/scripts.rs | 77 ++++++- backend/windmill-common/src/assets.rs | 190 ++++++++++++++++++ .../assets/AssetGraph/AssetGraphCanvas.svelte | 22 +- .../AssetGraph/AssetGraphDetailsPane.svelte | 4 +- .../assets/AssetGraph/AssetGraphEdge.svelte | 31 ++- .../parsePipelineAnnotations.parity.test.ts | 16 +- .../parsePipelineAnnotations.test.ts | 7 + .../AssetGraph/parsePipelineAnnotations.ts | 49 ++++- .../AssetGraph/pipelineTemplates.test.ts | 67 ++++++ .../assets/AssetGraph/pipelineTemplates.ts | 30 ++- .../assets/AssetGraph/resolveGraph.test.ts | 168 +++++++++++++++- .../assets/AssetGraph/resolveGraph.ts | 153 +++++++++++++- .../(logged)/pipeline/[folder]/+page.svelte | 4 +- 16 files changed, 950 insertions(+), 19 deletions(-) diff --git a/backend/parsers/windmill-parser/src/asset_parser.rs b/backend/parsers/windmill-parser/src/asset_parser.rs index 045cac87e6..bdce9ec764 100644 --- a/backend/parsers/windmill-parser/src/asset_parser.rs +++ b/backend/parsers/windmill-parser/src/asset_parser.rs @@ -528,6 +528,16 @@ pub struct PipelineAnnotations { pub column_lineage: Vec, pub macros: bool, pub use_libs: Vec, + // `// mute ` — suppress the auto-derived cascade edge for a read + // that would otherwise trigger this script (a lookup / slowly-changing + // dimension you read every run but don't want to re-run on). Only Asset + // specs are stored; native trigger kinds are never auto-derived, so + // muting them is meaningless. + pub mute: Vec, + // `// mute all` — opt out of auto-derivation entirely for this script. + // Falls back to explicit-`// on`-only semantics. Explicit `// on` edges + // are unaffected. + pub mute_all: bool, } impl ParseAssetsOutput { @@ -956,6 +966,23 @@ pub fn parse_pipeline_annotations(code: &str) -> PipelineAnnotations { continue; } + // `// mute all` opts out of auto-derived cascade edges entirely; + // `// mute ` suppresses the one edge. Only asset refs are + // muteable — native trigger kinds are never auto-derived. Checked + // before the generic `on`/asset shorthand (a complete word, so + // prose like `// muted for now` never matches). + if let Some(after_kw) = consume_keyword(rest, "mute") { + let arg = after_kw.trim(); + if arg == "all" { + out.mute_all = true; + } else if let Some(spec @ TriggerSpec::Asset { .. }) = parse_trigger_spec(arg) { + if !out.mute.contains(&spec) { + out.mute.push(spec); + } + } + continue; + } + // `data_test` is checked before `on`/asset shorthands and is a complete // word (so it never collides with the `// test:` CI annotation, which // has no whitespace after `test`). Accumulates — every well-formed line diff --git a/backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json b/backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json index cabfe20ddf..226f32914c 100644 --- a/backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json +++ b/backend/parsers/windmill-parser/tests/fixtures/pipeline_annotations.json @@ -769,5 +769,109 @@ "tag": null, "retry": null } + }, + { + "name": "mute suppresses a single ducklake read edge", + "code": "// pipeline\n// mute ducklake://main.orders\nselect 1", + "expected": { + "in_pipeline": true, + "asset_triggers": [], + "native_triggers": [], + "partition": null, + "freshness": null, + "tag": null, + "retry": null, + "mute": [ + "ducklake:main.orders" + ] + } + }, + { + "name": "mute all opts out of all auto-derivation", + "code": "// pipeline\n// mute all\nselect 1", + "expected": { + "in_pipeline": true, + "asset_triggers": [], + "native_triggers": [], + "partition": null, + "freshness": null, + "tag": null, + "retry": null, + "mute_all": true + } + }, + { + "name": "mute accumulates in order and dedups", + "code": "// pipeline\n// mute ducklake://main.a\n// mute s3://raw/b\n// mute ducklake://main.a\nselect 1", + "expected": { + "in_pipeline": true, + "asset_triggers": [], + "native_triggers": [], + "partition": null, + "freshness": null, + "tag": null, + "retry": null, + "mute": [ + "ducklake:main.a", + "s3object:raw/b" + ] + } + }, + { + "name": "mute of a native trigger kind is dropped (only assets are muteable)", + "code": "// pipeline\n// mute kafka\nselect 1", + "expected": { + "in_pipeline": true, + "asset_triggers": [], + "native_triggers": [], + "partition": null, + "freshness": null, + "tag": null, + "retry": null + } + }, + { + "name": "mute prose without an asset ref never false-positives", + "code": "// pipeline\n// muted for now\nselect 1", + "expected": { + "in_pipeline": true, + "asset_triggers": [], + "native_triggers": [], + "partition": null, + "freshness": null, + "tag": null, + "retry": null + } + }, + { + "name": "mute all coexists with explicit on edges", + "code": "// pipeline\n// mute all\n// on ducklake://main.orders\nselect 1", + "expected": { + "in_pipeline": true, + "asset_triggers": [ + "ducklake:main.orders" + ], + "native_triggers": [], + "partition": null, + "freshness": null, + "tag": null, + "retry": null, + "mute_all": true + } + }, + { + "name": "on asset ref strips trailing key=value opts", + "code": "// pipeline\n// on ducklake://main.orders debounce=60s\nselect 1", + "expected": { + "in_pipeline": true, + "asset_triggers": [ + "ducklake:main.orders" + ], + "native_triggers": [], + "partition": null, + "freshness": null, + "tag": null, + "retry": null + } } ] diff --git a/backend/parsers/windmill-parser/tests/pipeline_annotations_parity.rs b/backend/parsers/windmill-parser/tests/pipeline_annotations_parity.rs index 7adb07b55c..fff818edb2 100644 --- a/backend/parsers/windmill-parser/tests/pipeline_annotations_parity.rs +++ b/backend/parsers/windmill-parser/tests/pipeline_annotations_parity.rs @@ -53,6 +53,13 @@ struct Expected { // `// use ` accumulation, declaration order, deduped. Absent === []. #[serde(default)] use_libs: Vec, + // `// mute ` accumulation as `kind:path`, declaration order, deduped. + // Absent === []. + #[serde(default)] + mute: Vec, + // `// mute all` marker. Absent === false. + #[serde(default)] + mute_all: bool, } #[derive(Deserialize)] @@ -251,5 +258,18 @@ fn pipeline_annotation_fixtures_match() { assert_eq!(got.macros, f.expected.macros, "{ctx}: macros"); assert_eq!(got.use_libs, f.expected.use_libs, "{ctx}: use_libs"); + + let mute: Vec = got + .mute + .iter() + .filter_map(|t| match t { + TriggerSpec::Asset { asset_kind, path, .. } => { + Some(format!("{}:{}", kind_str(*asset_kind), path)) + } + _ => None, + }) + .collect(); + assert_eq!(mute, f.expected.mute, "{ctx}: mute"); + assert_eq!(got.mute_all, f.expected.mute_all, "{ctx}: mute_all"); } } diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index d7a461dafa..bfc7dd1ac7 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -45,8 +45,9 @@ use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; use windmill_common::{ assets::{ clear_script_triggers, clear_static_asset_usage, clear_static_asset_usage_by_script_hash, - insert_script_trigger, parse_duration_secs, parse_pipeline_annotations, - replace_static_asset_usage, trigger_spec_to_row, AssetUsageKind, TriggerSpec, + derive_pipeline_asset_trigger_refs, insert_script_trigger, parse_duration_secs, + parse_pipeline_annotations, replace_static_asset_usage, trigger_spec_to_row, + AssetUsageKind, ScriptTriggerKind, TriggerSpec, }, error::{self, to_anyhow}, min_version::{MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2}, @@ -1457,11 +1458,22 @@ async fn create_script_internal<'c>( // fire and the view would be an orphan node in the lineage graph. for (target_kind, path) in m.write_targets() { let kind = windmill_common::assets::asset_kind_from_parser(target_kind); - if !a.iter().any(|x| x.kind == kind && x.path == path) { + use windmill_common::assets::AssetUsageAccessType; + if let Some(existing) = a.iter_mut().find(|x| x.kind == kind && x.path == path) { + // The body reads its own managed target (an incremental/merge + // model `SELECT`ing from the table it materializes). The runtime + // still generates the write, so the effective access is RW — mark + // it so, otherwise it stays a plain read and (a) auto-derives a + // self-cascade edge back to this producer and (b) shows as a muted + // read on the canvas. Both are wrong: it's the script's own output. + if existing.access_type != Some(AssetUsageAccessType::W) { + existing.access_type = Some(AssetUsageAccessType::RW); + } + } else { a.push(windmill_common::assets::AssetWithAltAccessType { path, kind, - access_type: Some(windmill_common::assets::AssetUsageAccessType::W), + access_type: Some(AssetUsageAccessType::W), alt_access_type: None, columns: None, }); @@ -2035,6 +2047,63 @@ async fn create_script_internal<'c>( .await?; } + // Auto-derived cascade edges: within a `// pipeline`, a read of a ducklake + // table or s3 object wires the cascade edge straight from the FROM clause, + // so `// on ` is only needed for edges inference can't see (dynamic + // SQL) or to carry per-edge opts. `// mute ` / `// mute all` opt out. + // Explicit `// on` asset edges (inserted just above) win the dedup — they + // carry the per-edge debounce, so a derived row must not shadow them. + if in_pipeline && !pipeline_annotations.mute_all { + let asset_ref = |spec: &TriggerSpec| { + trigger_spec_to_row(spec) + .filter(|(k, _)| *k == ScriptTriggerKind::Asset) + .map(|(_, r)| r) + }; + let explicit_refs: std::collections::HashSet = + pipeline_triggers.iter().filter_map(asset_ref).collect(); + let muted_refs: std::collections::HashSet = pipeline_annotations + .mute + .iter() + .filter_map(asset_ref) + .collect(); + let derived = derive_pipeline_asset_trigger_refs( + effective_assets.as_deref().unwrap_or(&[]), + &explicit_refs, + &muted_refs, + pipeline_annotations.mute_all, + ); + // Derived edges have no per-`// on` opts, so they take the script-level + // `// debounce` default and `// retry` policy — same as writing a bare + // `// on ` would. + let derived_debounce_s = pipeline_debounce_default + .as_deref() + .and_then(parse_duration_secs); + let derived_retry_count = pipeline_annotations + .retry + .as_ref() + .map(|r| r.count.min(i16::MAX as u32) as i16); + let derived_retry_delay_s = pipeline_annotations + .retry + .as_ref() + .and_then(|r| r.delay.as_deref()) + .and_then(parse_duration_secs); + for trigger_ref in derived { + insert_script_trigger( + &mut *tx, + &w_id, + AssetUsageKind::Script, + &ns.path, + ScriptTriggerKind::Asset, + &trigger_ref, + pipeline_join_all, + derived_debounce_s, + derived_retry_count, + derived_retry_delay_s, + ) + .await?; + } + } + // Schedule annotations (`// on schedule`) are marker-only — the binding // lives on the schedule row's own `script_path` field, which the user // creates separately via the schedule editor. No script-create-time diff --git a/backend/windmill-common/src/assets.rs b/backend/windmill-common/src/assets.rs index 862bd645c3..62f6d32070 100644 --- a/backend/windmill-common/src/assets.rs +++ b/backend/windmill-common/src/assets.rs @@ -169,6 +169,68 @@ fn is_write_access(access: Option) -> bool { ) } +/// Kinds whose *read* usage auto-derives a cascade trigger edge inside a +/// `// pipeline`. Scoped to the two intra-pipeline data kinds — a ducklake +/// table read (the core case) and an s3 object read (file-ingestion +/// producers). Resource / datatable / volume reads stay explicit-`// on`: +/// a config/lookup read cascading is more often surprising than wanted. +fn is_auto_trigger_kind(kind: AssetKind) -> bool { + matches!(kind, AssetKind::Ducklake | AssetKind::S3Object) +} + +/// Trigger refs auto-derived from a pipeline script's inferred reads, so the +/// FROM clause alone wires the cascade edge (no redundant `// on `). +/// +/// Included: an input read read-*only* (`R`) of a supported kind +/// ([`is_auto_trigger_kind`]). The effective access type is +/// `access_type.or(alt_access_type)` — same precedence as the persisted +/// `asset.usage_access_type` and the frontend mirror's `access_type ?? +/// alt_access_type`, so a manual read override on an ambiguous parse still +/// derives an edge (and the live canvas and the deployed graph agree). +/// Excluded, each for a reason: +/// - `RW` / `W` — the script also writes the asset; an edge would be a +/// self-triggering loop. +/// - `None` access — usage is ambiguous (poisoned merge) with no override; +/// can't confirm a read, so fail safe and don't cascade. +/// - already in `explicit_refs` — the author wrote `// on `, which +/// wins (it carries the per-edge debounce/opts). +/// - in `muted_refs` — a `// mute ` opt-out (lookup / SCD input). +/// +/// `mute_all` (from `// mute all`) short-circuits to no derivation, leaving +/// only the explicit `// on` edges. Returns canonical refs (e.g. +/// `ducklake://main.orders`), deduped, in input order. +pub fn derive_pipeline_asset_trigger_refs( + assets: &[AssetWithAltAccessType], + explicit_refs: &HashSet, + muted_refs: &HashSet, + mute_all: bool, +) -> Vec { + if mute_all { + return vec![]; + } + let mut out = vec![]; + let mut seen = HashSet::new(); + for a in assets { + // Effective access mirrors the persisted `usage_access_type` and the + // frontend derivation: an explicit parse wins, else the manual override. + let access = a.access_type.or(a.alt_access_type); + if access != Some(AssetUsageAccessType::R) || !is_auto_trigger_kind(a.kind) { + continue; + } + let Some(prefix) = a.kind.canonical_prefix() else { + continue; + }; + let r = format!("{}{}", prefix, a.path); + if explicit_refs.contains(&r) || muted_refs.contains(&r) { + continue; + } + if seen.insert(r.clone()) { + out.push(r); + } + } + out +} + /// Clear and reinsert the full static-asset usage set of a script in one tx, /// invalidating the producer-writes cache at most once and only on a real /// change. The cache (asset_dispatch::ASSET_PRODUCER_WRITES_CACHE) keys a @@ -369,6 +431,134 @@ mod debounce_duration_tests { } } +#[cfg(test)] +mod derive_trigger_tests { + use super::{derive_pipeline_asset_trigger_refs, AssetKind, AssetUsageAccessType}; + use std::collections::HashSet; + use windmill_types::assets::AssetWithAltAccessType; + + fn asset( + kind: AssetKind, + path: &str, + at: Option, + ) -> AssetWithAltAccessType { + AssetWithAltAccessType { + path: path.to_string(), + kind, + access_type: at, + alt_access_type: None, + columns: None, + } + } + + fn derive(assets: &[AssetWithAltAccessType]) -> Vec { + derive_pipeline_asset_trigger_refs(assets, &HashSet::new(), &HashSet::new(), false) + } + + #[test] + fn read_only_ducklake_and_s3_derive_an_edge() { + use AssetUsageAccessType::R; + let a = [ + asset(AssetKind::Ducklake, "main.orders", Some(R)), + asset(AssetKind::S3Object, "raw/events", Some(R)), + ]; + assert_eq!( + derive(&a), + vec![ + "ducklake://main.orders".to_string(), + "s3://raw/events".to_string() + ] + ); + } + + #[test] + fn writes_and_rw_are_skipped_to_avoid_self_edges() { + use AssetUsageAccessType::*; + // W (pure producer) and RW (reads *and* writes the same table — a + // self-cascade if edged) both derive nothing. + let a = [ + asset(AssetKind::Ducklake, "main.out", Some(W)), + asset(AssetKind::Ducklake, "main.self", Some(RW)), + ]; + assert!(derive(&a).is_empty()); + } + + #[test] + fn ambiguous_access_and_unsupported_kinds_are_skipped() { + use AssetUsageAccessType::R; + let a = [ + asset(AssetKind::Ducklake, "main.ambiguous", None), // poisoned merge + asset(AssetKind::Resource, "f/db", Some(R)), // out of scope + asset(AssetKind::DataTable, "main.dt", Some(R)), // out of scope + ]; + assert!(derive(&a).is_empty()); + } + + #[test] + fn manual_read_override_on_ambiguous_parse_derives_an_edge() { + use AssetUsageAccessType::{R, W}; + // Parser can't confirm access (`access_type: None`) but the user manually + // overrode it. Effective access = `access_type.or(alt_access_type)`, the + // same value persisted to `asset.usage_access_type` and used by the + // frontend canvas — so a read override derives an edge (parity, no + // silently-vanishing edge on deploy) and a write override does not. + let read_override = AssetWithAltAccessType { + path: "main.override_r".to_string(), + kind: AssetKind::Ducklake, + access_type: None, + alt_access_type: Some(R), + columns: None, + }; + let write_override = AssetWithAltAccessType { + path: "main.override_w".to_string(), + kind: AssetKind::Ducklake, + access_type: None, + alt_access_type: Some(W), + columns: None, + }; + assert_eq!( + derive(&[read_override, write_override]), + vec!["ducklake://main.override_r".to_string()] + ); + } + + #[test] + fn explicit_and_muted_refs_are_excluded() { + use AssetUsageAccessType::R; + let a = [ + asset(AssetKind::Ducklake, "main.explicit", Some(R)), + asset(AssetKind::Ducklake, "main.muted", Some(R)), + asset(AssetKind::Ducklake, "main.keep", Some(R)), + ]; + let explicit: HashSet = ["ducklake://main.explicit".to_string()].into(); + let muted: HashSet = ["ducklake://main.muted".to_string()].into(); + assert_eq!( + derive_pipeline_asset_trigger_refs(&a, &explicit, &muted, false), + vec!["ducklake://main.keep".to_string()] + ); + } + + #[test] + fn mute_all_derives_nothing() { + use AssetUsageAccessType::R; + let a = [asset(AssetKind::Ducklake, "main.orders", Some(R))]; + assert!( + derive_pipeline_asset_trigger_refs(&a, &HashSet::new(), &HashSet::new(), true) + .is_empty() + ); + } + + #[test] + fn duplicate_reads_dedup() { + use AssetUsageAccessType::R; + let a = [ + asset(AssetKind::Ducklake, "main.orders", Some(R)), + asset(AssetKind::Ducklake, "main.orders", Some(R)), + ]; + assert_eq!(derive(&a), vec!["ducklake://main.orders".to_string()]); + } +} + #[cfg(test)] mod trigger_ref_roundtrip_tests { use super::{parse_asset_trigger_ref, trigger_spec_to_row, AssetKind, ScriptTriggerKind}; diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte index fbaf3ed6ed..64bd2790cb 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphCanvas.svelte @@ -18,6 +18,7 @@ import PanToNode from './PanToNode.svelte' import InitialFitView from './InitialFitView.svelte' import { layoutAssetGraph } from './assetGraphLayout' + import { computeMutedReadKeys } from './resolveGraph' import { buildDownstreamMap } from './graphTraversal' import { buildLineageDownstreamMap } from './boundedCascade' import type { AssetGraphResponse, AssetGraphSelection, NativeTriggerKind } from './types' @@ -228,6 +229,11 @@ | 'macro' | 'test-dependency' unsaved?: boolean + // Muted read edge: a ducklake/s3 input read every run whose (default) + // auto cascade trigger is suppressed by `// mute` / `// mute all`. + // Rendered with a bell-off badge — auto-wiring is the norm, so we mark + // the read that deliberately does NOT cascade, not every derived edge. + muted?: boolean // Edge from a missing-trigger placeholder — styled red dashed to // signal "this script declared `// on kafka` but no trigger row // targets it; create one or remove the annotation". @@ -490,6 +496,10 @@ }) } + // Read edges of a ducklake/s3 asset with no cascade trigger = muted + // (`// mute` / `// mute all` opted the default auto trigger out). Gated + // on pipeline scripts inside the helper (non-pipeline reads never derive). + const mutedReadKeys = computeMutedReadKeys(g.edges, g.triggers, g.runnables) for (const e of g.edges) { const runnableId = `${e.runnable_kind}:${e.runnable_path}` const assetId = `asset:${e.asset_kind}:${e.asset_path}` @@ -542,7 +552,13 @@ source: assetId, target: runnableId, kind: 'lineage-read', - unsaved: e.unsaved + unsaved: e.unsaved, + // Only a pure `'r'` read can be muted; `'rw'` is a self-read. + muted: + access === 'r' && + mutedReadKeys.has( + `${e.asset_kind}:${e.asset_path}->${e.runnable_kind}:${e.runnable_path}` + ) }) } } @@ -1079,7 +1095,9 @@ // Macro-edge badge: which of the library's macros the consumer // calls (all of them when pulled in via `// use`). macro_names: e.macro_names, - via_use: e.via_use + via_use: e.via_use, + // Muted read edge — bell-off badge on the read link. + muted: e.muted }, animated, label, diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte index a097f09b6c..4e5acaee08 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte @@ -636,7 +636,9 @@ dataTests: [], columnLineage: [], macros: false, - useLibs: [] + useLibs: [], + muteAssets: [], + muteAll: false } ) // `// macros` library: the defined signatures for the strip above the diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte index 329fad6902..e16e11566d 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphEdge.svelte @@ -1,7 +1,7 @@ @@ -197,10 +186,14 @@ {#snippet headerRight()} {#if $superadmin || $userStore?.is_admin} + {#snippet trigger()}
@@ -246,8 +239,8 @@ {#if $superadmin} {#snippet trigger()}
@@ -304,6 +297,7 @@ 0 } + // Load the channel state on mount so the "no channels" warning doesn't depend on + // there being unacknowledged alerts to trigger a refresh (muting auto-acks them). + onMount(() => { + if ($superadmin) checkCriticalAlertChannels() + }) + async function acknowledgeAlert(id: number) { await acknowledgeCriticalAlert({ id }) getAlerts(false) @@ -133,7 +143,7 @@ - {#if !hasCriticalAlertChannels && $superadmin} + {#if $superadmin && isMuted && !hasCriticalAlertChannels}
Go to the From fd8e64d11fea3ffdb7858100c67cb2e9ca841ed6 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Mon, 6 Jul 2026 20:43:17 +0200 Subject: [PATCH 10/14] feat: add cosmetic dev/staging label for dev workspaces (#9959) * feat: add cosmetic dev/staging label for dev workspaces Co-Authored-By: Claude Opus 4.8 (1M context) * feat: prefill dev fork name and use a link to switch its label Co-Authored-By: Claude Opus 4.8 (1M context) * style: reword the dev/staging label link copy Co-Authored-By: Claude Opus 4.8 (1M context) * style: preview the dev/staging label as a badge in the switch link Co-Authored-By: Claude Opus 4.8 (1M context) * feat: show the dev/staging badge in the session diff drawer header Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- ...dd530a653081619e6d3132bf07996520b1e25.json | 19 ++++ ...a29a8f88010891fcf3eaf4761a57461f97703.json | 23 +++++ ...0c956bea94dffb46b3b838c096f04b66d6c52.json | 34 +++++++ ...1493fcdbab4ae91cae271ea911f5b14ecf0d4.json | 18 ++++ ...2d0bbe1f5aff27187505778b6abe2c87a5d01.json | 16 ++++ ...da5c60aa6878b2c77f6028a68d64f797c3322.json | 70 ++++++++++++++ ...706094033_add_dev_workspace_label.down.sql | 1 + ...60706094033_add_dev_workspace_label.up.sql | 4 + .../windmill-api-workspaces/src/workspaces.rs | 91 +++++++++++++++++-- .../src/workspaces_extra.rs | 10 +- backend/windmill-api/openapi.yaml | 41 +++++++++ .../lib/components/DevWorkspaceSetting.svelte | 69 ++++++++++++-- .../lib/components/ForkWorkspaceBanner.svelte | 5 +- .../lib/components/NoDirectDeployAlert.svelte | 9 +- .../sessions/SessionDiffDrawer.svelte | 5 + .../sessions/SessionWorkspaceBar.svelte | 3 +- .../sessions/WorkspaceFamilyPicker.svelte | 5 +- .../components/sidebar/WorkspaceMenu.svelte | 6 +- .../components/workspace/WorkspaceCard.svelte | 4 +- .../components/workspace/WorkspaceIcon.svelte | 5 +- .../CreateWorkspaceInner.svelte | 40 +++++++- frontend/src/lib/stores.ts | 1 + frontend/src/lib/utils/devWorkspaceLabel.ts | 25 +++++ 23 files changed, 472 insertions(+), 32 deletions(-) create mode 100644 backend/.sqlx/query-530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25.json create mode 100644 backend/.sqlx/query-63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703.json create mode 100644 backend/.sqlx/query-868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52.json create mode 100644 backend/.sqlx/query-8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4.json create mode 100644 backend/.sqlx/query-9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01.json create mode 100644 backend/.sqlx/query-af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322.json create mode 100644 backend/migrations/20260706094033_add_dev_workspace_label.down.sql create mode 100644 backend/migrations/20260706094033_add_dev_workspace_label.up.sql create mode 100644 frontend/src/lib/utils/devWorkspaceLabel.ts diff --git a/backend/.sqlx/query-530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25.json b/backend/.sqlx/query-530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25.json new file mode 100644 index 0000000000..465ee41633 --- /dev/null +++ b/backend/.sqlx/query-530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace\n (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n VALUES ($1, $2, $3, $4, $5, $6)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "530a797e67ff352471f1b34f260dd530a653081619e6d3132bf07996520b1e25" +} diff --git a/backend/.sqlx/query-63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703.json b/backend/.sqlx/query-63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703.json new file mode 100644 index 0000000000..3959f4e8cc --- /dev/null +++ b/backend/.sqlx/query-63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "63d6d968905cf82fb3bb0577d41a29a8f88010891fcf3eaf4761a57461f97703" +} diff --git a/backend/.sqlx/query-868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52.json b/backend/.sqlx/query-868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52.json new file mode 100644 index 0000000000..34d99a7e61 --- /dev/null +++ b/backend/.sqlx/query-868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "dev_workspace_label", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "868985685d95197efc534bb2f3e0c956bea94dffb46b3b838c096f04b66d6c52" +} diff --git a/backend/.sqlx/query-8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4.json b/backend/.sqlx/query-8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4.json new file mode 100644 index 0000000000..3f8deb8835 --- /dev/null +++ b/backend/.sqlx/query-8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label)\n SELECT $1, $2, owner, false, premium,\n CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5,\n CASE WHEN $5 THEN dev_workspace_label ELSE NULL END\n FROM workspace WHERE id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + "Bool", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "8ed229e88dc49b0ba7328d48f991493fcdbab4ae91cae271ea911f5b14ecf0d4" +} diff --git a/backend/.sqlx/query-9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01.json b/backend/.sqlx/query-9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01.json new file mode 100644 index 0000000000..a29b5f0924 --- /dev/null +++ b/backend/.sqlx/query-9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "9f567f04f67ce3b197eaa641eaf2d0bbe1f5aff27187505778b6abe2c87a5d01" +} diff --git a/backend/.sqlx/query-af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322.json b/backend/.sqlx/query-af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322.json new file mode 100644 index 0000000000..c21a21163e --- /dev/null +++ b/backend/.sqlx/query-af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "parent_workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_dev_workspace", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "dev_workspace_label", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "operator_settings", + "type_info": "Jsonb" + }, + { + "ordinal": 8, + "name": "disabled", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + true, + null, + false + ] + }, + "hash": "af19b9e3deb4f5c9e6ba77963a5da5c60aa6878b2c77f6028a68d64f797c3322" +} diff --git a/backend/migrations/20260706094033_add_dev_workspace_label.down.sql b/backend/migrations/20260706094033_add_dev_workspace_label.down.sql new file mode 100644 index 0000000000..0b9b81636a --- /dev/null +++ b/backend/migrations/20260706094033_add_dev_workspace_label.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace DROP COLUMN dev_workspace_label; diff --git a/backend/migrations/20260706094033_add_dev_workspace_label.up.sql b/backend/migrations/20260706094033_add_dev_workspace_label.up.sql new file mode 100644 index 0000000000..951f1ec3c9 --- /dev/null +++ b/backend/migrations/20260706094033_add_dev_workspace_label.up.sql @@ -0,0 +1,4 @@ +-- Cosmetic display label for a dev workspace: NULL/'dev' render as "dev", 'staging' renders as "stg". +-- Only meaningful when is_dev_workspace = true; changes nothing about behavior (locking, promote and +-- compare all key off is_dev_workspace / parent_workspace_id). The value is validated in the handler. +ALTER TABLE workspace ADD COLUMN dev_workspace_label VARCHAR; diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 228b8d1831..8d53562635 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -156,6 +156,7 @@ pub fn workspaced_service() -> Router { .route("/create_fork", post(create_workspace_fork)) .route("/attach_dev_workspace", post(attach_dev_workspace)) .route("/detach_dev_workspace", post(detach_dev_workspace)) + .route("/set_dev_workspace_label", post(set_dev_workspace_label)) .route("/get_dev_workspace", get(get_dev_workspace)) .route("/change_workspace_name", post(change_workspace_name)) .route("/change_workspace_color", post(change_workspace_color)) @@ -472,6 +473,10 @@ struct CreateWorkspaceFork { /// the team can work in it. Defaults off; the dev-workspace UI defaults it on. #[serde(default)] copy_members: bool, + /// Cosmetic display label for the dev workspace: 'dev' | 'staging'. Purely visual (badge text + + /// wording); ignored for non-dev forks. None defaults to 'dev'. + #[serde(default)] + dev_workspace_label: Option, } #[derive(Deserialize)] @@ -501,6 +506,7 @@ struct UserWorkspace { pub operator_settings: Option>, pub parent_workspace_id: Option, pub is_dev_workspace: bool, + pub dev_workspace_label: Option, pub disabled: bool, } @@ -678,6 +684,20 @@ async fn exists_workspace( struct DevWorkspaceInfo { id: String, name: String, + dev_workspace_label: Option, +} + +/// Normalize/validate the cosmetic dev-workspace display label. None or 'dev' both render as "dev"; +/// 'staging' renders as "stg". Anything else is rejected. Stored explicitly ('dev'/'staging') so it +/// round-trips, but a NULL column is treated as 'dev' on the read side too. +fn normalize_dev_workspace_label(label: Option) -> Result> { + match label.as_deref() { + None | Some("dev") => Ok(Some("dev".to_string())), + Some("staging") => Ok(Some("staging".to_string())), + Some(other) => Err(Error::BadRequest(format!( + "invalid dev workspace label '{other}' (expected 'dev' or 'staging')" + ))), + } } /// This workspace's active canonical dev workspace, if any. The create-fork UI and the dev-workspace @@ -691,7 +711,7 @@ async fn get_dev_workspace( ) -> JsonResult> { let dev = sqlx::query_as!( DevWorkspaceInfo, - "SELECT id, name FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", + "SELECT id, name, dev_workspace_label FROM workspace WHERE parent_workspace_id = $1 AND is_dev_workspace AND deleted = false", &w_id ) .fetch_optional(&db) @@ -3697,7 +3717,7 @@ async fn user_workspaces( let workspaces = sqlx::query_as!( UserWorkspace, "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id, - workspace.is_dev_workspace, + workspace.is_dev_workspace, workspace.dev_workspace_label, CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings, usr.disabled FROM workspace @@ -5187,6 +5207,8 @@ async fn create_workspace_fork_branch( // that second call. Validating early lets a bad request fail before any branch is created. if nw.is_dev_workspace { validate_dev_workspace_id(&nw.id)?; + // Reject a bad cosmetic label before any git branch is created (acted on in create_workspace_fork). + normalize_dev_workspace_label(nw.dev_workspace_label.clone())?; ensure_dev_parent_is_root(&db, &w_id).await?; // Reject before creating any git branch if the parent already has a dev workspace, // otherwise the deferred branch-creation job leaves a dangling branch on the synced repos. @@ -5418,6 +5440,12 @@ async fn create_workspace_fork( validate_fork_workspace_id(&nw.id)?; } validate_workspace_name(&nw.name)?; + // Cosmetic label only applies to dev workspaces; a non-dev fork stores NULL. + let dev_workspace_label = if nw.is_dev_workspace { + normalize_dev_workspace_label(nw.dev_workspace_label.clone())? + } else { + None + }; // Check the id conflict before the CE workspace-count limit so that // re-using a taken (possibly archived) fork id reports the actual // conflict instead of a misleading "maximum number of workspaces" error. @@ -5495,13 +5523,14 @@ async fn create_workspace_fork( sqlx::query!( "INSERT INTO workspace - (id, name, owner, parent_workspace_id, is_dev_workspace) - VALUES ($1, $2, $3, $4, $5)", + (id, name, owner, parent_workspace_id, is_dev_workspace, dev_workspace_label) + VALUES ($1, $2, $3, $4, $5, $6)", forked_id, nw.name, authed.email, parent_workspace_id, nw.is_dev_workspace, + dev_workspace_label, ) .execute(&mut *tx) .await?; @@ -5634,6 +5663,9 @@ struct AttachDevWorkspace { lock_prod_deploy: bool, #[serde(default)] lock_prod_forking: bool, + /// Cosmetic display label for the attached dev workspace: 'dev' | 'staging'. None defaults to 'dev'. + #[serde(default)] + dev_workspace_label: Option, } #[derive(Deserialize)] @@ -5687,6 +5719,7 @@ async fn attach_dev_workspace( // The id is interpolated into a `wm-fork//` branch name like any fork. validate_dev_workspace_id(&dev_w_id)?; + let dev_workspace_label = normalize_dev_workspace_label(req.dev_workspace_label.clone())?; let dev = sqlx::query!( r#"SELECT parent_workspace_id, deleted FROM workspace WHERE id = $1"#, @@ -5754,9 +5787,10 @@ async fn attach_dev_workspace( let mut tx = db.begin().await?; sqlx::query!( - "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true WHERE id = $2", + "UPDATE workspace SET parent_workspace_id = $1, is_dev_workspace = true, dev_workspace_label = $3 WHERE id = $2", &prod_w_id, - &dev_w_id + &dev_w_id, + dev_workspace_label, ) .execute(&mut *tx) .await?; @@ -5822,6 +5856,51 @@ async fn attach_dev_workspace( )) } +#[derive(Deserialize)] +struct SetDevWorkspaceLabel { + #[serde(default)] + dev_workspace_label: Option, +} + +/// Change the cosmetic display label ('dev' | 'staging') of the current workspace, which must itself +/// be a dev workspace. Purely visual (badge text + wording); requires admin of the dev workspace. +async fn set_dev_workspace_label( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(req): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + let label = normalize_dev_workspace_label(req.dev_workspace_label)?; + + let mut tx = db.begin().await?; + let updated = sqlx::query_scalar!( + "UPDATE workspace SET dev_workspace_label = $1 WHERE id = $2 AND is_dev_workspace RETURNING id", + label, + &w_id, + ) + .fetch_optional(&mut *tx) + .await?; + if updated.is_none() { + return Err(Error::BadRequest(format!( + "Workspace '{w_id}' is not a dev workspace" + ))); + } + + audit_log( + &mut *tx, + &authed, + "workspaces.set_dev_workspace_label", + ActionKind::Update, + &w_id, + label.as_deref(), + None, + ) + .await?; + tx.commit().await?; + Ok(format!("Updated dev workspace label for {w_id}")) +} + /// Reverse [`attach_dev_workspace`] / clear the dev designation: unset the dev flag and remove the /// prod lock. The workspace keeps its `parent_workspace_id` (it remains an ordinary fork). async fn detach_dev_workspace( diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 9fbaad2bf3..6b4ace5f0a 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -91,9 +91,10 @@ pub(crate) async fn change_workspace_id( .await?; } sqlx::query!( - "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace) + "INSERT INTO workspace (id, name, owner, deleted, premium, parent_workspace_id, is_dev_workspace, dev_workspace_label) SELECT $1, $2, owner, false, premium, - CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5 + CASE WHEN $4 THEN parent_workspace_id ELSE NULL END, $5, + CASE WHEN $5 THEN dev_workspace_label ELSE NULL END FROM workspace WHERE id = $3", &rw.new_id, &rw.new_name, @@ -1095,7 +1096,10 @@ pub(crate) async fn delete_workspace( // effort: failures are logged — the workspace row is already gone, and broken storage // credentials must not have made it undeletable. for e in cleanup_fork_ducklake_namespaces(&db, &w_id, fork_ducklake_cleanups).await { - tracing::warn!("deleted workspace {w_id}: ducklake namespace cleanup: {}", e.msg); + tracing::warn!( + "deleted workspace {w_id}: ducklake namespace cleanup: {}", + e.msg + ); } if let Some(parent) = dev_lock_parent { diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 16a1f88ffc..ebeeb999f9 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1205,6 +1205,9 @@ paths: type: boolean lock_prod_forking: type: boolean + dev_workspace_label: + type: string + enum: [dev, staging] required: - dev_workspace_id responses: @@ -1263,10 +1266,40 @@ paths: type: string name: type: string + dev_workspace_label: + type: string + nullable: true + description: "Cosmetic display label ('dev' | 'staging'); null defaults to 'dev'" required: - id - name + /w/{workspace}/workspaces/set_dev_workspace_label: + post: + summary: set the cosmetic display label (dev/staging) of this dev workspace + operationId: setDevWorkspaceLabel + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + dev_workspace_label: + type: string + enum: [dev, staging] + responses: + "200": + description: dev workspace label updated + content: + text/plain: + schema: + type: string + /workspaces/exists: post: summary: exists workspace @@ -28180,6 +28213,10 @@ components: nullable: true is_dev_workspace: type: boolean + dev_workspace_label: + type: string + nullable: true + description: "Cosmetic display label of the dev workspace ('dev' | 'staging'); null defaults to 'dev'" created_by: type: string nullable: true @@ -28249,6 +28286,10 @@ components: copy_members: type: boolean description: "Copy the parent's members (users + group memberships) into the fork so the team can work in it" + dev_workspace_label: + type: string + enum: [dev, staging] + description: "Cosmetic display label for the dev workspace (badge text + wording only); ignored for non-dev forks" required: - id - name diff --git a/frontend/src/lib/components/DevWorkspaceSetting.svelte b/frontend/src/lib/components/DevWorkspaceSetting.svelte index bdba23c57f..e6295d24cd 100644 --- a/frontend/src/lib/components/DevWorkspaceSetting.svelte +++ b/frontend/src/lib/components/DevWorkspaceSetting.svelte @@ -1,7 +1,7 @@ @@ -54,7 +56,9 @@ {tooltip} {/if}
- {#if actionButton} + {#if headerAction} + {@render headerAction()} + {:else if actionButton} +
+ {/if} +{/snippet} + -
-