From 88056f8d4c91c1d14d85a08851ecf0bd97e2260d Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 27 May 2026 11:28:11 +0000 Subject: [PATCH 01/52] fix(cli): redact encryption_key diff in stdout by default (#9347) * fix(cli): redact encryption_key diff in stdout by default Sync diff output previously printed the full encryption_key contents on stdout whenever the workspace key changed locally or on the remote, which made it easy to leak the key via shell history, CI logs, etc. Now the diff is replaced with a redacted notice for any encryption_key change in both prettyChanges and showConflict. Pass --show-encryption-key-diff (also configurable via wmill.yaml's showEncryptionKeyDiff) to opt back into the full diff. Fixes WIN-1992 Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(cli): redact encryption_key diff with fixed-length mask Drop the --show-encryption-key-diff opt-in and always redact: the diff now keeps the first 5 chars of the key so rotations are still visible (different prefixes), then replaces every remaining char with `*` so the length of the key is preserved without leaking it. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- cli/src/commands/sync/sync.ts | 11 +++++++++-- cli/src/types.ts | 37 ++++++++++++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 625fe69780..22cdd5900b 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -22,6 +22,7 @@ import { showConflict, showDiff, extractNativeTriggerInfo, + redactEncryptionKey, } from "../../types.ts"; import { downloadZip } from "./pull.ts"; import { runLint, printReport, checkMissingLocks } from "../lint/lint.ts"; @@ -3095,16 +3096,22 @@ function prettyChanges( ), ); } else if (change.name === "edited") { + const changeType = getTypeStrFromPath(change.path); log.info( colors.yellow( - `~ ${getTypeStrFromPath(change.path)} ` + + `~ ${changeType} ` + displayPath + colors.gray(wsNote) + (change.codebase ? ` (codebase changed)` : ""), ), ); if (change.before != change.after) { - if (change.path.endsWith(".yaml")) { + if (changeType === "encryption_key") { + showDiff( + redactEncryptionKey(change.before), + redactEncryptionKey(change.after), + ); + } else if (change.path.endsWith(".yaml")) { try { showDiff( yamlStringify( diff --git a/cli/src/types.ts b/cli/src/types.ts index e0d45adb66..75bad535c8 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -129,11 +129,46 @@ export function showDiff(local: string, remote: string) { export function showConflict(path: string, local: string, remote: string) { log.info(colors.yellow(`- ${path}`)); - showDiff(local, remote); + let isEncryptionKey = false; + try { + isEncryptionKey = getTypeStrFromPath(path) === "encryption_key"; + } catch { + // ignore + } + if (isEncryptionKey) { + showDiff(redactEncryptionKey(local), redactEncryptionKey(remote)); + } else { + showDiff(local, remote); + } log.info("\x1b[31mlocal\x1b[31m - \x1b[32mremote\x1b[32m"); log.info("\n"); } +// Reveal only the first 5 chars of the key so a rotation is still visible in +// the diff (different prefixes), without leaking the whole secret to stdout. +// The remaining chars are replaced with `*`, preserving length so the diff +// keeps showing whether the key length changed. +export function redactEncryptionKey(content: string): string { + if (!content) return content; + // The encryption_key payload is JSON-encoded (a quoted string). Parse it so + // we redact the key value itself, then re-serialize to JSON to preserve the + // file's shape; fall back to raw redaction if parsing fails. + try { + const parsed = JSON.parse(content); + if (typeof parsed === "string") { + return JSON.stringify(redactString(parsed)); + } + } catch { + // not JSON — treat content as the raw key + } + return redactString(content); +} + +function redactString(s: string): string { + if (s.length <= 5) return s; + return s.slice(0, 5) + "*".repeat(s.length - 5); +} + /** * Pushes an object to the workspace server based on its type * @param workspace - The workspace ID to push to From e29dfbaa877c7b911727e060f408a134ad6b4f76 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Wed, 27 May 2026 14:19:57 +0200 Subject: [PATCH 02/52] test: add global chat eval coverage (#9320) * test: improve global chat eval parity * test: add human-style global chat evals --- .../frontend/core/global/globalEvalRunner.ts | 11 +- .../adapters/frontend/vitestAdapter.test.ts | 30 +++ ai_evals/cases/global.yaml | 254 ++++++++++++++++++ ai_evals/core/types.ts | 4 +- ai_evals/core/validators.test.ts | 63 +++++ ai_evals/core/validators.ts | 115 ++++++-- .../global/initial/process_invoice_flow.json | 40 +++ 7 files changed, 497 insertions(+), 20 deletions(-) create mode 100644 ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts index 5e00dd6f34..8541348c51 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -7,7 +7,10 @@ import { prepareGlobalSystemMessage, prepareGlobalUserMessage, } from "../../../../../frontend/src/lib/components/copilot/chat/global/core"; -import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte"; +import { + clearGlobalDrafts, + listGlobalDrafts, +} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter"; import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; import type { ModeRunContext } from "../../../../core/types"; import type { GlobalDraftState } from "../../../../core/validators"; @@ -55,7 +58,7 @@ export async function runGlobalEval( options.workspaceRoot ?? (await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-"))); - globalDraftStore.clearDrafts(workspaceRoot); + clearGlobalDrafts(workspaceRoot); registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {}); try { @@ -67,7 +70,7 @@ export async function runGlobalEval( tools: getGlobalEvalTools(), helpers: {}, apiKey, - getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }), + getOutput: () => ({ drafts: listGlobalDrafts(workspaceRoot) }), onAssistantMessageStart: options.runContext?.onAssistantMessageStart, onAssistantToken: options.runContext?.onAssistantChunk, onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd, @@ -94,7 +97,7 @@ export async function runGlobalEval( tokenUsage: rawResult.tokenUsage, }; } finally { - globalDraftStore.clearDrafts(workspaceRoot); + clearGlobalDrafts(workspaceRoot); unregisterBenchmarkWorkspaceRunnables(workspaceRoot); if (!options.workspaceRoot) { await rm(workspaceRoot, { recursive: true, force: true }); diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 1275acf0b4..92c43aab06 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -79,6 +79,16 @@ vi.mock('$lib/gen', async () => { } return actual.ScriptService.getScriptByPath(data) }, + getScriptByPathWithDraft: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + const script = getBenchmarkScriptByPath(data.workspace, data.path) + if (!script) { + throw new Error(`Script "${data.path}" not found in benchmark workspace`) + } + return script + } + return actual.ScriptService.getScriptByPathWithDraft(data) + }, getScriptByHash: async (data: { workspace: string; hash: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { const script = getBenchmarkScriptByHash(data.workspace, data.hash) @@ -108,6 +118,26 @@ vi.mock('$lib/gen', async () => { return flow } return actual.FlowService.getFlowByPath(data) + }, + getFlowByPathWithDraft: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + const flow = getBenchmarkFlowByPath(data.workspace, data.path) + if (!flow) { + throw new Error(`Flow "${data.path}" not found in benchmark workspace`) + } + return flow + } + return actual.FlowService.getFlowByPathWithDraft(data) + }, + getFlowLatestVersion: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + const flow = getBenchmarkFlowByPath(data.workspace, data.path) + if (!flow) { + throw new Error(`Flow "${data.path}" not found in benchmark workspace`) + } + return { id: 1 } + } + return actual.FlowService.getFlowLatestVersion(data) } }), JobService: wrapService(actual.JobService, { diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index b4526f8a85..373408a23d 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -87,3 +87,257 @@ - the flow accepts numeric inputs a and b - the flow returns the sum of a and b - the result stays as an AI draft and is not deployed or saved to the workspace + +- id: global-test4-multi-artifact-notification-job + prompt: |- + Set up a draft stale-trial notification job. + Create a Bun script at `f/evals/global/check_stale_trials` that accepts `max_age_days`, uses mocked inline trial account data, and returns the stale trial account IDs. + Also create a weekday 09:00 UTC schedule at `f/evals/global/check_stale_trials_weekday` for that script with `max_age_days` set to 14. + Add an HTTP POST trigger at `f/evals/global/check_stale_trials_manual` with route path `evals/check-stale-trials` that runs the same script manually. + Leave everything as AI drafts only; do not deploy or save anything to the workspace. + runtime: + maxTurns: 12 + validate: + draftCountExactly: 3 + requiredDrafts: + - type: script + path: f/evals/global/check_stale_trials + language: bun + valueIncludes: + - max_age_days + - trial + - type: schedule + path: f/evals/global/check_stale_trials_weekday + valueIncludes: + - f/evals/global/check_stale_trials + - UTC + - "14" + - type: trigger + triggerKind: http + path: f/evals/global/check_stale_trials_manual + valueIncludes: + - evals/check-stale-trials + - f/evals/global/check_stale_trials + toolExpect: + requiredToolsUsed: + - write_script + - write_schedule + - write_trigger + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates a Bun script draft for stale trial accounts + - creates a weekday 09:00 UTC schedule draft for the script with max_age_days set to 14 + - creates an HTTP POST trigger draft with route path evals/check-stale-trials for the same script + - leaves all artifacts as drafts only and does not deploy + +- id: global-test5-existing-flow-inline-code-edit + prompt: |- + Update the existing flow at `f/evals/global/process_invoice`. + Only change the `calculate_total` inline code so it applies 8% tax and returns an object containing `subtotal`, `tax`, and `total`. + Leave the updated flow as an AI draft only; do not deploy or save it. + initial: ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json + runtime: + maxTurns: 10 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + path: f/evals/global/process_invoice + valueIncludes: + - calculate_total + - tax + - total + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - reads the existing process_invoice flow before editing it + - updates the calculate_total inline code to apply 8% tax + - returns subtotal, tax, and total from the updated flow logic + - leaves the result as an AI draft only + +- id: global-test6-secret-variable-draft + prompt: |- + Create a secret variable draft at `f/evals/global/slack_bot_token`. + Use the placeholder value `xoxb-redacted-test-token` and description `Slack bot token for eval notifications`. + Do not create any resource or deploy anything. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: variable + path: f/evals/global/slack_bot_token + valueIncludes: + - Slack bot token + - "true" + forbiddenDrafts: + - type: resource + path: f/evals/global/slack_bot_token + toolExpect: + requiredToolsUsed: + - write_variable + forbiddenToolsUsed: + - write_resource + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - xoxb-redacted-test-token + skipJudge: true + judgeChecklist: + - creates exactly one secret variable draft at f/evals/global/slack_bot_token + - uses the requested placeholder value and description + - does not create a resource or deploy anything + +- id: global-test7-ambiguous-app-asks-question + prompt: |- + Create a new raw app for triaging support tickets. + runtime: + maxTurns: 4 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - askUserQuestion + forbiddenToolsUsed: + - init_app + - write_app_file + - write_app_runnable + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + +- id: global-test8-human-script-infer-path-language + prompt: |- + I need a small helper that formats a customer-facing welcome line. + It should take a person's name and return "Welcome aboard, !". + Please just stage it as a draft for now. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + valueIncludes: + - Welcome aboard + - name + toolExpect: + requiredToolsUsed: + - write_script + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates a single script draft for a welcome-line helper + - accepts a person's name as input + - returns a message containing Welcome aboard, the provided name, and an exclamation mark + - chooses a reasonable workspace path and script language without needing the user to specify them + - leaves the result as an AI draft only + +- id: global-test9-human-weekday-trial-job + prompt: |- + Can you set up a draft daily job that checks a few hard-coded trial accounts and returns the ones whose trial has ended? + It should run every weekday morning around 9 in UTC with a 30 day cutoff. + Keep it as draft work only. + runtime: + maxTurns: 10 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: script + pathIncludes: + - trial + valueIncludes: + - trial + - "30" + - type: schedule + pathIncludes: + - trial + valueIncludes: + - UTC + toolExpect: + requiredToolsUsed: + - write_script + - write_schedule + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates a script draft that checks hard-coded trial accounts + - returns the accounts whose trial has ended based on a 30 day cutoff + - creates a schedule draft for weekday mornings around 09:00 UTC + - links the schedule to the generated script + - leaves both artifacts as drafts only + +- id: global-test10-human-secret-variable + prompt: |- + I need a placeholder Slack bot token stored securely for future notification work. + Use xoxb-redacted-test-token and note that it is for eval notifications. + Only prepare a draft. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: variable + pathIncludes: + - slack + valueIncludes: + - eval notifications + - "true" + toolExpect: + requiredToolsUsed: + - write_variable + forbiddenToolsUsed: + - write_resource + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - xoxb-redacted-test-token + skipJudge: true + judgeChecklist: + - creates a single secret variable draft for the Slack bot token placeholder + - uses the requested placeholder value + - includes a note or description that it is for eval notifications + - does not create a resource or deploy anything + +- id: global-test11-human-existing-flow-informal-edit + prompt: |- + There is an invoice processing flow in this workspace. + Can you adjust its total calculation so it adds 8% tax and returns subtotal, tax, and total? + Keep the change as a draft. + initial: ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json + runtime: + maxTurns: 10 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + pathIncludes: + - invoice + valueIncludes: + - calculate_total + - tax + - total + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - finds and edits the existing invoice processing flow without the user providing its exact path + - updates the total calculation to apply 8% tax + - returns subtotal, tax, and total from the updated flow logic + - leaves the result as an AI draft only diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 2b42a0dfc5..19c97bce11 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -110,7 +110,9 @@ export interface AppValidationSpec { export interface GlobalDraftRequirement { type: string; - path: string; + path?: string; + pathIncludes?: string[]; + pathStartsWith?: string; triggerKind?: string; language?: string; summaryIncludes?: string[]; diff --git a/ai_evals/core/validators.test.ts b/ai_evals/core/validators.test.ts index d2a6e954bb..6010f6351a 100644 --- a/ai_evals/core/validators.test.ts +++ b/ai_evals/core/validators.test.ts @@ -195,6 +195,69 @@ describe("validateGlobalState", () => { }); }); + it("accepts a required script draft without an exact path", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/team_tools/friendly_greeting", + language: "bun", + summary: "Friendly greeting helper", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + validate: { + draftCountExactly: 1, + requiredDrafts: [ + { + type: "script", + pathIncludes: ["greeting"], + language: "bun", + summaryIncludes: ["Friendly"], + valueIncludes: ["Hello"], + }, + ], + }, + }); + + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("reports flexible global draft path filters when no draft matches", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/team_tools/friendly_greeting", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + validate: { + requiredDrafts: [ + { + type: "script", + pathIncludes: ["invoice"], + }, + ], + }, + }); + + expect(checks).toContainEqual({ + name: "global includes script draft (path includes invoice)", + passed: false, + details: "drafts: script:f/team_tools/friendly_greeting", + }); + }); + it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => { const checks = validateGlobalState({ actual: { diff --git a/ai_evals/core/validators.ts b/ai_evals/core/validators.ts index 4f59368113..693d34a013 100644 --- a/ai_evals/core/validators.ts +++ b/ai_evals/core/validators.ts @@ -315,10 +315,11 @@ export function validateGlobalState(input: { } for (const required of validate.requiredDrafts ?? []) { - const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind); + const requirementLabel = formatGlobalDraftRequirement(required); + const draft = findGlobalDraft(drafts, required); checks.push( check( - `global includes ${required.type} draft ${required.path}`, + `global includes ${requirementLabel}`, Boolean(draft), summarizeGlobalDrafts(drafts) ) @@ -330,7 +331,7 @@ export function validateGlobalState(input: { if (required.language !== undefined) { checks.push( check( - `${required.type} draft ${required.path} uses ${required.language}`, + `${requirementLabel} uses ${required.language}`, draft.language === required.language, `language=${draft.language ?? "(none)"}` ) @@ -340,7 +341,7 @@ export function validateGlobalState(input: { for (const snippet of required.summaryIncludes ?? []) { checks.push( check( - `${required.type} draft ${required.path} summary includes '${snippet}'`, + `${requirementLabel} summary includes '${snippet}'`, normalizeText(draft.summary ?? "").includes(normalizeText(snippet)), `summary=${draft.summary ?? ""}` ) @@ -351,7 +352,7 @@ export function validateGlobalState(input: { for (const snippet of required.valueIncludes ?? []) { checks.push( check( - `${required.type} draft ${required.path} value includes '${snippet}'`, + `${requirementLabel} value includes '${snippet}'`, normalizeText(valueText).includes(normalizeText(snippet)), truncateForDetails(valueText) ) @@ -361,7 +362,7 @@ export function validateGlobalState(input: { for (const snippet of required.valueExcludes ?? []) { checks.push( check( - `${required.type} draft ${required.path} value excludes '${snippet}'`, + `${requirementLabel} value excludes '${snippet}'`, !normalizeText(valueText).includes(normalizeText(snippet)), truncateForDetails(valueText) ) @@ -373,7 +374,7 @@ export function validateGlobalState(input: { checks.push( check( `global does not include ${forbidden.type} draft ${forbidden.path}`, - !findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind), + !findGlobalDraft(drafts, forbidden), summarizeGlobalDrafts(drafts) ) ); @@ -615,16 +616,100 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined { function findGlobalDraft( drafts: GlobalDraft[], - type: string, - path: string, - triggerKind?: string + requirement: { + type: string; + path?: string; + pathIncludes?: string[]; + pathStartsWith?: string; + triggerKind?: string; + summaryIncludes?: string[]; + valueIncludes?: string[]; + valueExcludes?: string[]; + } ): GlobalDraft | undefined { - return drafts.find( - (draft) => - draft.type === type && - draft.path === path && - (triggerKind === undefined || draft.triggerKind === triggerKind) + const candidates = drafts.filter((draft) => + globalDraftMatchesLocator(draft, requirement) ); + return ( + candidates.find((draft) => globalDraftMatchesContent(draft, requirement)) ?? + candidates[0] + ); +} + +function globalDraftMatchesLocator( + draft: GlobalDraft, + requirement: { + type: string; + path?: string; + pathIncludes?: string[]; + pathStartsWith?: string; + triggerKind?: string; + } +): boolean { + return ( + draft.type === requirement.type && + (requirement.path === undefined || draft.path === requirement.path) && + (requirement.pathStartsWith === undefined || + draft.path.startsWith(requirement.pathStartsWith)) && + (requirement.pathIncludes ?? []).every((snippet) => + normalizeText(draft.path).includes(normalizeText(snippet)) + ) && + (requirement.triggerKind === undefined || + draft.triggerKind === requirement.triggerKind) + ); +} + +function globalDraftMatchesContent( + draft: GlobalDraft, + requirement: { + summaryIncludes?: string[]; + valueIncludes?: string[]; + valueExcludes?: string[]; + } +): boolean { + const summary = normalizeText(draft.summary ?? ""); + const value = normalizeText(stringifyGlobalDraftValue(draft.value)); + return ( + (requirement.summaryIncludes ?? []).every((snippet) => + summary.includes(normalizeText(snippet)) + ) && + (requirement.valueIncludes ?? []).every((snippet) => + value.includes(normalizeText(snippet)) + ) && + (requirement.valueExcludes ?? []).every( + (snippet) => !value.includes(normalizeText(snippet)) + ) + ); +} + +function formatGlobalDraftRequirement( + requirement: { + type: string; + path?: string; + pathIncludes?: string[]; + pathStartsWith?: string; + triggerKind?: string; + } +): string { + const typeLabel = + requirement.triggerKind === undefined + ? requirement.type + : `${requirement.triggerKind} ${requirement.type}`; + if (requirement.path !== undefined) { + return `${typeLabel} draft ${requirement.path}`; + } + + const filters = [ + ...(requirement.pathStartsWith === undefined + ? [] + : [`path starts with ${requirement.pathStartsWith}`]), + ...(requirement.pathIncludes ?? []).map( + (snippet) => `path includes ${snippet}` + ), + ]; + return filters.length === 0 + ? `${typeLabel} draft` + : `${typeLabel} draft (${filters.join(", ")})`; } function summarizeGlobalDrafts(drafts: GlobalDraft[]): string { diff --git a/ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json b/ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json new file mode 100644 index 0000000000..b9b4c675c5 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json @@ -0,0 +1,40 @@ +{ + "workspace": { + "flows": [ + { + "path": "f/evals/global/process_invoice", + "summary": "Process an invoice subtotal", + "description": "Calculates invoice totals from a subtotal.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate total from subtotal", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + } + } + ] + } +} From f947b1dfdfcce6c23b389a2a33614623e063cfd8 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Wed, 27 May 2026 15:49:02 +0200 Subject: [PATCH 03/52] fix (frontend): schedule "View runs" url (#9350) --- .../triggers/schedules/ScheduleEditorInner.svelte | 2 +- .../src/routes/(root)/(logged)/schedules/+page.svelte | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte index b7652da98f..fb5f998aad 100644 --- a/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte +++ b/frontend/src/lib/components/triggers/schedules/ScheduleEditorInner.svelte @@ -696,7 +696,7 @@ variant="default" startIcon={{ icon: List }} disabled={!allowSchedule || pathError != '' || emptyString(script_path)} - href={`${base}/runs/${script_path}?show_schedules=true&show_future_jobs=true`} + href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`} > View runs diff --git a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte index b9731394a0..2a88266c4e 100644 --- a/frontend/src/routes/(root)/(logged)/schedules/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/schedules/+page.svelte @@ -462,7 +462,7 @@ {/key}
- + {#snippet children({ createMenu })} - +
+ - {#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)} - {#if menuLink.subItems} - {@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)} - - {#snippet triggr({ trigger })} - - {/snippet} + {#each secondaryMenuLinks as menuLink (menuLink.href ?? menuLink.label)} + {#if menuLink.subItems} + {@const notificationsCount = computeAllNotificationsCount(menuLink.subItems)} + + {#snippet triggr({ trigger })} + + {/snippet} - {#snippet children({ item })} - {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} - { - subItem?.['action']?.() - }} - aiId={subItem.aiId} - aiDescription={subItem.aiDescription} - > -
- {#if subItem.icon} - - {/if} - {subItem.label} - {#if subItem?.['notificationCount']} -
- -
- {/if} -
-
- {/each} - {/snippet} -
- {:else} - - {#snippet children({})} - - {/snippet} - - {/if} - {/each} - {/snippet} - - - - {#snippet children({ createMenu })} - {#each thirdMenuLinks as menuLink (menuLink)} - {#if menuLink.subItems} - - {#snippet triggr({ trigger })} - - {/snippet} - {#snippet children({ item })} - {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} - -
- {#if subItem.icon} - - {/if} - - {subItem.label} -
-
- {/each} - {#if recentChangelogs.length > 0} -
- Latest changelogs - {#each recentChangelogs as changelog} -
- {changelog.label} + {#if subItem.icon} + + {/if} + {subItem.label} + {#if subItem?.['notificationCount']} +
+ +
+ {/if}
{/each} - {/if} - {/snippet} -
- {/if} - {/each} + {/snippet} +
+ {:else} + + {#snippet children({})} + + {/snippet} + + {/if} + {/each} +
+ +
+ {#each thirdMenuLinks as menuLink (menuLink)} + {#if menuLink.subItems} + + {#snippet triggr({ trigger })} + + {/snippet} + {#snippet children({ item })} + {#each menuLink.subItems as subItem (subItem.href ?? subItem.label)} + +
+ {#if subItem.icon} + + {/if} + + {subItem.label} +
+
+ {/each} + {#if recentChangelogs.length > 0} +
+ Latest changelogs + {#each recentChangelogs as changelog} + +
+ {changelog.label} +
+
+ {/each} + {/if} + {/snippet} +
+ {/if} + {/each} +
{/snippet}
From 4efc37212a98571214aba135b0fbb10dc263fd4f Mon Sep 17 00:00:00 2001 From: Guilhem Date: Wed, 27 May 2026 23:16:21 +0200 Subject: [PATCH 06/52] fix: infer script arg schema when deploying via AI chat (#9356) Co-authored-by: Claude Opus 4.7 (1M context) --- .../lib/components/copilot/chat/global/core.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index dab974fa2f..693ba3072f 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -64,6 +64,7 @@ import { import type { ContextElement } from '../context' import { UserDraft, type UserDraftMeta } from '$lib/userDraft.svelte' import { emptySchema } from '$lib/utils' +import { inferArgs } from '$lib/infer' import { resourceRequestSchema, scheduleRequestSchema, @@ -2746,10 +2747,16 @@ async function deployDraft( const existing = (await ScriptService.existsScriptByPath({ workspace, path })) ? await ScriptService.getScriptByPath({ workspace, path }) : undefined - await ScriptService.createScript({ - workspace, - requestBody: buildScriptDeployRequestBody(path, draft, existing, deploymentMessage) - }) + const requestBody = buildScriptDeployRequestBody(path, draft, existing, deploymentMessage) + // Infer the arg schema from the content so it matches the code, like the editor does. + try { + const schema = emptySchema() + await inferArgs(requestBody.language, requestBody.content, schema) + requestBody.schema = schema + } catch (e) { + console.error('Failed to infer script schema before deploy', e) + } + await ScriptService.createScript({ workspace, requestBody }) break } case 'flow': { From c2b5ba8871abbbcff6de69c90e2f09fee70586c1 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Thu, 28 May 2026 12:31:25 +0200 Subject: [PATCH 07/52] fix(cli): stop re-prompting on wmill refresh prompts (#9357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit referencesIncludeLine required the include token to be the entire trimmed line. The wmill-default CLAUDE.md template is `Instructions are in @AGENTS.md` — include mid-sentence — so the migration prompt fired every run on files wmill itself wrote. Accept the include as a whitespace-separated token on any non-comment line. Co-authored-by: Claude Opus 4.7 (1M context) --- cli/src/guidance/writer.ts | 18 ++++++++++++------ cli/test/guidance_writer_unit.test.ts | 8 +++++++- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/cli/src/guidance/writer.ts b/cli/src/guidance/writer.ts index fcaae0671a..033519bf8e 100644 --- a/cli/src/guidance/writer.ts +++ b/cli/src/guidance/writer.ts @@ -207,13 +207,19 @@ async function reconcileIncludingFile(options: { } function referencesIncludeLine(content: string, includeLine: string): boolean { - // Match only when the include sits on a line by itself (allowing leading - // and trailing whitespace). Earlier we split on `\s+`, but that - // false-positives on commented-out includes like `` - // where the middle token equals the include. CRLF is handled by the - // `\r?\n` split. + // Match when the include appears as a whitespace-separated token on any + // line that isn't an HTML comment. We can't require the include to be on a + // line by itself: our own CLAUDE.md default is `Instructions are in + // @AGENTS.md` (one sentence), and a strict equality check made `wmill + // refresh prompts` re-prompt every run on files wmill itself wrote. + // Skipping comment-bearing lines keeps `` from + // false-positiving. for (const line of content.split(/\r?\n/)) { - if (line.trim() === includeLine) { + const trimmed = line.trim(); + if (trimmed.startsWith("")) { + continue; + } + if (trimmed.split(/\s+/).includes(includeLine)) { return true; } } diff --git a/cli/test/guidance_writer_unit.test.ts b/cli/test/guidance_writer_unit.test.ts index fa3a91d641..cbeb494d1c 100644 --- a/cli/test/guidance_writer_unit.test.ts +++ b/cli/test/guidance_writer_unit.test.ts @@ -391,6 +391,13 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () ["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"], ["leading whitespace then include", " @AGENTS.cli.md\n"], ["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"], + // Mid-sentence include: this is how our own CLAUDE.md default looks + // ("Instructions are in @AGENTS.md"). A strict line-equality check made + // `wmill refresh prompts` re-prompt every run on files wmill wrote. + ["mid-sentence include", "Instructions are in @AGENTS.cli.md\n"], + // `>` blockquote prefix doesn't disable Claude's `@`-import expansion, + // so we treat it as a reference too. + ["blockquoted include", "> @AGENTS.cli.md"], ])("treats %s as a reference (no append)", async (_label, content) => { await withTempDir(async (tempDir) => { await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); @@ -406,7 +413,6 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () ["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"], ["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"], ["commented-out include", ""], - ["blockquoted include", "> @AGENTS.cli.md"], ])("does not treat %s as a reference (append happens)", async (_label, content) => { await withTempDir(async (tempDir) => { await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); From a9e514099585e5ee72df21bd551a223cceb20fb0 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Thu, 28 May 2026 15:57:22 +0200 Subject: [PATCH 08/52] feat: warn when custom instance db is shared across workspaces (#9359) * feat: warn when custom instance db is shared across workspaces * Fix leaking workspace names * sqlx prepare --- ...1f7f387f5055c47f493271d26731336257384.json | 10 +-- ...0a168cc1191acaab96bed6a016c437059c2cd.json | 26 +++++++ backend/windmill-api-settings/src/lib.rs | 70 ++++++++++++++++--- backend/windmill-api/openapi.yaml | 5 ++ .../CustomInstanceDbSelect.svelte | 39 +++++++++-- 5 files changed, 132 insertions(+), 18 deletions(-) create mode 100644 backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index d29a18c691..e7ed0aee65 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - true, - true, - true, - true, - true, + false, + false, + false, + false, + false, true, true ] diff --git a/backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json b/backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json new file mode 100644 index 0000000000..28a725277d --- /dev/null +++ b/backend/.sqlx/query-815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ws.workspace_id AS \"workspace_id!\", entry->'catalog'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'\n THEN ws.ducklake->'ducklakes'\n ELSE '{}'::jsonb END\n ) AS dl(k, entry)\n WHERE entry->'catalog'->>'resource_type' = 'instance'\n AND entry->'catalog'->>'resource_path' IS NOT NULL\n UNION ALL\n SELECT ws.workspace_id AS \"workspace_id!\", entry->'database'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'\n THEN ws.datatable->'datatables'\n ELSE '{}'::jsonb END\n ) AS dt(k, entry)\n WHERE entry->'database'->>'resource_type' = 'instance'\n AND entry->'database'->>'resource_path' IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "dbname", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null + ] + }, + "hash": "815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd" +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index af7c42ee4b..9fd1091d66 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -6,7 +6,10 @@ * LICENSE-AGPL for a copy of the license. */ -use std::{collections::HashMap, time::Duration}; +use std::{ + collections::{BTreeSet, HashMap}, + time::Duration, +}; #[cfg(feature = "parquet")] mod audit_logs_s3; @@ -47,6 +50,7 @@ use windmill_common::secret_backend::{ AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, }; use windmill_common::{ + auth::is_super_admin_email, ee_oss::{get_license_plan, LicensePlan}, email_oss::send_email_plain_text, error::{self, JsonResult, Result}, @@ -1135,6 +1139,8 @@ struct CustomInstanceDb { success: bool, error: Option, tag: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + used_by_workspaces: Vec, } #[derive(Deserialize, Debug, Serialize, Default)] @@ -1154,7 +1160,7 @@ struct CustomInstanceDbLogs { } async fn list_custom_instance_pg_databases( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { let result = sqlx::query_scalar!( @@ -1163,12 +1169,57 @@ async fn list_custom_instance_pg_databases( .fetch_one(&db) .await? .ok_or_else(|| error::Error::ExecutionErr("Couldn't find custom_instance_pg_databases".to_string()))?; - let result = serde_json::from_value(result).map_err(|e| { - error::Error::ExecutionErr(format!( - "couldn't parse custom_instance_pg_databases.databases : {}", - e.to_string() - )) - })?; + let mut result: HashMap = + serde_json::from_value(result).map_err(|e| { + error::Error::ExecutionErr(format!( + "couldn't parse custom_instance_pg_databases.databases : {}", + e.to_string() + )) + })?; + + if is_super_admin_email(&db, &authed.email).await? { + // Enrich each database with the list of workspaces referencing it through + // either a ducklake catalog or a datatable database whose resource_type is + // 'instance'. Not stored in DB to avoid drift. + let usages = sqlx::query!( + r#" + SELECT ws.workspace_id AS "workspace_id!", entry->'catalog'->>'resource_path' AS dbname + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object' + THEN ws.ducklake->'ducklakes' + ELSE '{}'::jsonb END + ) AS dl(k, entry) + WHERE entry->'catalog'->>'resource_type' = 'instance' + AND entry->'catalog'->>'resource_path' IS NOT NULL + UNION ALL + SELECT ws.workspace_id AS "workspace_id!", entry->'database'->>'resource_path' AS dbname + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' + ELSE '{}'::jsonb END + ) AS dt(k, entry) + WHERE entry->'database'->>'resource_type' = 'instance' + AND entry->'database'->>'resource_path' IS NOT NULL + "#, + ) + .fetch_all(&db) + .await?; + + let mut by_db: HashMap> = HashMap::new(); + for row in usages { + if let Some(dbname) = row.dbname { + by_db.entry(dbname).or_default().insert(row.workspace_id); + } + } + for (dbname, entry) in result.iter_mut() { + if let Some(workspaces) = by_db.remove(dbname) { + entry.used_by_workspaces = workspaces.into_iter().collect(); + } + } + } + return Ok(Json(result)); } @@ -1196,7 +1247,8 @@ async fn setup_custom_instance_pg_database( let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await; let success = result.is_ok(); let error = result.err().map(|e| e.to_string()); - let status = CustomInstanceDb { logs, success, error, tag: body.tag }; + let status = + CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] }; let status_json = serde_json::to_value(&status).map_err(to_anyhow)?; // Save that the database was setup successfully sqlx::query!( diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 0d21853205..4576f655ea 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -25405,6 +25405,11 @@ components: example: "Connection timeout" tag: $ref: "#/components/schemas/CustomInstanceDbTag" + used_by_workspaces: + type: array + items: + type: string + description: Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted. NewSqsTrigger: type: object diff --git a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte index 9fccaa8b35..222e5606c8 100644 --- a/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte +++ b/frontend/src/lib/components/workspaceSettings/CustomInstanceDbSelect.svelte @@ -9,6 +9,8 @@ import { ArrowRight, TriangleAlert } from 'lucide-svelte' import type { ConfirmationModalHandle } from '../common/confirmationModal/asyncConfirmationModal.svelte' import type { Snippet } from 'svelte' + import Tooltip from '../meltComponents/Tooltip.svelte' + import { workspaceStore } from '$lib/stores' type Props = { value: string | undefined @@ -37,6 +39,11 @@ ) ) let open = $state(false) + + function otherWorkspaces(dbname: string): string[] { + const all = customInstanceDbs.current?.[dbname]?.used_by_workspaces ?? [] + return all.filter((w) => w !== $workspaceStore) + }
@@ -52,11 +59,17 @@ disabled={!$isCustomInstanceDbEnabled} > {#snippet endSnippet({ item })} - {@render customInstanceDbWizardButton(item.value)} +
+ {@render sharedWorkspacesWarning(item.value)} + {@render customInstanceDbWizardButton(item.value)} +
{/snippet} {#if value} - {@render customInstanceDbWizardButton(value, 'absolute right-1.5')} +
+ {@render sharedWorkspacesWarning(value)} + {@render customInstanceDbWizardButton(value)} +
{/if}
@@ -74,12 +87,12 @@ } /> -{#snippet customInstanceDbWizardButton(dbname: string, clazz: string = '')} +{#snippet customInstanceDbWizardButton(dbname: string)} {@const status = customInstanceDbs.current?.[dbname]} {/snippet} + +{#snippet sharedWorkspacesWarning(dbname: string)} + {@const others = otherWorkspaces(dbname)} + {#if others.length > 0} + + + {#snippet text()} + This database is also used by workspace{others.length > 1 ? 's' : ''} + {others.join(', ')}. Any data written here will be shared + with {others.length > 1 ? 'them' : 'it'}. + {/snippet} + + {/if} +{/snippet} From 9e7eaf36847ad3a004ec84e8b7d4784771b7b451 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 28 May 2026 15:57:49 +0200 Subject: [PATCH 09/52] feat: inject active editor into global chat (#9361) --- ai_evals/README.md | 19 +- .../frontend/core/global/globalEvalRunner.ts | 57 ++++- ai_evals/cases/global.yaml | 89 +++++++ ai_evals/cli/index.ts | 10 +- ai_evals/core/cases.test.ts | 28 ++ ai_evals/core/results.test.ts | 242 ++++++++++++++++++ ai_evals/core/results.ts | 181 ++++++++----- ai_evals/core/types.ts | 3 + .../initial/current_greeting_live_script.json | 66 +++++ .../initial/current_invoice_live_flow.json | 118 +++++++++ ai_evals/modes/global.ts | 8 +- .../copilot/chat/AIChatManager.svelte.ts | 10 +- .../copilot/chat/global/core.test.ts | 22 ++ .../components/copilot/chat/global/core.ts | 47 +++- 14 files changed, 821 insertions(+), 79 deletions(-) create mode 100644 ai_evals/core/results.test.ts create mode 100644 ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json create mode 100644 ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json diff --git a/ai_evals/README.md b/ai_evals/README.md index 6982d70da9..4804ad2543 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -142,6 +142,15 @@ For `global` mode, `validate` can express draft-level requirements such as: - required or forbidden draft counts - forbidden draft paths +Global initial fixtures can also seed `liveEditorDrafts` with `type`, +`storagePath`, `effectivePath`, and `value` fields. These drafts emulate the +currently open script, flow, or raw app editor so cases can test prompts that +refer to "this" or the "current" item. + +Set `WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT=1` to run those cases with +the old behavior where the live editor is only discoverable through +`list_workspace_items`. + App fixtures can also include an optional `datatables.json` file at the fixture root. For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of @@ -189,11 +198,15 @@ If `--record` is used, the CLI also appends one compact JSON line to: Each recorded line contains: - run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`) -- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`) -- average token usage (`averageTokenUsagePerAttempt`) -- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate) +- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`) +- average token usage (`averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`) +- per-case metrics under `cases[]` (`averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`, pass rate) - `failedCaseIds` +The CLI headline duration and token averages use passed attempts only. +All-attempt averages are still recorded to make failures auditable without +letting failed attempts skew success cost comparisons. + Example: - summary: `ai_evals/results/2026-04-09T09-40-33.051Z__flow.json` diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts index 8541348c51..058adc3644 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -12,6 +12,7 @@ import { listGlobalDrafts, } from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter"; import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; +import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte"; import type { ModeRunContext } from "../../../../core/types"; import type { GlobalDraftState } from "../../../../core/validators"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; @@ -27,6 +28,21 @@ const MUTATING_GLOBAL_TOOLS = new Set([ "deploy_workspace_item", "delete_workspace_item", ]); +const DISABLE_ACTIVE_EDITOR_CONTEXT_ENV = + "WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT"; + +const LIVE_EDITOR_ITEM_KINDS = { + script: "script", + flow: "flow", + app: "raw_app", +} as const; + +export interface GlobalLiveEditorDraftFixture { + type: keyof typeof LIVE_EDITOR_ITEM_KINDS; + storagePath?: string; + effectivePath?: string; + value?: unknown; +} export interface GlobalEvalResult { success: boolean; @@ -41,6 +57,7 @@ export interface GlobalEvalResult { export interface GlobalEvalOptions { workspaceFixtures?: BenchmarkWorkspaceRunnables; + liveEditorDrafts?: GlobalLiveEditorDraftFixture[]; model?: string; maxIterations?: number; provider?: AIProvider; @@ -60,13 +77,20 @@ export async function runGlobalEval( clearGlobalDrafts(workspaceRoot); registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {}); + seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); try { const model = options.model ?? "claude-haiku-4-5-20251001"; + const injectActiveEditorContext = + process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1"; const rawResult = await runEval({ userPrompt, systemMessage: prepareGlobalSystemMessage(), - userMessage: prepareGlobalUserMessage(userPrompt), + userMessage: prepareGlobalUserMessage( + userPrompt, + [], + injectActiveEditorContext ? { workspace: workspaceRoot } : {}, + ), tools: getGlobalEvalTools(), helpers: {}, apiKey, @@ -98,6 +122,7 @@ export async function runGlobalEval( }; } finally { clearGlobalDrafts(workspaceRoot); + clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); unregisterBenchmarkWorkspaceRunnables(workspaceRoot); if (!options.workspaceRoot) { await rm(workspaceRoot, { recursive: true, force: true }); @@ -105,6 +130,36 @@ export async function runGlobalEval( } } +function seedLiveEditorDrafts( + workspace: string, + fixtures: GlobalLiveEditorDraftFixture[], +): void { + for (const fixture of fixtures) { + const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type]; + const storagePath = fixture.storagePath ?? fixture.effectivePath ?? ""; + if (fixture.value !== undefined) { + UserDraft.save(itemKind, storagePath, fixture.value, { workspace }); + } + UserDraft.setLiveEditorDraft({ + workspace, + itemKind, + storagePath, + effectivePath: fixture.effectivePath ?? fixture.storagePath, + }); + } +} + +function clearLiveEditorDrafts( + workspace: string, + fixtures: GlobalLiveEditorDraftFixture[], +): void { + for (const fixture of fixtures) { + const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type]; + const storagePath = fixture.storagePath ?? fixture.effectivePath ?? ""; + UserDraft.clearLiveEditorDraft(itemKind, { workspace, storagePath }); + } +} + function getGlobalEvalTools(): ProductionTool<{}>[] { return (globalTools as ProductionTool<{}>[]).map((tool) => { if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) { diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 373408a23d..36238e8ccd 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -341,3 +341,92 @@ - updates the total calculation to apply 8% tax - returns subtotal, tax, and total from the updated flow logic - leaves the result as an AI draft only + +- id: global-test12-current-live-script-edit + prompt: |- + The script I have open formats greetings. + Can you update this script so it uppercases the name before greeting them and ends with an exclamation mark? + Keep it as draft work. + initial: ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/current_greeting + language: bun + valueIncludes: + - toUpperCase + - "!" + forbiddenDrafts: + - type: script + path: f/evals/global/format_greeting + - type: script + path: f/evals/global/format_greeting_archive + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - resolves "this script" to the active live editor script instead of another similarly named workspace script + - updates the greeting logic to uppercase the provided name + - returns a greeting ending with an exclamation mark + - leaves the result as a draft only + +- id: global-test13-current-live-flow-edit + prompt: |- + I have the invoice flow open. + In the current flow, update the total calculation to add 8% tax and return subtotal, tax, and total. + Keep the change as a draft. + initial: ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json + runtime: + maxTurns: 10 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + path: f/evals/global/current_invoice_flow + valueIncludes: + - calculate_total + - tax + - total + forbiddenDrafts: + - type: flow + path: f/evals/global/process_invoice + - type: flow + path: f/evals/global/process_refund + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - resolves "current flow" to the active live editor flow + - does not edit the similarly named deployed invoice or refund flows + - updates the calculate_total logic to apply 8% tax + - returns subtotal, tax, and total from the updated flow logic + - leaves the result as a draft only + +- id: global-test14-current-without-live-editor-asks-question + prompt: |- + Please update this script so it returns `ok`. + Keep it as a draft. + runtime: + maxTurns: 4 + validate: + draftCountExactly: 0 + toolExpect: + forbiddenToolsUsed: + - write_script + - edit_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - asks which script to update when the user refers to "this script" without selected or active editor context + - does not guess a path or create a new script draft diff --git a/ai_evals/cli/index.ts b/ai_evals/cli/index.ts index 8ed61740c8..f92d6d7027 100644 --- a/ai_evals/cli/index.ts +++ b/ai_evals/cli/index.ts @@ -211,7 +211,7 @@ async function handleRun(input: { const summaries: Array<{ label: string; passRate: number; - averageDurationMs: number; + averagePassedDurationMs: number | null; }> = []; for (const [index, model] of models.entries()) { @@ -259,7 +259,7 @@ async function handleRun(input: { summaries.push({ label: `${model.id} (${runModel})`, passRate: result.passRate, - averageDurationMs: result.averageDurationMs, + averagePassedDurationMs: result.averagePassedDurationMs ?? null, }); } @@ -267,7 +267,7 @@ async function handleRun(input: { process.stdout.write("\nModel summary\n"); for (const summary of summaries) { process.stdout.write( - `- ${summary.label}: ${formatPercent(summary.passRate)} | ${Math.round(summary.averageDurationMs)}ms\n`, + `- ${summary.label}: ${formatPercent(summary.passRate)} | passed avg ${formatNullableDuration(summary.averagePassedDurationMs)}\n`, ); } } @@ -351,6 +351,10 @@ function formatPercent(value: number): string { return `${(value * 100).toFixed(1)}%`; } +function formatNullableDuration(value: number | null): string { + return value === null ? "n/a" : `${Math.round(value)}ms`; +} + void main().catch((error) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); diff --git a/ai_evals/core/cases.test.ts b/ai_evals/core/cases.test.ts index 733d34ddd2..526ea7c70f 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -203,6 +203,34 @@ describe("loadCases", () => { }); }); + it("loads global active-editor eval cases", async () => { + const globalCases = await loadCases("global"); + const scriptCase = globalCases.find( + (entry) => entry.id === "global-test12-current-live-script-edit" + ); + const flowCase = globalCases.find( + (entry) => entry.id === "global-test13-current-live-flow-edit" + ); + + expect(scriptCase?.initialPath).toContain( + "ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json" + ); + expect(scriptCase?.toolExpect).toMatchObject({ + requiredToolsUsed: ["read_workspace_item"], + }); + expect(flowCase?.initialPath).toContain( + "ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json" + ); + expect(flowCase?.validate).toMatchObject({ + requiredDrafts: [ + { + type: "flow", + path: "f/evals/global/current_invoice_flow", + }, + ], + }); + }); + it("loads tool expectations for workspace mutation cases", async () => { const scriptCases = await loadCases("script"); const caseEntry = scriptCases.find( diff --git a/ai_evals/core/results.test.ts b/ai_evals/core/results.test.ts new file mode 100644 index 0000000000..2d6077c5bd --- /dev/null +++ b/ai_evals/core/results.test.ts @@ -0,0 +1,242 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "bun:test"; +import { + appendHistoryRecord, + buildRunResult, + formatRunSummary, +} from "./results"; +import type { BenchmarkCaseResult } from "./types"; + +function caseResult( + attempts: BenchmarkCaseResult["attempts"], +): BenchmarkCaseResult { + return { + id: "case-1", + prompt: "Do the thing", + attempts, + }; +} + +describe("benchmark results", () => { + it("keeps success cost metrics separate from failed attempts", () => { + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: true, + durationMs: 1000, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: { prompt: 100, completion: 20, total: 120 }, + }, + { + attempt: 2, + passed: false, + durationMs: 100, + assistantMessageCount: 1, + toolCallCount: 0, + toolsUsed: [], + skillsInvoked: [], + checks: [{ name: "edited", passed: false }], + judgeScore: 10, + judgeSummary: "missed", + error: "failed", + tokenUsage: { prompt: 10, completion: 5, total: 15 }, + }, + ]), + ], + }); + + expect(result.attemptCount).toBe(2); + expect(result.passedAttempts).toBe(1); + expect(result.passRate).toBe(0.5); + expect(result.averageDurationMs).toBe(550); + expect(result.averagePassedDurationMs).toBe(1000); + expect(result.totalTokenUsage).toEqual({ + prompt: 110, + completion: 25, + total: 135, + }); + expect(result.totalPassedTokenUsage).toEqual({ + prompt: 100, + completion: 20, + total: 120, + }); + expect(result.averageTokenUsagePerAttempt).toEqual({ + prompt: 55, + completion: 12.5, + total: 67.5, + }); + expect(result.averageTokenUsagePerPassedAttempt).toEqual({ + prompt: 100, + completion: 20, + total: 120, + }); + + const summary = formatRunSummary(result); + expect(summary).toContain("Average duration (passed): 1000ms"); + expect(summary).toContain("Average tokens (passed): 120 total"); + expect(summary).toContain("Average duration (all attempts): 550ms"); + }); + + it("reports passed averages as unavailable when no attempt passes", () => { + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: false, + durationMs: 100, + assistantMessageCount: 1, + toolCallCount: 0, + toolsUsed: [], + skillsInvoked: [], + checks: [{ name: "edited", passed: false }], + judgeScore: 10, + judgeSummary: "missed", + error: "failed", + tokenUsage: { prompt: 10, completion: 5, total: 15 }, + }, + ]), + ], + }); + + expect(result.averagePassedDurationMs).toBeNull(); + expect(result.totalPassedTokenUsage).toBeNull(); + expect(result.averageTokenUsagePerPassedAttempt).toBeNull(); + expect(formatRunSummary(result)).toContain( + "Average duration (passed): n/a", + ); + }); + + it("normalizes passed token averages by passed attempts", () => { + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: true, + durationMs: 1000, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: { prompt: 100, completion: 20, total: 120 }, + }, + { + attempt: 2, + passed: true, + durationMs: 1200, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: null, + }, + ]), + ], + }); + + expect(result.passedAttempts).toBe(2); + expect(result.totalPassedTokenUsage).toEqual({ + prompt: 100, + completion: 20, + total: 120, + }); + expect(result.averageTokenUsagePerPassedAttempt).toEqual({ + prompt: 50, + completion: 10, + total: 60, + }); + }); + + it("records passed-attempt metrics in history", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "windmill-ai-evals-")); + try { + const historyPath = join(tempDir, "history.jsonl"); + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: true, + durationMs: 1000, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: { prompt: 100, completion: 20, total: 120 }, + }, + { + attempt: 2, + passed: false, + durationMs: 100, + assistantMessageCount: 1, + toolCallCount: 0, + toolsUsed: [], + skillsInvoked: [], + checks: [{ name: "edited", passed: false }], + judgeScore: 10, + judgeSummary: "missed", + error: "failed", + tokenUsage: { prompt: 10, completion: 5, total: 15 }, + }, + ]), + ], + }); + + await appendHistoryRecord(result, historyPath); + const record = JSON.parse(await readFile(historyPath, "utf8")); + + expect(record.averageDurationMs).toBe(550); + expect(record.averagePassedDurationMs).toBe(1000); + expect(record.averageTokenUsagePerAttempt.total).toBe(67.5); + expect(record.averageTokenUsagePerPassedAttempt.total).toBe(120); + expect(record.cases[0].averageDurationMs).toBe(550); + expect(record.cases[0].averagePassedDurationMs).toBe(1000); + expect(record.cases[0].averageTokenUsagePerAttempt.total).toBe(67.5); + expect(record.cases[0].averageTokenUsagePerPassedAttempt.total).toBe( + 120, + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/ai_evals/core/results.ts b/ai_evals/core/results.ts index e58840f911..0b84497165 100644 --- a/ai_evals/core/results.ts +++ b/ai_evals/core/results.ts @@ -4,12 +4,20 @@ import { execFileSync } from "node:child_process"; import { getAiEvalsRoot, getRepoRoot } from "./cases"; import type { BenchmarkArtifactFile, + BenchmarkAttemptResult, BenchmarkCaseResult, BenchmarkRunResult, BenchmarkTokenUsage, EvalMode, } from "./types"; +type AttemptAggregate = { + attemptCount: number; + durationTotal: number; + tokenUsageAttemptCount: number; + tokenUsageTotal: BenchmarkTokenUsage | null; +}; + export async function writeRunResult( result: BenchmarkRunResult, outputPath?: string, @@ -77,36 +85,12 @@ export function buildRunResult(input: { judgeModel: string | null; caseResults: BenchmarkCaseResult[]; }): BenchmarkRunResult { - const attemptCount = input.caseResults.reduce( - (sum, entry) => sum + entry.attempts.length, - 0, - ); - const passedAttempts = input.caseResults.reduce( - (sum, entry) => - sum + entry.attempts.filter((attempt) => attempt.passed).length, - 0, - ); - const durationTotal = input.caseResults.reduce( - (sum, entry) => - sum + - entry.attempts.reduce((inner, attempt) => inner + attempt.durationMs, 0), - 0, - ); - const tokenUsageTotal = input.caseResults.reduce( - (sum, entry) => { - for (const attempt of entry.attempts) { - if (!attempt.tokenUsage) { - continue; - } - sum ??= { prompt: 0, completion: 0, total: 0 }; - sum.prompt += attempt.tokenUsage.prompt; - sum.completion += attempt.tokenUsage.completion; - sum.total += attempt.tokenUsage.total; - } - return sum; - }, - null, - ); + const attempts = input.caseResults.flatMap((entry) => entry.attempts); + const passedAttemptResults = attempts.filter((attempt) => attempt.passed); + const attemptAggregate = aggregateAttempts(attempts); + const passedAttemptAggregate = aggregateAttempts(passedAttemptResults); + const attemptCount = attemptAggregate.attemptCount; + const passedAttempts = passedAttemptAggregate.attemptCount; return { version: 1, @@ -120,16 +104,19 @@ export function buildRunResult(input: { attemptCount, passedAttempts, passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount, - averageDurationMs: attemptCount === 0 ? 0 : durationTotal / attemptCount, - totalTokenUsage: tokenUsageTotal, + averageDurationMs: + attemptCount === 0 ? 0 : attemptAggregate.durationTotal / attemptCount, + averagePassedDurationMs: averageDuration(passedAttemptAggregate), + totalTokenUsage: attemptAggregate.tokenUsageTotal, + totalPassedTokenUsage: passedAttemptAggregate.tokenUsageTotal, averageTokenUsagePerAttempt: - attemptCount === 0 || !tokenUsageTotal + attemptCount === 0 ? null - : { - prompt: tokenUsageTotal.prompt / attemptCount, - completion: tokenUsageTotal.completion / attemptCount, - total: tokenUsageTotal.total / attemptCount, - }, + : averageTokenUsage(attemptAggregate, attemptCount), + averageTokenUsagePerPassedAttempt: averageTokenUsage( + passedAttemptAggregate, + passedAttempts, + ), cases: input.caseResults, }; } @@ -138,9 +125,25 @@ export function formatRunSummary(result: BenchmarkRunResult): string { const lines = [ `${result.mode} benchmark complete`, `Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`, - `Average duration: ${Math.round(result.averageDurationMs)}ms`, + `Average duration (passed): ${formatNullableDuration(result.averagePassedDurationMs ?? null)}`, ]; + if (result.averageTokenUsagePerPassedAttempt) { + lines.push( + `Average tokens (passed): ${formatTokenUsage(result.averageTokenUsagePerPassedAttempt)}`, + ); + } + if (result.passedAttempts < result.attemptCount) { + lines.push( + `Average duration (all attempts): ${Math.round(result.averageDurationMs)}ms`, + ); + if (result.averageTokenUsagePerAttempt) { + lines.push( + `Average tokens (all attempts): ${formatTokenUsage(result.averageTokenUsagePerAttempt)}`, + ); + } + } + const failures = collectFailures(result); if (failures.length > 0) { lines.push("Failures:"); @@ -172,6 +175,60 @@ function collectFailures(result: BenchmarkRunResult): string[] { return failures; } +function aggregateAttempts(attempts: BenchmarkAttemptResult[]): AttemptAggregate { + const aggregate: AttemptAggregate = { + attemptCount: attempts.length, + durationTotal: 0, + tokenUsageAttemptCount: 0, + tokenUsageTotal: null, + }; + + for (const attempt of attempts) { + aggregate.durationTotal += attempt.durationMs; + if (!attempt.tokenUsage) { + continue; + } + aggregate.tokenUsageAttemptCount += 1; + aggregate.tokenUsageTotal ??= { prompt: 0, completion: 0, total: 0 }; + aggregate.tokenUsageTotal.prompt += attempt.tokenUsage.prompt; + aggregate.tokenUsageTotal.completion += attempt.tokenUsage.completion; + aggregate.tokenUsageTotal.total += attempt.tokenUsage.total; + } + + return aggregate; +} + +function averageDuration(aggregate: AttemptAggregate): number | null { + return aggregate.attemptCount === 0 + ? null + : aggregate.durationTotal / aggregate.attemptCount; +} + +function averageTokenUsage( + aggregate: AttemptAggregate, + denominator: number, +): BenchmarkTokenUsage | null { + if (denominator === 0 || !aggregate.tokenUsageTotal) { + return null; + } + return { + prompt: aggregate.tokenUsageTotal.prompt / denominator, + completion: aggregate.tokenUsageTotal.completion / denominator, + total: aggregate.tokenUsageTotal.total / denominator, + }; +} + +function formatNullableDuration(value: number | null): string { + return value === null ? "n/a" : `${Math.round(value)}ms`; +} + +function formatTokenUsage(value: BenchmarkTokenUsage): string { + const total = Math.round(value.total); + const prompt = Math.round(value.prompt); + const completion = Math.round(value.completion); + return `${total} total (${prompt} prompt, ${completion} completion)`; +} + function defaultFileName(mode: EvalMode): string { return `${new Date().toISOString().replaceAll(":", "-")}__${mode}.json`; } @@ -252,12 +309,15 @@ function toHistoryRecord(result: BenchmarkRunResult) { passedAttempts: result.passedAttempts, passRate: result.passRate, averageDurationMs: result.averageDurationMs, + averagePassedDurationMs: result.averagePassedDurationMs ?? null, averageJudgeScore: judgeScores.length === 0 ? null : judgeScores.reduce((sum, score) => sum + score, 0) / judgeScores.length, averageTokenUsagePerAttempt: result.averageTokenUsagePerAttempt ?? null, + averageTokenUsagePerPassedAttempt: + result.averageTokenUsagePerPassedAttempt ?? null, failedCaseIds: Array.from( new Set( result.cases @@ -268,31 +328,15 @@ function toHistoryRecord(result: BenchmarkRunResult) { ), ), cases: result.cases.map((caseResult) => { - const attemptCount = caseResult.attempts.length; - const passedAttempts = caseResult.attempts.filter( - (attempt) => attempt.passed, - ).length; - const totalDurationMs = caseResult.attempts.reduce( - (sum, attempt) => sum + attempt.durationMs, - 0, + const attemptAggregate = aggregateAttempts(caseResult.attempts); + const passedAttemptAggregate = aggregateAttempts( + caseResult.attempts.filter((attempt) => attempt.passed), ); + const attemptCount = attemptAggregate.attemptCount; + const passedAttempts = passedAttemptAggregate.attemptCount; const judgeScores = caseResult.attempts.flatMap((attempt) => typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : [], ); - const totalTokenUsage = - caseResult.attempts.reduce( - (sum, attempt) => { - if (!attempt.tokenUsage) { - return sum; - } - sum ??= { prompt: 0, completion: 0, total: 0 }; - sum.prompt += attempt.tokenUsage.prompt; - sum.completion += attempt.tokenUsage.completion; - sum.total += attempt.tokenUsage.total; - return sum; - }, - null, - ); return { id: caseResult.id, @@ -300,20 +344,23 @@ function toHistoryRecord(result: BenchmarkRunResult) { passedAttempts, passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount, averageDurationMs: - attemptCount === 0 ? 0 : totalDurationMs / attemptCount, + attemptCount === 0 + ? 0 + : attemptAggregate.durationTotal / attemptCount, + averagePassedDurationMs: averageDuration(passedAttemptAggregate), averageJudgeScore: judgeScores.length === 0 ? null : judgeScores.reduce((sum, score) => sum + score, 0) / judgeScores.length, averageTokenUsagePerAttempt: - attemptCount === 0 || !totalTokenUsage + attemptCount === 0 ? null - : { - prompt: totalTokenUsage.prompt / attemptCount, - completion: totalTokenUsage.completion / attemptCount, - total: totalTokenUsage.total / attemptCount, - }, + : averageTokenUsage(attemptAggregate, attemptCount), + averageTokenUsagePerPassedAttempt: averageTokenUsage( + passedAttemptAggregate, + passedAttempts, + ), }; }), }; diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 19c97bce11..ecc46591fc 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -326,8 +326,11 @@ export interface BenchmarkRunResult { passedAttempts: number; passRate: number; averageDurationMs: number; + averagePassedDurationMs?: number | null; totalTokenUsage?: BenchmarkTokenUsage | null; + totalPassedTokenUsage?: BenchmarkTokenUsage | null; averageTokenUsagePerAttempt?: BenchmarkTokenUsage | null; + averageTokenUsagePerPassedAttempt?: BenchmarkTokenUsage | null; artifactsPath?: string | null; cases: BenchmarkCaseResult[]; } diff --git a/ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json b/ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json new file mode 100644 index 0000000000..98538a1ccb --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json @@ -0,0 +1,66 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/format_greeting", + "summary": "Format a deployed greeting", + "description": "Returns a plain greeting for a provided name.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n" + }, + { + "path": "f/evals/global/format_greeting_archive", + "summary": "Archived greeting formatter", + "description": "Older greeting formatter kept for reference.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hi, ${name}`\n}\n" + } + ] + }, + "liveEditorDrafts": [ + { + "type": "script", + "storagePath": "f/evals/global/current_greeting", + "effectivePath": "f/evals/global/current_greeting", + "value": { + "path": "f/evals/global/current_greeting", + "summary": "Open greeting formatter", + "description": "Formats a greeting in the live editor.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n", + "is_template": false, + "kind": "script" + } + } + ] +} diff --git a/ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json b/ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json new file mode 100644 index 0000000000..5c2ffcb0f0 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json @@ -0,0 +1,118 @@ +{ + "workspace": { + "flows": [ + { + "path": "f/evals/global/process_invoice", + "summary": "Deployed invoice processor", + "description": "Calculates invoice totals from a subtotal.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate total from subtotal", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + } + }, + { + "path": "f/evals/global/process_refund", + "summary": "Refund processor", + "description": "Calculates refund totals.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate refund total", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + } + } + ] + }, + "liveEditorDrafts": [ + { + "type": "flow", + "storagePath": "f/evals/global/current_invoice_flow", + "effectivePath": "f/evals/global/current_invoice_flow", + "value": { + "path": "f/evals/global/current_invoice_flow", + "summary": "Open invoice processor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate total from subtotal", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + }, + "edited_by": "", + "edited_at": "", + "archived": false, + "extra_perms": {} + } + } + ] +} diff --git a/ai_evals/modes/global.ts b/ai_evals/modes/global.ts index d68df9f5f8..f3cbf6fd86 100644 --- a/ai_evals/modes/global.ts +++ b/ai_evals/modes/global.ts @@ -1,5 +1,8 @@ import { readFile } from "node:fs/promises"; -import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner"; +import { + runGlobalEval, + type GlobalLiveEditorDraftFixture, +} from "../adapters/frontend/core/global/globalEvalRunner"; import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend"; import type { FrontendEvalModelConfig } from "../core/models"; import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types"; @@ -9,6 +12,7 @@ import { getFrontendApiKey } from "./frontendCommon"; export interface GlobalInitialFixture { workspace?: BenchmarkWorkspaceRunnables; + liveEditorDrafts?: GlobalLiveEditorDraftFixture[]; } export function createGlobalModeRunner( @@ -31,6 +35,7 @@ export function createGlobalModeRunner( getFrontendApiKey(modelConfig.provider), { workspaceFixtures: initial?.workspace, + liveEditorDrafts: initial?.liveEditorDrafts, maxIterations: context.evalCase?.runtime?.maxTurns, provider: modelConfig.provider, model: modelConfig.model, @@ -73,6 +78,7 @@ async function loadGlobalInitialFixture(path: string): Promise { expect(content).toContain( 'Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft' ) + expect(content).toContain('If the user message includes an ACTIVE EDITOR section') expect(content).not.toContain('AI draft') expect(content).not.toContain('UserDraft') expect(content).not.toContain('localStorage') @@ -1359,6 +1360,27 @@ describe('prepareGlobalSystemMessage', () => { }) describe('prepareGlobalUserMessage', () => { + it('injects the active editor reference without contents', () => { + __resetUserDraftForTesting() + localStorage.clear() + UserDraft.setLiveEditorDraft({ + workspace: WORKSPACE, + itemKind: 'script', + storagePath: '', + effectivePath: 'f/scripts/live_greeting' + }) + + const message = prepareGlobalUserMessage('Update this script', [], { workspace: WORKSPACE }) + + expect(message.content).toContain('## ACTIVE EDITOR') + expect(message.content).toContain('type: script') + expect(message.content).toContain('path: f/scripts/live_greeting') + expect(message.content).toContain('isLiveDraft: true') + expect(message.content).toContain('## INSTRUCTIONS:\nUpdate this script') + expect(message.content).not.toContain('When the user says') + expect(message.content).not.toContain('content') + }) + it('includes selected workspace item references without contents', () => { const message = prepareGlobalUserMessage('Update these items', [ { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 693ba3072f..f193da10a0 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -113,6 +113,28 @@ const INSTRUCTION_SUBJECTS = [ 'app' ] as const satisfies readonly WorkspaceItemType[] const MAX_LIST_LIMIT = 100 +type ActiveGlobalEditorType = Extract +type LiveEditorDraftKind = Parameters[0] + +const ACTIVE_GLOBAL_EDITOR_DRAFTS: readonly { + itemKind: LiveEditorDraftKind + type: ActiveGlobalEditorType +}[] = [ + { itemKind: 'script', type: 'script' }, + { itemKind: 'flow', type: 'flow' }, + { itemKind: 'raw_app', type: 'app' } +] + +export type GlobalActiveEditorContext = { + type: ActiveGlobalEditorType + path: string + isLiveDraft: true +} + +export type GlobalUserMessageOptions = { + workspace?: string + activeEditor?: GlobalActiveEditorContext +} const itemTypeSchema = z.enum(ITEM_TYPES) const instructionSubjectSchema = z.enum(INSTRUCTION_SUBJECTS) @@ -499,7 +521,7 @@ Use tools to inspect workspace items and create local drafts for scripts, flows, Rules: - Draft tools create or update local drafts only; they do not deploy or mutate deployed workspace items. - Use list_workspace_items to find items and read_workspace_item before changing an existing item. For triggers, pass trigger_kind. -- If the user refers to the open editor, use the item marked isLiveDraft=true. +- If the user message includes an ACTIVE EDITOR section, treat it as the currently open item and use it for references like "this", "current", or "open editor". - Use deploy_workspace_item only after the user explicitly asks to deploy. It persists a local draft to the workspace. - Use discard_local_draft to remove an unsaved local draft, including the matching open editor draft. Use delete_workspace_item only to delete a deployed workspace item. - Variable values are never readable. For secrets, create a secret variable and reference it from resources as "$var:path/to/variable". @@ -3000,15 +3022,36 @@ export function prepareGlobalSystemMessage( } } +export function getActiveGlobalEditorContext( + workspace: string +): GlobalActiveEditorContext | undefined { + for (const { itemKind, type } of ACTIVE_GLOBAL_EDITOR_DRAFTS) { + const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace }) + const path = liveDraft?.effectivePath || liveDraft?.storagePath + if (!path) continue + return { type, path, isLiveDraft: true } + } +} + export function prepareGlobalUserMessage( instructions: string, - selectedContext: ContextElement[] = [] + selectedContext: ContextElement[] = [], + options: GlobalUserMessageOptions = {} ): ChatCompletionUserMessageParam { const selectedWorkspaceItems = selectedContext.filter( (context) => context.type === 'workspace_script' || context.type === 'workspace_flow' ) + const activeEditor = + options.activeEditor ?? (options.workspace ? getActiveGlobalEditorContext(options.workspace) : undefined) let content = '' + if (activeEditor) { + content += '## ACTIVE EDITOR\n' + content += `type: ${activeEditor.type}\n` + content += `path: ${activeEditor.path}\n` + content += `isLiveDraft: true\n\n` + } + if (selectedWorkspaceItems.length > 0) { content += '## SELECTED CONTEXT\n' for (const context of selectedWorkspaceItems) { From aea00611c41379be2afdad0eedd608c9537d03f7 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 16:48:54 +0200 Subject: [PATCH 10/52] fix(frontend): prevent MultiSelect crash on undefined value (#9364) MultiSelect read `value.length` directly while `value` is a bindable prop with no default, so a parent passing `undefined` (e.g. an enum-array approval form field with no initial value via ArgInput) threw a TypeError that blanked the entire approval page. Guard all reads behind a `value ?? []` derived. Fixes WIN-1996 Co-authored-by: Claude Opus 4.7 (1M context) --- .../lib/components/select/MultiSelect.svelte | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/lib/components/select/MultiSelect.svelte b/frontend/src/lib/components/select/MultiSelect.svelte index 750ab62b8c..419984b20d 100644 --- a/frontend/src/lib/components/select/MultiSelect.svelte +++ b/frontend/src/lib/components/select/MultiSelect.svelte @@ -72,6 +72,8 @@ let wrapperEl: HTMLDivElement | undefined = $state() let searchInputEl: TextInput | undefined = $state() + let currentValue = $derived(value ?? []) + $effect(() => searchInputEl?.focus()) let processedItems: ProcessedItem[] = $derived.by(() => { @@ -87,18 +89,20 @@ }) let valueEntry = $derived( - value.map((v) => processedItems.find((item) => item.value === v) ?? { value: v, label: v }) + currentValue.map( + (v) => processedItems.find((item) => item.value === v) ?? { value: v, label: v } + ) ) function onAddValue(item: ProcessedItem) { if (item.__is_create && onCreateItem) { onCreateItem(item.value) } else { - value = [...value, item.value] + value = [...currentValue, item.value] } } function onRemoveValue(item: ProcessedItem) { - value = value.filter((v) => v !== item.value) + value = currentValue.filter((v) => v !== item.value) } function clearValue() { @@ -132,7 +136,7 @@ - {#if value.length === 0} + {#if currentValue.length === 0} {placeholder} @@ -149,7 +153,7 @@ {allowClear} onRemove={onRemoveValue} onReorder={reorderable - ? (oldIdx, newIdx) => (value = reorder(value, oldIdx, newIdx)) + ? (oldIdx, newIdx) => (value = reorder(currentValue, oldIdx, newIdx)) : undefined} /> @@ -166,7 +170,7 @@ {disablePortal} onSelectValue={onAddValue} {open} - processedItems={processedItems.filter((item) => !value.includes(item.value))} + processedItems={processedItems.filter((item) => !currentValue.includes(item.value))} value={undefined} {disabled} {filterText} @@ -181,7 +185,7 @@ ulClass="options" > {#snippet header()} - {#if processedItems.length - value.length > 0 || onCreateItem} + {#if processedItems.length - currentValue.length > 0 || onCreateItem}
Date: Thu, 28 May 2026 16:53:05 +0200 Subject: [PATCH 11/52] feat(queue): duration-weighted fairness admission (#9334) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] feat(queue): duration-weighted fairness admission atomic Add the `WORKSPACE_FAIRNESS_ADMISSION_PPM` atomic that the EE `workspace_fairness_ee::refresh_overloaded` writes on each refresh (see companion EE PR). The atomic is read on every pull by `should_admit_capped` to decide whether the dispatch goes down the standard or fairness path. Defaults to 10_000 (= admit all) so the pre-fairness behaviour is preserved until the first refresh fires. OSS stub in `workspace_fairness.rs` continues to return `true` unconditionally, so non-EE builds are bit-identical. * docs(queue): consolidate full fairness algorithm into workspace_fairness.rs Move the algorithm doc — what "overloaded" means in worker-seconds, the duration-weighted admission derivation, coordinated refresh structure, audit emission, the SQL perf constraints (no params CTE, drive running side from v2_job_runtime), and EE gating — into the OSS surface module where it is readable without EE access. The EE file becomes implementation only. Also bump ee-repo-ref to the EE commit that strips the duplicate doc. * docs(queue): clarify ADMISSION_PPM default is "admit all", not count-based Addresses CI review (claude[bot]): the `10_000` initial value is the "admit all" no-op default that applies before the first refresh classifies an overloaded set — not the count-based value (which would be `target * 10_000`). The count-based form is the empty-bucket fallback inside `compute_admission_ppm`, a different thing. * chore(queue): point ee-repo-ref at EE main (fairness admission merged via #593) * fix(queue): duration-weighted admission uses unclamped service-time window Bumps ee-repo-ref to the EE fix (windmill-ee-private#596) that sources D_c/D_u for the admission probability from a separate 60s service-time window of true `duration_ms`, instead of the occupancy aggregation whose per-job contributions are clamped to the 10s occupancy window. The clamp truncated D_c for capped jobs longer than the window, under-admitting the duration skew (true 34s jobs → ~86% effective share instead of the target 65%). Occupancy worker-seconds still drive overload classification. Updates the algorithm doc in workspace_fairness.rs accordingly. Note: ee-repo-ref points at the EE feature branch; re-point to EE main once #596 merges. --- backend/ee-repo-ref.txt | 2 +- backend/windmill-common/src/worker.rs | 11 + .../windmill-queue/src/workspace_fairness.rs | 258 +++++++++++++++++- 3 files changed, 260 insertions(+), 11 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 761de56ebb..80a746c1c0 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -327d23f7438968a21bac9fd42e7f6f027c61477c \ No newline at end of file +55c19293232be379a3044eb78f677b545882ffd6 \ No newline at end of file diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 5a7c6d2bfa..92ebf08477 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -252,6 +252,17 @@ lazy_static::lazy_static! { pub static ref WORKSPACE_FAIRNESS_OVERLOADED: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(vec![]); pub static ref WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS: AtomicI64 = AtomicI64::new(0); + /// Stochastic admission probability for capped workspaces, expressed in + /// parts per 10_000 (so `420` = 4.2%). The refresh computes this from the + /// observed worker-second distribution and the configured cap so that + /// admission converges to the target *worker-second* share — independent + /// of how the capped vs uncapped workspaces compare on per-job durations. + /// See `workspace_fairness_ee::refresh_overloaded` for the derivation. + /// `10_000` (= admit all) is the default until the first refresh + /// classifies an overloaded set — before that, no workspace is capped so + /// `should_admit_capped` is moot and "admit all" is the correct no-op. + pub static ref WORKSPACE_FAIRNESS_ADMISSION_PPM: AtomicU32 = AtomicU32::new(10_000); + pub static ref SMTP_CONFIG: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); pub static ref INDEXER_CONFIG: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee(TantivyIndexerSettings::default()); diff --git a/backend/windmill-queue/src/workspace_fairness.rs b/backend/windmill-queue/src/workspace_fairness.rs index 9f4ba00211..8eb3a69394 100644 --- a/backend/windmill-queue/src/workspace_fairness.rs +++ b/backend/windmill-queue/src/workspace_fairness.rs @@ -1,15 +1,253 @@ -//! Per-workspace fairness for the shared worker pool (Enterprise feature). +//! # Per-workspace fairness for the shared worker pool (Enterprise feature) //! -//! The real algorithm — overloaded-set aggregation, coordinated refresh on -//! `background_task_state`, audit emission, stochastic admission decision — -//! lives in [`crate::workspace_fairness_ee`] and only compiles when the -//! `private` feature is on. This module is the public surface used by the -//! pull dispatch in `jobs.rs` and the integration tests; when EE is on it -//! transparently re-exports the EE implementation, when EE is off it -//! provides no-op stubs so the OSS build stays bit-identical to the -//! pre-fairness pull path. +//! On multi-tenant deployments (notably `app.windmill.dev`, and any EE +//! cluster with a single shared worker group) a single workspace flooding +//! the queue with jobs can degrade quality of service for everyone else. +//! This module computes the set of "overloaded" workspaces whose share of +//! the worker pool must be capped, and the dispatch in `jobs.rs` uses a +//! **duration-weighted stochastic admission rule** at pull time to enforce +//! the cap as a *worker-second* share, not a pull-count share. //! -//! See [`crate::workspace_fairness_ee`] for design notes and SQL details. +//! The full algorithm lives in [`crate::workspace_fairness_ee`] behind the +//! `private` feature — this OSS-facing module is the public surface that +//! the pull dispatch and integration tests call. When EE is on, the symbols +//! here transparently re-export the EE implementation. When EE is off, they +//! are no-ops: `maybe_refresh_overloaded` does nothing, `should_admit_capped` +//! always returns `true`, and the pull path is bit-identical to its +//! pre-fairness shape. **Both runtime correctness and the entire reasoning +//! below assume the EE module is compiled in**; the OSS build is a stub. +//! +//! Every numerical default mentioned below (`MAX_PERCENT = 50`, +//! `DURATION_SECS = 10`, `MIN_TOTAL = 4`, `WORKER_PING_LIVE_SECS = 60`, +//! `ADMISSION_EPSILON_PERCENT = 5`) is tunable via global settings or +//! constants; the values here are the as-shipped defaults at the time of +//! writing and what the design discussion below was calibrated against. +//! +//! ## 1. What "overloaded" means — worker-seconds, not jobs +//! +//! A workspace is overloaded when, over a rolling +//! `WORKSPACE_FAIRNESS_DURATION_SECS = 10s` window, it has consumed at least +//! `WORKSPACE_FAIRNESS_MAX_PERCENT = 50%` of cluster worker-time. Activity +//! is measured in **worker-seconds**: each job contributes the wall-clock +//! time it actually held a worker, intersected with the window. A +//! count-based signal — "what fraction of jobs in the window are from this +//! workspace" — gets badly fooled by job-duration heterogeneity: 600 +//! short (100ms) jobs and one long (60s) job consume the same worker-time +//! but the count-based form attributes 600× more weight to the spammy +//! workspace. Worker-seconds put both patterns on the same scale. +//! +//! Two sources contribute to a workspace's worker-second total: +//! +//! - **Running** (live, currently-on-a-worker): driven from `v2_job_runtime` +//! filtered on `ping > now() - WORKER_PING_LIVE_SECS` (60s, ≈ 2× worker +//! heartbeat interval), then PK-joined to `v2_job` for the `kind` filter +//! and `v2_job_queue` for `started_at` / `suspend_until`. Contribution is +//! `clamp(min(now, ping) − max(started_at, window_start), 0, window)`. +//! End-of-interval is the per-job `ping`, which both (a) implements the +//! zombie defense — a worker that stopped pinging stops accruing +//! worker-seconds at its last heartbeat, so a backlog of stuck +//! `running = true` rows can't dominate the denominator — and (b) matches +//! the semantic of "worker-seconds the worker has confirmed". `v2_job_runtime` +//! is small (rows deleted on completion), so driving the scan from there +//! keeps the per-refresh cost bounded by the *in-flight* count rather +//! than by the queue size, even when one workspace has thousands of +//! `running = true` rows. +//! +//! - **Completed** (recently finished): pulled by an index scan over +//! `v2_job_completed (completed_at)`, then PK-joined to `v2_job`. The +//! index hit is critical — see "Why no `WITH params AS (...)` CTE" below. +//! Contribution is `clamp(min(completed_at, now) − max(started_at, +//! completed_at − duration_ms, window_start), 0, window)`. Clamping +//! start-of-interval by `completed_at − duration_ms` defends against +//! zombie rows that `zombie_monitor` force-failed: `started_at` may be +//! far in the past, but `duration_ms` reflects the actual measured worker +//! time, so the row only contributes its real runtime, not the idle wait +//! before force-fail. +//! +//! Both halves exclude **flow-orchestration kinds** +//! (`flow, flowpreview, flownode, singlestepflow`) and **concurrency- +//! suspended rows** (`suspend_until IS NOT NULL`) — these hold +//! `running = true` but consume no worker slot. Same predicate as +//! `handle_zombie_jobs` in `monitor.rs`. +//! +//! `WORKSPACE_FAIRNESS_MIN_TOTAL = 4` is also in worker-seconds (≈ 40 % +//! utilization of one worker over a 10s window) — below the floor, the +//! cluster is too quiet to bother capping anyone. +//! +//! ## 2. The cap is enforced stochastically, weighted by duration +//! +//! The pull dispatch in `jobs.rs` flips a coin on every pull: with +//! probability `p_c` it uses the standard pull query (capped workspaces +//! are admissible — FIFO will pick them if they're at the head), and with +//! probability `1 − p_c` it uses the *fairness pull query* which excludes +//! the overloaded workspaces. Doing it as a probabilistic split rather +//! than a binary cap/uncap gate keeps victim latency flat instead of +//! breathing in/out with each refresh cycle. +//! +//! The key design choice is how `p_c` is set. The natural first try is +//! `p_c = (MAX_PERCENT + ε) / 100` — a constant. That converges the +//! *pull-count* ratio to `MAX_PERCENT`, but only matches the worker-second +//! ratio when capped and uncapped workspaces share the same mean job +//! duration. The steady-state share equation is: +//! +//! `share = p_c · D_c / (p_c · D_c + (1 − p_c) · D_u)` +//! +//! where `D_c` and `D_u` are the per-job mean durations of capped and +//! uncapped workspaces respectively. With `D_c = 34s` and `D_u = 1s` (the +//! exact numbers observed during the lancom01-prod / jps-internal cloud +//! incident), a constant `p_c = 0.65` (60 % + 5 % ε) yields +//! +//! `share = 0.65 · 34 / (0.65 · 34 + 0.35 · 1) = 22.1 / 22.45 ≈ 98%` +//! +//! — i.e., the "60 % cap" was in practice giving capped workspaces 98 % +//! of worker-seconds. Victims were observed waiting 15s+ for pickup +//! despite the cap firing on every pull. +//! +//! Inverting the equation for the desired share `t = (MAX_PERCENT + ε) / 100`: +//! +//! `p_c = t · D_u / ((1 − t) · D_c + t · D_u)` +//! +//! Same numbers, target 0.65: `p_c ≈ 0.054` — about 12× tighter than the +//! count-based form. The refresh computes `p_c` and stores it in +//! [`WORKSPACE_FAIRNESS_ADMISSION_PPM`] (parts-per-10_000, fits in an +//! `AtomicU32`). The pull-time check is one atomic load plus one +//! `rand::rng().random_range(0..10_000)` draw — same hot-path cost as the +//! count-based form. +//! +//! ### `D_c`/`D_u` come from a separate, longer service-time window +//! +//! Crucially, `D_c` and `D_u` must be **true mean service times**, because +//! the share equation above is Little's-law-based +//! (`occupancy = arrival_rate × mean_service_time`). They are **not** taken +//! from the occupancy aggregation: that aggregation clamps each job's +//! contribution to the short occupancy window (`DURATION_SECS`, 10s), so a +//! job longer than the window contributes at most 10s — fine for measuring +//! *share*, but it would truncate `D_c` to ≤ 10s and systematically +//! under-admit the skew exactly when capped jobs are long (the case the cap +//! exists for: e.g. true `D_c = 34s` clamped to 10s gives `p_c ≈ 0.157`, an +//! 86 % effective share instead of 65 %). Instead, the refresh samples true +//! unclamped `duration_ms` of completed jobs over a longer, decoupled +//! service-time window (`DURATION_SAMPLE_SECS`, 60s) — long enough to avoid +//! truncation and to keep the mean stable when few jobs complete within the +//! 10s occupancy window. So the refresh emits two per-workspace signals: +//! windowed occupancy worker-seconds (for classification) and a 60s +//! service-time `(Σ duration_ms, count)` (for admission), merged per +//! workspace. +//! +//! ### Why we kept the fallback when the fairness pull returns empty +//! +//! The 100 − `p_c` % of pulls that try the fairness query (excluding +//! capped workspaces) fall back to the standard query if the fairness +//! query returns no row. The alternative — idle the worker, holding the +//! slot open in case a victim shows up — was considered but rejected for +//! the first iteration: with `p_c` correctly tightened, victims do get the +//! slot they need *when they exist*, and absent victims, falling back to +//! the capped pool is the right behaviour (otherwise the cluster +//! under-utilises itself for no benefit). Adding a reserve-capacity skip +//! is a fine-tuning lever for bursty victim arrival patterns and is left +//! as a follow-up. +//! +//! ### Degenerate cases +//! +//! If either bucket is empty — no capped jobs, no uncapped jobs, or a +//! capped workspace with zero completions in the 60s service-time window +//! (all its jobs still running) — the formula is undefined. The refresh +//! falls back to the count-based `p_c = t` in those cases — it matches +//! the pre-refactor behaviour and is the safest thing to do when there's +//! no service-time signal yet to weight on. +//! +//! ## 3. Coordinated refresh — exactly once per cycle, cluster-wide +//! +//! The aggregation is too expensive to run on every worker process every +//! pull (and would produce no new information on the sub-second +//! timescale). It runs **at most once every `refresh_interval` seconds +//! across the entire fleet**, gated by both a per-process CAS and a +//! DB-side row lock: +//! +//! 1. **Per-process gate** — `maybe_refresh_overloaded` (called from the +//! pull path) does `LAST_REFRESH_MICROS.compare_exchange` to ensure at +//! most one in-flight refresh per process per interval. If the CAS +//! fails or the interval hasn't elapsed yet, the call is a no-op. +//! Cost on the hot path: one atomic load, optionally one CAS. +//! +//! 2. **DB-side claim** — `refresh_overloaded` first does a cheap upsert +//! (`INSERT ... ON CONFLICT ON background_task_state ... WHERE +//! updated_at < NOW() − refresh_interval RETURNING true`). The `VALUES` +//! clause is all constants, so Postgres has no expensive work to do +//! even for losers. Only the unique winner per cycle gets `Some(true)`; +//! losers get `None` and skip the aggregation entirely. +//! +//! 3. **Winner-only aggregation** — the winner runs the +//! `v2_job_runtime ∪ v2_job_completed` worker-second aggregation +//! returning per-workspace `(workspace_id, worker_seconds, jobs)`, +//! classifies into overloaded/uncapped, computes `p_c`, and writes the +//! new payload `{"overloaded": [...], "admission_ppm": N}` back to +//! `background_task_state.workspace_fairness`. +//! +//! 4. **Everyone reads** — winner and losers alike then `SELECT` the +//! current value, parse it, and update their in-process +//! `WORKSPACE_FAIRNESS_OVERLOADED` and `WORKSPACE_FAIRNESS_ADMISSION_PPM` +//! atomics. This is what makes losers eventually see the winner's +//! decision; they just don't pay the aggregation cost. +//! +//! The refresh interval is `ACTIVE_REFRESH_SECS = 2s` when the cluster +//! currently has a capped workspace (faster — we want the cap to lift +//! promptly once load drops) and `IDLE_REFRESH_SECS = 5s` otherwise +//! (slower — minimise DB load during normal operation). The DB-side guard +//! always uses the tighter `ACTIVE_REFRESH_SECS` to bound the race +//! window; the per-process gate enforces the idle cadence. +//! +//! If a refresh fails (DB error, timeout > 5s), `LAST_REFRESH_MICROS` is +//! left set to the attempt's timestamp so the next attempt has to wait a +//! full interval — exactly the same cooldown as a successful refresh. +//! Resetting to `0` on failure would remove the rate limit entirely +//! precisely when DB load is highest, which is the wrong direction. +//! +//! ## 4. Audit logging +//! +//! Workspaces entering or leaving the capped set produce +//! `workspace_fairness.capped` / `workspace_fairness.uncapped` audit +//! entries scoped to the `admins` workspace, with the affected workspace +//! as the `resource` field. Emitted by the refresh winner only, so a +//! transition produces exactly one audit row regardless of fleet size. +//! The "previous list" diffed against is the DB value (not the per-process +//! cache) so a freshly-restarted worker that happens to win the first +//! claim doesn't emit spurious "newly capped" entries for workspaces that +//! were already capped before it started. +//! +//! ## 5. Notable SQL performance constraints +//! +//! - **No `WITH params AS (...)` CTE for `window_start`.** A natural +//! refactor would be to compute `NOW() - make_interval(secs => N)` once +//! in a CTE and reference it in both halves of the UNION. But Postgres +//! *materialises* the CTE and the optimiser can no longer push the +//! `completed_at > window_start` predicate down to the +//! `ix_job_completed_completed_at` index. On the production cloud DB +//! (~12M `v2_job_completed` rows), that turns a 10 ms index scan into a +//! ~47s full table scan. The query intentionally inlines `NOW()` and +//! `NOW() - make_interval(...)` at every callsite. +//! +//! - **Drive running side from `v2_job_runtime`, not `v2_job_queue`.** +//! Naive ordering ("scan v2_job_queue for `running = true`, join v2_job +//! for the kind filter") does a Seq Scan over ~thousands of running-or- +//! bookkeeping rows and does a PK lookup into `v2_job` for every one of +//! them — ~10 ms in prod, but worse: bounded by *queue size*. Pivoting +//! to drive the scan from `v2_job_runtime` filtered on +//! `ping > NOW() - 60s` narrows to the in-flight set (small, deletes- +//! on-completion) *before* any PK lookups: 1.3 ms, 9× less I/O, +//! bounded by *live worker count*. +//! +//! ## 6. Enterprise gating +//! +//! The cap is an Enterprise feature. `windmill-api-settings` rejects +//! `workspace_fairness_enabled = true` writes from non-EE builds, and on a +//! single-tenant self-hosted deployment the default +//! `workspace_fairness_enabled = false` keeps the pull path identical to +//! the pre-fairness baseline. At runtime the dispatch checks the atomic +//! only — when fairness is off, `maybe_refresh_overloaded` drains the +//! cached state in one pull cycle (resetting `WORKSPACE_FAIRNESS_OVERLOADED` +//! to empty and `WORKSPACE_FAIRNESS_ADMISSION_PPM` to 10_000 = "admit all"), +//! so toggling the feature off without restarting workers is safe. #[cfg(feature = "private")] #[allow(unused)] From a7d85a39ff177834e87b7baf444ed19179a14ca9 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 28 May 2026 18:05:04 +0200 Subject: [PATCH 12/52] refactor: clean up ai provider proxy logic (#9360) * refactor: clean up ai provider proxy logic * docs: remove completed ai refactor plan * fix: audit failed google global proxy calls --- backend/windmill-ai/src/ai_bedrock.rs | 36 ++ backend/windmill-ai/src/credentials.rs | 25 + backend/windmill-ai/src/lib.rs | 1 + .../windmill-ai/src/providers/anthropic.rs | 3 +- backend/windmill-ai/src/providers/bedrock.rs | 356 +++++++++----- .../windmill-ai/src/providers/google_ai.rs | 2 +- backend/windmill-ai/src/providers/mod.rs | 4 +- backend/windmill-ai/src/proxy.rs | 28 +- backend/windmill-ai/src/proxy/fim.rs | 120 +++++ backend/windmill-ai/src/types.rs | 2 +- backend/windmill-api/src/ai.rs | 188 ++++---- backend/windmill-worker/src/ai/mod.rs | 2 +- ...y_builder.rs => stream_event_processor.rs} | 0 backend/windmill-worker/src/ai/tools.rs | 2 +- backend/windmill-worker/src/ai_executor.rs | 2 +- docs/windmill-ai-refactor-plan.md | 441 ------------------ 16 files changed, 510 insertions(+), 702 deletions(-) create mode 100644 backend/windmill-ai/src/credentials.rs create mode 100644 backend/windmill-ai/src/proxy/fim.rs rename backend/windmill-worker/src/ai/{query_builder.rs => stream_event_processor.rs} (100%) delete mode 100644 docs/windmill-ai-refactor-plan.md diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index c05094b8f4..e549e63041 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -754,6 +754,26 @@ pub fn bedrock_stream_event_to_tool_start( } } +pub fn bedrock_stream_event_to_tool_start_with_block_index( + event: &ConverseStreamOutput, +) -> Option<(usize, StreamingToolCall)> { + match event { + ConverseStreamOutput::ContentBlockStart(start) => { + let block_index = usize::try_from(start.content_block_index()).ok()?; + let tool_use = start.start().and_then(|s| s.as_tool_use().ok())?; + Some(( + block_index, + StreamingToolCall { + id: tool_use.tool_use_id().to_string(), + name: tool_use.name().to_string(), + arguments: String::new(), + }, + )) + } + _ => None, + } +} + /// Extract tool use input delta from stream pub fn bedrock_stream_event_to_tool_delta(event: &ConverseStreamOutput) -> Option { match event { @@ -765,6 +785,22 @@ pub fn bedrock_stream_event_to_tool_delta(event: &ConverseStreamOutput) -> Optio } } +pub fn bedrock_stream_event_to_tool_delta_with_block_index( + event: &ConverseStreamOutput, +) -> Option<(usize, String)> { + match event { + ConverseStreamOutput::ContentBlockDelta(delta) => { + let block_index = usize::try_from(delta.content_block_index()).ok()?; + let input = delta + .delta() + .and_then(|d| d.as_tool_use().ok()) + .map(|tool_use| tool_use.input().to_string())?; + Some((block_index, input)) + } + _ => None, + } +} + /// Check if stream event indicates content block stop pub fn bedrock_stream_event_is_block_stop(event: &ConverseStreamOutput) -> bool { matches!(event, ConverseStreamOutput::ContentBlockStop(_)) diff --git a/backend/windmill-ai/src/credentials.rs b/backend/windmill-ai/src/credentials.rs new file mode 100644 index 0000000000..75d523cf25 --- /dev/null +++ b/backend/windmill-ai/src/credentials.rs @@ -0,0 +1,25 @@ +use std::collections::HashMap; + +use crate::ai_providers::{AIPlatform, AIProvider}; + +/// Resolved provider credentials shared by API proxy and worker execution. +/// +/// Raw API resources and worker agent payloads convert into this shape at their +/// execution boundaries. Request-specific state such as the selected model stays +/// outside this type. +#[derive(Clone, Debug)] +pub struct ProviderCredentials { + pub provider: AIProvider, + pub base_url: String, + pub api_key: Option, + pub access_token: Option, + pub organization_id: Option, + pub user: Option, + pub region: Option, + pub aws_access_key_id: Option, + pub aws_secret_access_key: Option, + pub aws_session_token: Option, + pub platform: AIPlatform, + pub enable_1m_context: bool, + pub custom_headers: HashMap, +} diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index a138d72f3c..b6487c0ac5 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -4,6 +4,7 @@ pub mod ai_cache; pub mod ai_google; pub mod ai_providers; pub mod ai_types; +pub mod credentials; pub mod image_handler; pub mod providers; pub mod proxy; diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 8a8a2b846d..17a27e370f 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -729,8 +729,7 @@ impl QueryBuilder for AnthropicQueryBuilder { mod tests { use super::*; use crate::{ - proxy::{ProviderCredentials, ProxyBuildArgs}, - query_builder::QueryBuilder, + credentials::ProviderCredentials, proxy::ProxyBuildArgs, query_builder::QueryBuilder, }; use http::{HeaderMap, HeaderValue, Method}; use std::collections::HashMap; diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index ec6f4fbcd3..0eef359c36 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -10,9 +10,10 @@ use crate::{ ai_bedrock::{ bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, - bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config, - format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai, - BearerTokenProvider, BedrockClient, StreamingToolCall, + bedrock_stream_event_to_tool_delta_with_block_index, bedrock_stream_event_to_tool_start, + bedrock_stream_event_to_tool_start_with_block_index, build_tool_config, + create_inference_config, format_bedrock_error, openai_messages_to_bedrock, + streaming_tool_calls_to_openai, BearerTokenProvider, BedrockClient, StreamingToolCall, }, ai_providers::USE_ENV_REGION, ai_types::{OpenAIFunction, OpenAIToolCall, ToolDefFunction}, @@ -403,137 +404,15 @@ pub fn sdk_stream_to_sse( .unwrap() .as_secs(); - struct StreamState { - id: String, - model: String, - created: u64, - tool_calls: HashMap, - current_tool_index: usize, - } - - let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState { - id, - model, - created, - tool_calls: HashMap::new(), - current_tool_index: 0, - })); - async_stream::stream! { let mut stream = stream; - let state = state.clone(); + let mut state = BedrockSseStreamState::new(id, model, created); loop { match stream.recv().await { Ok(Some(event)) => { - let mut state = state.lock().await; - - if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) { - let index = state.current_tool_index; - state.tool_calls.insert( - index, - (tool_call.id.clone(), tool_call.name.clone(), String::new()), - ); - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": index, - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.name, - "arguments": "" - } - }] - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); - } - - if let Some(text) = bedrock_stream_event_to_text(&event) { - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "content": text - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); - } - - if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) { - let index = state.current_tool_index; - if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) { - args.push_str(&input_delta); - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": index, - "function": { - "arguments": input_delta - } - }] - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); - } - } - - if bedrock_stream_event_is_block_stop(&event) { - state.current_tool_index += 1; - } - - if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = &event { - let stop_reason = stop.stop_reason().as_str(); - let finish_reason = match stop_reason { - "end_turn" => "stop", - "max_tokens" => "length", - "tool_use" => "tool_calls", - "stop_sequence" => "stop", - "guardrail_intervened" | "content_filtered" => "content_filter", - _ => "stop", - }; - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": {}, - "finish_reason": finish_reason - }] - }); - - yield Ok(Bytes::from(format!("data: {}\n\n", chunk))); + for chunk in bedrock_sse_chunks_for_event(&event, &mut state) { + yield Ok(chunk); } } Ok(None) => break, @@ -551,6 +430,149 @@ pub fn sdk_stream_to_sse( } } +#[derive(Debug)] +struct BedrockSseStreamState { + id: String, + model: String, + created: u64, + tool_calls: HashMap, + tool_block_indexes: HashMap, + next_tool_index: usize, +} + +impl BedrockSseStreamState { + fn new(id: String, model: String, created: u64) -> Self { + Self { + id, + model, + created, + tool_calls: HashMap::new(), + tool_block_indexes: HashMap::new(), + next_tool_index: 0, + } + } +} + +fn bedrock_sse_chunks_for_event( + event: &aws_sdk_bedrockruntime::types::ConverseStreamOutput, + state: &mut BedrockSseStreamState, +) -> Vec { + let mut chunks = Vec::new(); + + if let Some((block_index, tool_call)) = + bedrock_stream_event_to_tool_start_with_block_index(event) + { + let index = state.next_tool_index; + state.next_tool_index += 1; + state.tool_block_indexes.insert(block_index, index); + state.tool_calls.insert( + index, + (tool_call.id.clone(), tool_call.name.clone(), String::new()), + ); + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": "" + } + }] + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + if let Some(text) = bedrock_stream_event_to_text(event) { + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "content": text + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + if let Some((block_index, input_delta)) = + bedrock_stream_event_to_tool_delta_with_block_index(event) + { + if let Some(index) = state.tool_block_indexes.get(&block_index).copied() { + if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) { + args.push_str(&input_delta); + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "function": { + "arguments": input_delta + } + }] + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + } + } + + if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = event { + let stop_reason = stop.stop_reason().as_str(); + let finish_reason = match stop_reason { + "end_turn" => "stop", + "max_tokens" => "length", + "tool_use" => "tool_calls", + "stop_sequence" => "stop", + "guardrail_intervened" | "content_filtered" => "content_filter", + _ => "stop", + }; + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": finish_reason + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + chunks +} + async fn handle_bedrock_sdk_non_streaming( model: &str, body: &[u8], @@ -970,6 +992,19 @@ impl BedrockQueryBuilder { #[cfg(test)] mod tests { use super::*; + use aws_sdk_bedrockruntime::types::{ + ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStart, ContentBlockStartEvent, + ContentBlockStopEvent, ConverseStreamOutput, ToolUseBlockDelta, ToolUseBlockStart, + }; + + fn sse_json(chunk: &Bytes) -> serde_json::Value { + let chunk = std::str::from_utf8(chunk).expect("SSE chunk should be UTF-8"); + let payload = chunk + .strip_prefix("data: ") + .and_then(|chunk| chunk.strip_suffix("\n\n")) + .expect("chunk should be SSE data"); + serde_json::from_str(payload).expect("chunk should contain JSON") + } #[test] fn determine_auth_config_prioritizes_bearer_token() { @@ -1022,4 +1057,69 @@ mod tests { let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token")); assert!(matches!(config, BedrockAuthConfig::Environment)); } + + #[test] + fn bedrock_sse_tool_indexes_ignore_text_block_stops() { + let mut state = + BedrockSseStreamState::new("chatcmpl-test".to_string(), "model".to_string(), 1); + + let text_delta = ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(0) + .delta(ContentBlockDelta::Text("hello".to_string())) + .build() + .unwrap(), + ); + assert_eq!( + bedrock_sse_chunks_for_event(&text_delta, &mut state).len(), + 1 + ); + + let text_stop = ConverseStreamOutput::ContentBlockStop( + ContentBlockStopEvent::builder() + .content_block_index(0) + .build() + .unwrap(), + ); + assert!(bedrock_sse_chunks_for_event(&text_stop, &mut state).is_empty()); + + let tool_start = ConverseStreamOutput::ContentBlockStart( + ContentBlockStartEvent::builder() + .content_block_index(1) + .start(ContentBlockStart::ToolUse( + ToolUseBlockStart::builder() + .tool_use_id("call_1") + .name("lookup") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let start_chunks = bedrock_sse_chunks_for_event(&tool_start, &mut state); + let start_json = sse_json(&start_chunks[0]); + assert_eq!( + start_json["choices"][0]["delta"]["tool_calls"][0]["index"], + 0 + ); + + let tool_delta = ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(1) + .delta(ContentBlockDelta::ToolUse( + ToolUseBlockDelta::builder() + .input("{\"city\":\"Paris\"}") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let delta_chunks = bedrock_sse_chunks_for_event(&tool_delta, &mut state); + let delta_json = sse_json(&delta_chunks[0]); + assert_eq!( + delta_json["choices"][0]["delta"]["tool_calls"][0]["index"], + 0 + ); + } } diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index 57abae5182..cf00ddc142 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -691,7 +691,7 @@ impl QueryBuilder for GoogleAIQueryBuilder { #[cfg(test)] mod tests { use super::*; - use crate::{ai_providers::AIProvider, proxy::ProviderCredentials}; + use crate::{ai_providers::AIProvider, credentials::ProviderCredentials}; use std::collections::HashMap; fn credentials(base_url: &str, platform: AIPlatform) -> ProviderCredentials { diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index f3f4a37478..fb50707221 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -6,7 +6,9 @@ pub mod openai; pub mod openrouter; pub mod other; -use crate::{ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder}; +use crate::{ + ai_providers::AIProvider, credentials::ProviderCredentials, query_builder::QueryBuilder, +}; use self::{ anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder, diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index 2600999e1c..32ba35cd6d 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -4,30 +4,11 @@ use http::{HeaderMap, Method}; use serde_json::value::RawValue; use windmill_common::error::{Error, Result}; -use crate::ai_providers::{AIPlatform, AIProvider}; +use crate::ai_providers::AIProvider; +use crate::credentials::ProviderCredentials; use crate::utils::AI_HTTP_HEADERS; -/// Resolved provider credentials shared by API proxy and worker execution. -/// -/// Raw API resources and worker agent payloads convert into this shape at their -/// execution boundaries. Request-specific state such as the selected model stays -/// outside this type. -#[derive(Clone, Debug)] -pub struct ProviderCredentials { - pub provider: AIProvider, - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - pub region: Option, - pub aws_access_key_id: Option, - pub aws_secret_access_key: Option, - pub aws_session_token: Option, - pub platform: AIPlatform, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} +pub mod fim; /// Inputs needed to transform an OpenAI-compatible proxy request for a provider. pub struct ProxyBuildArgs<'a> { @@ -167,6 +148,9 @@ pub(crate) fn add_user_to_body(body: &[u8], user: &str) -> Result> { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + + use crate::ai_providers::AIPlatform; fn credentials(provider: AIProvider, base_url: &str) -> ProviderCredentials { ProviderCredentials { diff --git a/backend/windmill-ai/src/proxy/fim.rs b/backend/windmill-ai/src/proxy/fim.rs new file mode 100644 index 0000000000..2d14fd23ce --- /dev/null +++ b/backend/windmill-ai/src/proxy/fim.rs @@ -0,0 +1,120 @@ +use bytes::Bytes; +use serde::Deserialize; +use serde_json::json; +use windmill_common::error::{Error, Result}; + +use crate::ai_providers::AIProvider; + +#[derive(Debug, Eq, PartialEq)] +pub struct FimProxyTransform { + pub body: Bytes, + pub path: String, +} + +#[derive(Deserialize)] +struct FimRequest { + model: String, + prompt: String, + suffix: Option, + temperature: Option, + max_tokens: Option, + stop: Option>, +} + +pub fn supports_native_fim(provider: &AIProvider) -> bool { + matches!(provider, AIProvider::Mistral) +} + +pub fn maybe_transform_fim_request( + provider: &AIProvider, + path: &str, + body: &[u8], +) -> Result> { + if path.contains("fim/completions") && !supports_native_fim(provider) { + transform_fim_to_chat_completions(body).map(Some) + } else { + Ok(None) + } +} + +fn transform_fim_to_chat_completions(body: &[u8]) -> Result { + let fim_req: FimRequest = serde_json::from_slice(body) + .map_err(|e| Error::BadRequest(format!("Failed to parse FIM request: {}", e)))?; + + let suffix = fim_req.suffix.unwrap_or_default(); + + let system_prompt = "You are a code completion assistant. Complete the code at the position between the given prefix and suffix. Output ONLY the code that goes at the cursor - no explanations, no markdown, no repeating the prefix or suffix."; + + let user_content = format!( + "\n{}\n\n\n{}", + fim_req.prompt, suffix + ); + + let chat_req = json!({ + "model": fim_req.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content} + ], + "temperature": fim_req.temperature.unwrap_or(0.0), + "max_tokens": fim_req.max_tokens.unwrap_or(256), + "stop": fim_req.stop + }); + + let body = serde_json::to_vec(&chat_req) + .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; + + Ok(FimProxyTransform { body: Bytes::from(body), path: "chat/completions".to_string() }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mistral_keeps_native_fim_request() { + let transformed = + maybe_transform_fim_request(&AIProvider::Mistral, "fim/completions", br#"{}"#).unwrap(); + + assert!(transformed.is_none()); + assert!(supports_native_fim(&AIProvider::Mistral)); + } + + #[test] + fn openai_fim_request_is_transformed_to_chat_completion() { + let transformed = maybe_transform_fim_request( + &AIProvider::OpenAI, + "fim/completions", + br#"{ + "model": "gpt-4.1", + "prompt": "fn main() {", + "suffix": "}", + "stop": ["\n\n"] + }"#, + ) + .unwrap() + .expect("OpenAI FIM should be transformed"); + + assert_eq!(transformed.path, "chat/completions"); + + let body: serde_json::Value = serde_json::from_slice(&transformed.body).unwrap(); + assert_eq!(body["model"], "gpt-4.1"); + assert_eq!(body["temperature"], 0.0); + assert_eq!(body["max_tokens"], 256); + assert_eq!(body["stop"], serde_json::json!(["\n\n"])); + assert_eq!(body["messages"][1]["role"], "user"); + assert_eq!( + body["messages"][1]["content"], + "\nfn main() {\n\n\n}" + ); + } + + #[test] + fn invalid_fim_body_is_bad_request() { + let err = + maybe_transform_fim_request(&AIProvider::OpenAI, "fim/completions", br#"{"model": 1}"#) + .unwrap_err(); + + assert!(matches!(err, Error::BadRequest(_))); + } +} diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 1d18e2411c..56a796e41d 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -18,7 +18,7 @@ pub struct McpToolSource { use crate::{ ai_google::sanitize_schema_for_google, ai_providers::{empty_string_as_none, AIProvider}, - proxy::ProviderCredentials, + credentials::ProviderCredentials, }; use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule}; use windmill_parser::Typ; diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 6956192f04..81f601e53f 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -11,13 +11,14 @@ use http::{HeaderMap, Method}; use quick_cache::sync::Cache; use reqwest::{Client, RequestBuilder}; use serde::{Deserialize, Serialize}; -use serde_json::{json, value::RawValue}; +use serde_json::value::RawValue; use std::collections::HashMap; use std::time::Duration; use windmill_ai::ai_cache::current_instance_ai_config_revision; use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; +use windmill_ai::credentials::ProviderCredentials; #[cfg(feature = "bedrock")] use windmill_ai::providers::bedrock::{ handle_bedrock_proxy, BedrockProxyResponse, BedrockProxyResponseBody, @@ -30,10 +31,9 @@ use windmill_ai::providers::{ }, }; use windmill_ai::proxy::{ - proxy_execution_mode, supports_query_builder_proxy, ProviderCredentials, ProxyBuildArgs, - ProxyExecutionMode, ProxyRequest, + fim::maybe_transform_fim_request, proxy_execution_mode, ProxyBuildArgs, ProxyExecutionMode, + ProxyRequest, }; -use windmill_ai::utils::AI_HTTP_HEADERS; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::db::UserDB; use windmill_common::error::{to_anyhow, Error, Result}; @@ -369,53 +369,6 @@ impl AIConfig { } } -// FIM (Fill-in-the-Middle) simulation for providers that don't support native FIM -#[derive(Deserialize, Debug)] -struct FimRequest { - model: String, - prompt: String, // code before cursor - suffix: Option, // code after cursor - temperature: Option, - max_tokens: Option, - stop: Option>, -} - -/// Checks if the AI provider supports native FIM (Fill-in-the-Middle) endpoint -fn supports_native_fim(provider: &AIProvider) -> bool { - matches!(provider, AIProvider::Mistral) -} - -/// Transforms a FIM request to chat/completions format for providers that don't support native FIM. -fn transform_fim_to_chat_completions(body: &Bytes) -> Result<(Bytes, String)> { - let fim_req: FimRequest = serde_json::from_slice(body) - .map_err(|e| Error::internal_err(format!("Failed to parse FIM request: {}", e)))?; - - let suffix = fim_req.suffix.unwrap_or_default(); - - let system_prompt = "You are a code completion assistant. Complete the code at the position between the given prefix and suffix. Output ONLY the code that goes at the cursor - no explanations, no markdown, no repeating the prefix or suffix."; - - let user_content = format!( - "\n{}\n\n\n{}", - fim_req.prompt, suffix - ); - - let chat_req = json!({ - "model": fim_req.model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content} - ], - "temperature": fim_req.temperature.unwrap_or(0.0), - "max_tokens": fim_req.max_tokens.unwrap_or(256), - "stop": fim_req.stop - }); - - let chat_body = serde_json::to_vec(&chat_req) - .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; - - Ok((Bytes::from(chat_body), "chat/completions".to_string())) -} - pub fn global_service() -> Router { Router::new().route("/proxy/{*ai}", post(global_proxy).get(global_proxy)) } @@ -455,6 +408,24 @@ fn proxy_request_to_request_builder(proxy_request: ProxyRequest) -> RequestBuild request.body(proxy_request.body) } +async fn audit_global_ai_request(db: &DB, authed: &ApiAuthed) -> Result<()> { + let mut tx = db.begin().await?; + + audit_log( + &mut *tx, + authed, + "ai.global_request", + ActionKind::Execute, + "global", + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(()) +} + fn google_ai_proxy_response_to_body( response: GoogleAIProxyResponse, ) -> (http::StatusCode, HeaderMap, axum::body::Body) { @@ -530,63 +501,78 @@ async fn global_proxy( return Err(Error::BadRequest("API key is required".to_string())); }; - let base_url = provider.get_base_url(None, &db).await?; + let proxy_mode = proxy_execution_mode(&provider); - let request = if supports_query_builder_proxy(&provider) { - let credentials = ProviderCredentials { - provider: provider.clone(), - base_url, - api_key: Some(api_key.clone()), - access_token: None, - organization_id: None, - user: None, - region: None, - aws_access_key_id: None, - aws_secret_access_key: None, - aws_session_token: None, - platform: AIPlatform::Standard, - enable_1m_context: false, - custom_headers: HashMap::new(), - }; - let query_builder = create_query_builder(&credentials); - let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { + return Err(Error::BadRequest( + "AWS Bedrock global proxy is not supported; use a workspace AI resource with a region" + .to_string(), + )); + } + + let base_url = provider.get_base_url(None, &db).await?; + let credentials = ProviderCredentials { + provider: provider.clone(), + base_url, + api_key: Some(api_key.clone()), + access_token: None, + organization_id: None, + user: None, + region: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_session_token: None, + platform: AIPlatform::Standard, + enable_1m_context: false, + custom_headers: HashMap::new(), + }; + + if matches!(proxy_mode, ProxyExecutionMode::NativeGoogleAi) { + let proxy_args = ProxyBuildArgs { method: &method, path: &ai_path, headers: &headers, body: &body, credentials: &credentials, - })?; - proxy_request_to_request_builder(proxy_request) - } else { - let url = format!("{}/{}", base_url, ai_path); - let mut request = HTTP_CLIENT - .request(method, url) - .header("content-type", "application/json") - .header("Authorization", format!("Bearer {}", &api_key)); + }; - // Apply custom headers from AI_HTTP_HEADERS environment variable - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - request = request.header(header_name.as_str(), header_value.as_str()); + audit_global_ai_request(&db, &authed).await?; + + let response = match ai_path.as_str() { + "chat/completions" => handle_google_ai_chat_proxy(&HTTP_CLIENT, &proxy_args).await, + "models" => handle_google_ai_models_proxy(&HTTP_CLIENT, &proxy_args).await, + _ => Err(Error::BadRequest(format!( + "Unsupported Google AI path: {}", + ai_path + ))), + }?; + + return Ok(google_ai_proxy_response_to_body(response)); + } + + let request = match proxy_mode { + ProxyExecutionMode::HttpForward => { + let query_builder = create_query_builder(&credentials); + let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + })?; + proxy_request_to_request_builder(proxy_request) + } + ProxyExecutionMode::NativeGoogleAi | ProxyExecutionMode::NativeAwsBedrock => { + return Err(Error::BadRequest(format!( + "Unsupported global proxy mode for provider {:?}", + provider + ))) } - - request.body(body) }; let response = request.send().await.map_err(to_anyhow)?; - let mut tx = db.begin().await?; - - audit_log( - &mut *tx, - &authed, - "ai.global_request", - ActionKind::Execute, - "global", - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; + audit_global_ai_request(&db, &authed).await?; if response.error_for_status_ref().is_err() { let err_msg = response.text().await.unwrap_or("".to_string()); @@ -772,17 +758,13 @@ async fn proxy( } }; - // Check if this is a FIM request to a provider that doesn't support native FIM endpoint - // For such providers, transform to use FIM sentinel tokens with the chat/completions endpoint - let is_fim_request = ai_path.contains("fim/completions"); - if is_fim_request && !supports_native_fim(&provider) { + if let Some(fim_transform) = maybe_transform_fim_request(&provider, &ai_path, &body)? { tracing::debug!( "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", provider ); - let (chat_body, chat_path) = transform_fim_to_chat_completions(&body)?; - body = chat_body; - ai_path = chat_path; + body = fim_transform.body; + ai_path = fim_transform.path; } let proxy_mode = proxy_execution_mode(&provider); diff --git a/backend/windmill-worker/src/ai/mod.rs b/backend/windmill-worker/src/ai/mod.rs index 24e877ab13..ad0986bbea 100644 --- a/backend/windmill-worker/src/ai/mod.rs +++ b/backend/windmill-worker/src/ai/mod.rs @@ -1,6 +1,6 @@ // AI executor module structure // This module will contain all AI-related execution logic -pub mod query_builder; +pub mod stream_event_processor; pub mod tools; pub mod utils; diff --git a/backend/windmill-worker/src/ai/query_builder.rs b/backend/windmill-worker/src/ai/stream_event_processor.rs similarity index 100% rename from backend/windmill-worker/src/ai/query_builder.rs rename to backend/windmill-worker/src/ai/stream_event_processor.rs diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index fcc9cdf3c9..11100d4888 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -1,4 +1,4 @@ -use crate::ai::query_builder::StreamEventProcessor; +use crate::ai::stream_event_processor::StreamEventProcessor; use crate::ai::utils::{ add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow, is_completed_input_transform, update_flow_status_module_with_actions, diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 49c24eac2d..b2478e3be1 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -45,7 +45,7 @@ use windmill_common::{ use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob}; use crate::{ - ai::query_builder::StreamEventProcessor, + ai::stream_event_processor::StreamEventProcessor, common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier}, handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome}, }; diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md deleted file mode 100644 index 6dcd2d7605..0000000000 --- a/docs/windmill-ai-refactor-plan.md +++ /dev/null @@ -1,441 +0,0 @@ -# Refactor Plan: `windmill-ai` Crate - -## Context - -AI provider logic is currently split across three crates with duplicate code: - -- **windmill-common** — base types (`ai_types`, `ai_providers`, `ai_google`, `ai_bedrock`, `ai_cache`) -- **windmill-api** — chat proxy routes (`ai.rs`), audit logging, caching, and DB-backed credential resolution into `ProviderCredentials` -- **windmill-worker** — agent execution (`ai/` module) with `QueryBuilder` trait, SSE parsers, provider implementations - -The goal: a single `windmill-ai` crate with all AI provider logic. Worker agent execution uses `QueryBuilder`; the API proxy uses `QueryBuilder::build_proxy_request` for HTTP-forwarding providers and native proxy handlers for providers that need response conversion or SDK execution. - -## Dependency Direction - -``` -windmill-ai → windmill-common (for DB, Error, AgentAction, AuthedClient, etc.) - → windmill-types (for S3Object) - → windmill-parser (for Typ, used in OpenAPISchema) - -windmill-api → windmill-ai -windmill-worker → windmill-ai -``` - -windmill-common does **NOT** re-export from windmill-ai (would be circular). All consumers update imports. - -## Reviewer Note: Keep API Proxy Unification Split - -The crate boundary, shared utilities, SSE parsers, image handling, worker provider implementations, provider-specific API proxy transformations, and resolved runtime credential shape are now in `windmill-ai`. Raw API resources and worker agent provider payloads remain separate input/deserialization shapes and convert into `ProviderCredentials` at execution boundaries. - -Do not jump directly from the current state to full proxy and credential unification in one PR. The API proxy combines request transformation, endpoint selection, auth headers, custom headers, OAuth user injection, Azure URL handling, Anthropic Vertex handling, Bedrock SDK calls, and SSE keepalive behavior. Split the work by risk: -- Introduce shared proxy request and credential types first. -- Move the OpenAI-compatible proxy path into `windmill-ai` next, while keeping provider-native behavior unchanged. -- Move Anthropic/Vertex, Google AI, and Bedrock in separate follow-up PRs. -- Unify credential resolution only after all proxy request builders use the shared shape. - -Avoid adding modules whose only purpose is to re-export moved code. Direct imports from `windmill_ai` make ownership and dependency direction clearer at each call site. - -Also do not make `build_proxy_request(raw_body, path)` too narrow. The proxy path needs method, incoming headers, resolved credentials, base URL/platform, organization/user fields, custom headers, and Bedrock/Azure/Vertex-specific context. Introduce a structured `ProxyBuildArgs`/`ProviderCredentials` shape before deleting `AIRequestConfig::prepare_request`, `google.rs`, or `bedrock.rs`. - -## Completed Phase: Proxy Contract + OpenAI-Compatible Proxy ✅ - -Goal: introduce the shared API proxy contract in `windmill-ai` and move the OpenAI-compatible proxy request builder there without changing provider behavior. - -Suggested PR title: `refactor(ai): move openai-compatible proxy building to windmill-ai`. - -Scope: -- Add `windmill-ai/src/proxy.rs` and export it from `lib.rs`. -- Define `ProviderCredentials`, `ProxyBuildArgs`, and `ProxyRequest`. -- Include all context known to be needed by the current API proxy path: method, path, incoming headers, body, provider, base URL, API key, OAuth access token, organization/user fields, platform, 1M context flag, custom headers, region, and AWS credentials. -- Add a conversion from API-side `AIRequestConfig` to `ProviderCredentials`. -- Add `QueryBuilder::build_proxy_request` with a default unsupported-provider implementation. -- Implement `build_proxy_request` for OpenAI-compatible providers (`OpenAI`, `AzureOpenAI`, `Mistral`, `DeepSeek`, `Groq`, `OpenRouter`, `TogetherAI`, `CustomAI`). -- Route workspace and global API proxy requests for OpenAI-compatible providers through `windmill-ai`. -- Keep FIM transformation in `windmill-api` before calling the proxy builder. -- Keep `AIRequestConfig::prepare_request` for Anthropic/Vertex and remaining fallback paths. - -Out of scope: -- Do not move Anthropic/Vertex proxy behavior yet. -- Do not move Google AI or Bedrock proxy behavior yet. -- Do not change credential resolution, audit logging, cache behavior, SSE keepalive behavior, or Bedrock/Google special cases. -- Do not remove `windmill-api/src/google.rs`, `windmill-api/src/bedrock.rs`, or `AIRequestConfig::prepare_request`. - -Validation: -- `cargo test -p windmill-ai proxy` -- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace` -- `cargo check -p windmill-ai -p windmill-api` -- `cargo check -p windmill-ai -p windmill-api --features bedrock` - -Follow-up status: Anthropic/Vertex proxy handling has since moved into -`windmill-ai`, and the dead `AIRequestConfig::prepare_request` fallback has -been removed. - -## Completed Phase: Proxy Execution Mode + Google AI Proxy Migration ✅ - -Goal: introduce a shared provider execution classifier before moving Google AI -and Bedrock. `ProxyRequest` is a good contract for HTTP-forwarding providers -such as OpenAI-compatible providers and Anthropic, but Google AI also converts -responses back to OpenAI shape and Bedrock uses SDK execution. Model that split -explicitly before moving those providers, then move the Google AI proxy -transformation into `windmill-ai` as the first native-provider migration. - -Suggested PR title: `refactor(ai): add provider proxy execution mode`. - -Scope: -- Add `ProxyExecutionMode` in `windmill-ai::proxy`. -- Classify providers as HTTP-forwarding, native Google AI, or native Bedrock. -- Make `supports_query_builder_proxy` derive from the shared execution mode. -- Use the shared execution mode in `windmill-api/src/ai.rs` for workspace proxy routing. -- Move Google AI workspace proxy request conversion, streaming/non-streaming response conversion, and model-list normalization into `windmill-ai::providers::google_ai`. -- Share Google AI `GeminiTextRequest` and generation-config construction between worker agent requests and API proxy requests. -- Delete the API-local `windmill-api/src/google.rs` module. -- Keep global proxy behavior, Bedrock native handling, credential resolution, audit logging, caching, and SSE keepalive behavior unchanged. - -Out of scope: -- Do not move `windmill-api/src/bedrock.rs`. -- Do not unify `AIRequestConfig` and `ProviderWithResource`. - -Validation: -- `cargo test -p windmill-ai google_ai` -- `cargo test -p windmill-ai proxy` -- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace` -- `cargo test -p windmill-ai anthropic` - -Follow-up status: Bedrock native proxy handling has since moved into -`windmill-ai`, and the API-local `windmill-api/src/bedrock.rs` module has been -removed. - -## Completed Phase: Bedrock Native Proxy Migration ✅ - -Goal: move the remaining native-provider API proxy execution out of -`windmill-api` and into `windmill-ai`, while leaving API-owned routing, -credential resolution, auditing, cache behavior, and Axum response conversion in -`windmill-api`. - -Suggested PR title: `refactor(ai): move bedrock proxy handling to windmill-ai`. - -Scope: -- Move Bedrock control-plane proxy calls (`foundation-models`, - `inference-profiles`) into `windmill-ai::providers::bedrock`. -- Move Bedrock chat proxy OpenAI request parsing, Converse request execution, - streaming SSE conversion, non-streaming OpenAI-shaped response conversion, and - auth selection into `windmill-ai::providers::bedrock`. -- Add an Axum-free `BedrockProxyResponse` shape in `windmill-ai`; the API route - converts it into an Axum body. -- Move the optional `aws-sdk-bedrock` dependency from `windmill-api` to - `windmill-ai`. -- Delete the API-local `windmill-api/src/bedrock.rs` module. - -Out of scope: -- Do not unify `AIRequestConfig` and `ProviderWithResource`. -- Do not change Bedrock credential resolution, audit logging, request caching, - or non-Bedrock proxy behavior. - -Validation: -- `cargo test -p windmill-ai bedrock --features bedrock` -- `cargo check -p windmill-ai -p windmill-api` -- `cargo check -p windmill-ai -p windmill-api --features bedrock` - -## Known Follow-Ups - -These are not blockers for the current migration PR because they either preserve -existing behavior or need a separate product decision, but they should stay -visible for later hardening work. - -- **Google AI/Gemini native proxy custom headers**: the native Google AI proxy - path intentionally does not apply `AI_HTTP_HEADERS` or resource-level custom - headers today. Decide whether and how env/resource custom-header injection - should apply to Google AI once the proxy behavior is unified further. -- **Bedrock SSE tool-call indexing**: Bedrock streaming currently increments - the OpenAI tool-call index on every Bedrock `ContentBlockStop`, including text - content blocks. This behavior existed before the move from `windmill-api` to - `windmill-ai`, but a later cleanup should advance the index only when the - stopped block was a tool-use block. -- **Bedrock SSE keepalives**: Bedrock native SSE streams are still returned - directly without the API proxy keepalive injection used by other SSE paths. - This also preserves the pre-move behavior. A later cleanup can generalize the - keepalive wrapper so it works for both `reqwest::Error` streams and Bedrock's - SDK-backed `std::io::Error` streams. - -## Completed Phase: Credential Unification Phase 1 ✅ - -Goal: make `ProviderCredentials` the shared resolved runtime credential shape -without overloading it with raw resource input or model-selection state. - -`AIRequestConfig` and `ProviderWithResource` are not equivalent concepts: -`AIRequestConfig` is API-side resolved state after DB, variable, OAuth, and -resource handling, while `ProviderWithResource` is worker-side raw agent input -that also carries the selected model. Keep raw/deserialization types separate and -convert them into `ProviderCredentials` at execution boundaries. - -Suggested PR title: `refactor(ai): use provider credentials for worker builders`. - -Scope: -- Add a worker-side conversion from `ProviderWithResource` to - `ProviderCredentials`. -- Keep `model` outside `ProviderCredentials`; it remains agent request data. -- Keep `ProviderWithResource` as the backward-compatible deserialization type for - existing agent payloads. -- Use `ProviderCredentials` for worker query-builder creation. -- Collapse `create_query_builder` and `create_proxy_query_builder` into one - `create_query_builder(&ProviderCredentials)` factory. - -Out of scope: -- Do not remove API-local `AIRequestConfig` yet. -- Do not change API request-cache behavior. -- Do not change worker agent payload shape or serialized field names. - -Validation: -- `cargo check -p windmill-ai -p windmill-api -p windmill-worker` -- `cargo check -p windmill-ai -p windmill-api -p windmill-worker --features bedrock` - -## Completed Phase: Credential Unification Phase 2 ✅ - -Goal: remove the API-local resolved credential wrapper after worker execution -already uses the shared shape. - -Suggested PR title: `refactor(ai): resolve api proxy credentials directly`. - -Scope: -- Change API credential resolution to return `ProviderCredentials` directly. -- Replace `ExpiringAIRequestConfig` with an expiring `ProviderCredentials` - cache entry. -- Remove `AIRequestConfig::into_provider_credentials`. -- Delete `AIRequestConfig` entirely if no API-only behavior remains. - -Out of scope: -- Do not merge raw worker resource input into `ProviderCredentials`. -- Do not put model selection into `ProviderCredentials`. - -Validation: -- `cargo check -p windmill-ai -p windmill-api -p windmill-worker` -- `cargo check -p windmill-ai -p windmill-api -p windmill-worker --features bedrock` -- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace` - -## Step-by-Step Plan - -Each step produces a compiling, working backend. - ---- - -### Step 1: Create `windmill-ai` crate, move base types from windmill-common ✅ - -Create `backend/windmill-ai/Cargo.toml` and `backend/windmill-ai/src/lib.rs`. - -Move from `windmill-common/src/` to `windmill-ai/src/`: -- `ai_types.rs` — OpenAI-compatible message types -- `ai_providers.rs` — `AIProvider` enum, `AIPlatform`, base URLs, `ProviderConfig` -- `ai_google.rs` — Gemini types and OpenAI↔Gemini conversion -- `ai_bedrock.rs` — Bedrock SDK wrapper (feature-gated on `bedrock`) -- `ai_cache.rs` — instance AI config revision tracking - -Update all imports (`windmill_common::ai_*` → `windmill_ai::ai_*`). - ---- - -### Step 2: Move worker AI types to windmill-ai ✅ - -Move from `windmill-worker/src/ai/types.rs` to `windmill-ai/src/types.rs`: -- `ProviderWithResource`, `ProviderResource` — credential types -- `TokenUsage` — token usage tracking -- `OutputType`, `SchemaType`, `AdditionalProperties` — output configuration -- `OpenAPISchema` — tool parameter schema (depends on `windmill-parser::Typ`) -- `Tool`, `Message`, `ResponseFormat`, `JsonSchemaFormat` — agent types -- `StreamingEvent` — SSE event enum -- `AIAgentArgs`, `AIAgentArgsRaw`, `AIAgentResult` — agent job args -- `Memory` — agent memory enum -- `S3ObjectWithType` — S3 image type -- `McpToolSource` stub (with same `#[cfg(feature = "mcp")]` pattern) - -Worker `ai/types.rs` becomes a re-export: `pub use windmill_ai::types::*`. - ---- - -### Step 3: Move QueryBuilder trait, ParsedResponse, and StreamEventSink abstraction to windmill-ai ✅ - -Move from `windmill-worker/src/ai/query_builder.rs` to `windmill-ai/src/query_builder.rs`: -- `BuildRequestArgs` struct -- `ParsedResponse` enum -- `QueryBuilder` trait (with all existing methods) - -New `StreamEventSink` trait in windmill-ai: -```rust -#[async_trait] -pub trait StreamEventSink: Send + Sync { - async fn send(&self, event: StreamingEvent, events_str: &mut String) -> Result<(), Error>; -} -``` - -`StreamEventSink` abstracts the worker's `StreamEventProcessor` so windmill-ai doesn't depend on windmill-queue or the worker's job logger. The worker's `StreamEventProcessor` implements `StreamEventSink`. All provider `parse_streaming_response` methods and SSE parsers accept `Box`. - ---- - -### Step 4: Move SSE parsers to windmill-ai ✅ - -Move from `windmill-worker/src/ai/sse.rs` to `windmill-ai/src/sse.rs`: -- `SSEParser` trait -- `OpenAISSEParser`, `AnthropicSSEParser`, `GeminiSSEParser`, `OpenAIResponsesSSEParser` -- All associated types (delta types, usage types, etc.) - ---- - -### Step 5: Move provider implementations to windmill-ai ✅ - -Move from `windmill-worker/src/ai/providers/` to `windmill-ai/src/providers/`: -- `anthropic.rs` — `AnthropicQueryBuilder` -- `openai.rs` — `OpenAIQueryBuilder` -- `google_ai.rs` — `GoogleAIQueryBuilder` -- `bedrock.rs` — `BedrockQueryBuilder` (feature-gated) -- `other.rs` — `OtherQueryBuilder` (Mistral, DeepSeek, Groq, TogetherAI, CustomAI) -- `openrouter.rs` — `OpenRouterQueryBuilder` -- `mod.rs` with `create_query_builder` factory - -Move utility functions providers depend on: -- `should_use_structured_output_tool` (from `utils.rs`) -- `extract_text_content` (from `utils.rs`) - ---- - -### Step 6: Move image_handler to windmill-ai ✅ - -Move from `windmill-worker/src/ai/image_handler.rs` to `windmill-ai/src/image_handler.rs`: -- `download_and_encode_s3_image` — no signature change needed -- `prepare_messages_for_api` — no signature change needed -- `upload_image_to_s3` — **refactor**: `(base64_image, workspace_id, job_id, client)` instead of `(base64_image, &MiniPulledJob, client)` to remove windmill-queue dependency - ---- - -### Step 7: Move shared utilities to windmill-ai ✅ - -Move `AI_HTTP_HEADERS` lazy_static (currently duplicated in `windmill-api/src/ai.rs` and `windmill-worker/src/ai_executor.rs`) to `windmill_ai::utils`. Both consumers import from windmill-ai. - ---- - -### Step 8: Add API proxy execution support to windmill-ai ✅ - -This is the key proxy unification step. HTTP-forwarding providers use -`QueryBuilder::build_proxy_request`: - -```rust -/// Build a request from a raw OpenAI-format proxy request. -/// Used by the API chat proxy. Handles format conversion for non-OpenAI providers. -fn build_proxy_request( - &self, - args: &ProxyBuildArgs<'_>, -) -> Result; -``` - -Where `ProxyBuildArgs` carries the API proxy context that provider implementations need: -```rust -pub struct ProxyBuildArgs<'a> { - pub method: &'a http::Method, - pub path: &'a str, - pub headers: &'a http::HeaderMap, - pub body: &'a [u8], - pub credentials: &'a ProviderCredentials, -} -``` - -And `ProxyRequest` contains the transformed request: -```rust -pub struct ProxyRequest { - pub method: http::Method, - pub url: String, - pub headers: Vec<(String, String)>, - pub body: Vec, -} -``` - -**Provider implementations:** -- **OpenAI-compatible** (OpenAI, Mistral, DeepSeek, Groq, TogetherAI, CustomAI, OpenRouter): Minimal transformation — pass body through, build URL and auth headers. -- **Anthropic**: Handle standard vs Vertex AI. For Vertex: transform body (extract model, add anthropic_version). For standard: pass through with appropriate headers. -- **Google AI**: Native execution mode converts OpenAI format → Gemini format and Gemini responses → OpenAI shape. Replaces `windmill-api/src/google.rs`. -- **Bedrock**: Native execution mode converts OpenAI format → Bedrock SDK calls and SDK responses → OpenAI shape. Replaces `windmill-api/src/bedrock.rs`. - -**Refactor API proxy** (`windmill-api/src/ai.rs`): -1. Parse provider from headers, resolve credentials → `ProviderCredentials` -2. Create `QueryBuilder` via `create_query_builder` -3. Dispatch by `ProxyExecutionMode`: - - HTTP-forwarding providers call `query_builder.build_proxy_request(&proxy_args)` → `ProxyRequest` - - Google AI and Bedrock call native handlers in `windmill-ai` -4. Convert the provider response to the API response body - -**Remove** from windmill-api: -- `AIRequestConfig::prepare_request` — replaced by `QueryBuilder::build_proxy_request` -- `google.rs` — replaced by `windmill_ai::providers::google_ai` native proxy handlers -- `bedrock.rs` — replaced by `windmill_ai::providers::bedrock` native proxy handlers -- `transform_anthropic_for_vertex` — moved to `AnthropicQueryBuilder` -- `supports_native_fim`, `transform_fim_to_chat_completions` — moved to windmill-ai - -**Keep** in API: -- credential resolution from DB, workspace settings, instance settings, variables, and OAuth into `ProviderCredentials` -- HTTP routes, audit logging, request caching -- `inject_keepalives`, `is_sse_response` helpers -- `AIConfig`, `ExpiringProviderCredentials` caching types - ---- - -### Step 9: Unify credential resolution - -Make `ProviderCredentials` the single resolved runtime credential shape in -windmill-ai, while keeping raw API and worker input/deserialization types at -their boundaries. - -The API's `resolve_provider_credentials` resolves credentials from DB, workspace -or instance settings, variables, and OAuth. The worker's `ProviderWithResource` -gets raw credentials from the flow module definition and also carries the -selected model. Convert both paths into `ProviderCredentials`; do not make -`ProviderCredentials` carry raw resource state or the model. - -Extend `windmill_ai::proxy::ProviderCredentials` as needed so both can produce it: -```rust -pub struct ProviderCredentials { - pub provider: AIProvider, - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - pub platform: AIPlatform, - pub region: Option, - pub aws_access_key_id: Option, - pub aws_secret_access_key: Option, - pub aws_session_token: Option, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} -``` - -The `create_query_builder` factory takes `&ProviderCredentials` instead of `&ProviderWithResource`. - ---- - -## Final Crate Structure - -``` -windmill-ai/src/ -├── lib.rs # module exports -├── ai_types.rs # OpenAI-compatible message types -├── ai_providers.rs # AIProvider enum, base URLs, config -├── ai_google.rs # Gemini types and conversions -├── ai_bedrock.rs # Bedrock SDK wrapper (feature: bedrock) -├── ai_cache.rs # Instance AI config revision -├── types.rs # TokenUsage, Tool, OpenAPISchema, etc. -├── proxy.rs # ProviderCredentials, ProxyBuildArgs, ProxyRequest -├── query_builder.rs # QueryBuilder trait, BuildRequestArgs, ParsedResponse, StreamEventSink -├── sse.rs # SSE parsers (OpenAI, Anthropic, Gemini, Responses) -├── image_handler.rs # S3 image upload/download -├── utils.rs # extract_text_content, should_use_structured_output_tool -└── providers/ - ├── mod.rs # create_query_builder factory - ├── anthropic.rs # build_request + build_proxy_request - ├── openai.rs # build_request + build_proxy_request - ├── google_ai.rs # build_request + native proxy handlers - ├── bedrock.rs # build_request + native proxy handlers (feature: bedrock) - ├── other.rs # build_request + build_proxy_request - └── openrouter.rs # build_request + build_proxy_request -``` - -**windmill-worker** keeps: `ai_executor.rs`, `ai/tools.rs`, `ai/utils.rs` (flow/conversation/MCP logic), `StreamEventProcessor` (impl of `StreamEventSink`). - -**windmill-api** keeps: HTTP routes (`ai.rs` proxy endpoints), audit logging, caching, credential resolution from DB. `google.rs` and `bedrock.rs` deleted. From 2fdc51e62985fc755884436130bdd58e294247c8 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 18:13:38 +0200 Subject: [PATCH 13/52] fix(git-sync): publish fork branch on only_create_branch from the CLI (#9366) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [ee] fix(git-sync): publish fork branch on only_create_branch from the CLI Fixes WIN-1997. Forking a git-sync-configured workspace must push a `wm-fork//` branch to the repo, but the integration test `test_workspace_fork_creates_branch` failed: the fork callback job succeeded yet no branch appeared. Root cause: the fork-branch callback runs the sync script with `only_create_branch: true` and no items. The hub sync script delegates branch checkout to `wmill sync git-deploy --only-create-branch` and runs its own in-process commit+push ONLY for the `!only_create_branch` path (`if (!only_create_branch) git_push(...)`). #9284 had moved commit+push out of the CLI to the caller for the GPG-cache-warmth invariant (WIN-1974) — but it also dropped the CLI's push for the branch-only case. A branch-only publish has no commit, so no signing is involved and the GPG concern does not apply; with neither the CLI nor the hub script pushing, the empty fork branch was never published. Restore the CLI push for the `only_create_branch` path (a bare `git push --porcelain` of the checked-out branch ref). Adds a deterministic CLI regression test that runs `git-deploy --only-create-branch` for a fork workspace and asserts the branch reaches the remote with no caller-side push. EE companion: format the fork-branch commit message with Display instead of Debug (no more `Some("...")` leak). Co-Authored-By: Claude Opus 4.7 (1M context) * chore: update ee-repo-ref to a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd This commit updates the EE repository reference after PR #597 was merged in windmill-ee-private. Previous ee-repo-ref: 8b02336fcebdfae4b9d2795cbb74fa7046530bcb New ee-repo-ref: a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: windmill-internal-app[bot] --- backend/ee-repo-ref.txt | 2 +- cli/src/commands/sync/sync.ts | 16 ++++++- cli/test/gitsync_promotion.test.ts | 71 ++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 80a746c1c0..f457a34fb7 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -55c19293232be379a3044eb78f677b545882ffd6 \ No newline at end of file +a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 22cdd5900b..2632f6c80c 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -2504,8 +2504,20 @@ export async function pull( } if (opts.onlyCreateBranch) { - // Branch is checked out locally; the caller pushes it. Symmetric with - // the non-onlyCreateBranch path: CLI does branch + pull, never push. + // Branch-only publish: there is no commit here, so the GPG-cache-warmth + // invariant that motivated moving commit+push to the hub script (WIN-1974, + // #9284) does not apply — a bare `git push` of the (empty) branch ref needs + // no signing. The hub script only runs its in-process commit+push for the + // non-onlyCreateBranch path (`if (!only_create_branch) git_push(...)`), so + // the CLI MUST publish the fork branch here or it is never pushed at all. + gitSyncDeployPush({ + items: deployItems, + authorName: process.env["WM_USERNAME"] || "windmill", + authorEmail: process.env["WM_EMAIL"] || "windmill@windmill.dev", + committerName: opts.gitCommitterName, + committerEmail: opts.gitCommitterEmail, + onlyCreateBranch: true, + }); return; } } diff --git a/cli/test/gitsync_promotion.test.ts b/cli/test/gitsync_promotion.test.ts index 5b73e8f8e1..709ae11fc2 100644 --- a/cli/test/gitsync_promotion.test.ts +++ b/cli/test/gitsync_promotion.test.ts @@ -200,3 +200,74 @@ test.skipIf(shouldSkipOnCI())( }); }, ); + +/** + * Regression test for WIN-1997: forking a workspace with git sync configured + * must publish a `wm-fork//` branch to the remote. + * + * The fork-branch callback runs the sync script with `only_create_branch: + * true` and no items. The hub script delegates branch checkout + push of that + * empty ref to `wmill sync git-deploy --only-create-branch` — its own + * in-process commit+push runs ONLY for the `!only_create_branch` path. So if + * the CLI doesn't push the freshly checked-out branch here, nothing does and + * the fork branch never reaches the remote (the symptom that broke the e2e + * test after #9284 moved commit+push to the caller). This guards that the CLI + * owns the push for the branch-only case. + */ +test.skipIf(shouldSkipOnCI())( + "git-sync fork: only_create_branch publishes the wm-fork branch (CLI owns the push)", + async () => { + await withTestBackend(async (backend) => { + // Bare "remote" seeded with an initial `main` commit. + const bareDir = await mkdtemp(join(tmpdir(), "wmill_fork_bare_")); + execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]); + const seedDir = await mkdtemp(join(tmpdir(), "wmill_fork_seed_")); + git(seedDir, "init", "--initial-branch=main"); + git(seedDir, "config", "user.email", "seed@windmill.dev"); + git(seedDir, "config", "user.name", "seed"); + await writeFile(join(seedDir, "README.md"), "# fork test\n"); + git(seedDir, "add", "-A"); + git(seedDir, "commit", "-m", "seed"); + git(seedDir, "remote", "add", "origin", `file://${bareDir}`); + git(seedDir, "push", "-u", "origin", "main"); + const seedMain = remoteHead(bareDir, "main"); + + // The CWD the hub script runs git-deploy in: a clone of the repo on main. + const work = await mkdtemp(join(tmpdir(), "wmill_fork_work_")); + git(work, "clone", `file://${bareDir}`, "."); + await writeFile( + join(work, "wmill.yaml"), + "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\n", + ); + + // Branch creation happens BEFORE the fork workspace exists (step 1 of the + // fork flow), so we pass the fork workspace id straight through — whoami + // returns synthetic superadmin info for it. No items, only_create_branch. + const forkWs = "wm-fork-clitest"; + const res = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/unused_on_branch_only_path", + "--git-deploy-items", + "[]", + "--only-create-branch", + ], + work, + { workspace: forkWs }, + ); + expect(res.code).toBe(0); + + // The regression: with NO caller-side commit/push, the fork branch must + // already be on the remote because the CLI pushed it. + expect(remoteBranches(bareDir)).toContain("refs/heads/wm-fork/main/clitest"); + // Base branch untouched — branch-only publish creates no commit. + expect(remoteHead(bareDir, "main")).toBe(seedMain); + + await rm(bareDir, { recursive: true, force: true }); + await rm(seedDir, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + }); + }, +); From 9a659b636d713ee8fdfbdad41c58bb3d7c79e0d9 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 18:16:12 +0200 Subject: [PATCH 14/52] fix(frontend): prevent duplicate asset node ids crashing flow graph (#9367) --- .../graph/renderers/nodes/AssetNode.svelte | 86 +++++++++---------- .../graph/renderers/nodes/assetNode.test.ts | 51 +++++++++++ 2 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte index 50e4f9c87a..0d5a9a14ea 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte @@ -63,7 +63,7 @@ type: 'asset' as const, parentId: node.id, data: { asset, displayedAccessType: 'r' }, - id: `${node.id}-asset-in-${asset.kind}-${asset.path}`, + id: `${node.id}-asset-in-${asset.kind}-${asset.path}-${i}`, width: inputAssetWidth, position: { x: @@ -100,7 +100,7 @@ type: 'asset' as const, parentId: node.id, data: { asset, displayedAccessType: 'w' }, - id: `${node.id}-asset-out-${asset.kind}-${asset.path}`, + id: `${node.id}-asset-out-${asset.kind}-${asset.path}-${i}`, width: outputAssetWidth, position: { x: @@ -136,7 +136,7 @@ allAssetNodes.push(...(inputAssetNodes ?? []), ...(outputAssetNodes ?? [])) // If there are more than 3 assets, we create an overflow node - if (overflowedInputAssets.length) + if (overflowedInputAssets.length) { allAssetNodes.push({ type: 'assetsOverflowed', data: { overflowedAssets: overflowedInputAssets, displayedAccessType: 'r' }, @@ -148,14 +148,15 @@ y: READ_ASSET_Y_OFFSET } } satisfies Node & AssetsOverflowedN) - allAssetEdges.push({ - id: `${node.id}-assets-overflowed-in-edge`, - source: `${node.id}-assets-overflowed-in`, - target: node.id, - type: 'empty', - data: { class: '!opacity-35 dark:!opacity-20' } - }) - if (overflowedOutputAssets.length) + allAssetEdges.push({ + id: `${node.id}-assets-overflowed-in-edge`, + source: `${node.id}-assets-overflowed-in`, + target: node.id, + type: 'empty', + data: { class: '!opacity-35 dark:!opacity-20' } + }) + } + if (overflowedOutputAssets.length) { allAssetNodes.push({ type: 'assetsOverflowed', data: { overflowedAssets: overflowedOutputAssets, displayedAccessType: 'w' }, @@ -167,13 +168,14 @@ y: WRITE_ASSET_Y_OFFSET } } satisfies Node & AssetsOverflowedN) - allAssetEdges.push({ - id: `${node.id}-assets-overflowed-out-edge`, - source: node.id, - target: `${node.id}-assets-overflowed-out`, - type: 'empty', - data: { class: '!opacity-35 dark:!opacity-25' } - }) + allAssetEdges.push({ + id: `${node.id}-assets-overflowed-out-edge`, + source: node.id, + target: `${node.id}-assets-overflowed-out`, + type: 'empty', + data: { class: '!opacity-35 dark:!opacity-25' } + }) + } } let ret: ReturnType = { @@ -274,8 +276,8 @@ {#snippet text()} - Could not find resource - {/snippet} + Could not find resource + {/snippet} {:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !$userStore?.operator}
@@ -291,29 +293,27 @@ {/if}
{#snippet text()} - - {#if usageCount !== undefined} - Used in {pluralize(usageCount, 'step')}
- {/if} - { - if (data.asset.kind === 'resource') - flowGraphAssetsCtx?.val.resourceEditorDrawer?.initEdit(data.asset.path) - }} - > - {data.asset.path} -
- - {formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })} - - - {/snippet} + {#if usageCount !== undefined} + Used in {pluralize(usageCount, 'step')}
+ {/if} + { + if (data.asset.kind === 'resource') + flowGraphAssetsCtx?.val.resourceEditorDrawer?.initEdit(data.asset.path) + }} + > + {data.asset.path} +
+ + {formatAssetKind({ ...data.asset, metadata: cachedResourceMetadata })} + + {/snippet} {/snippet} diff --git a/frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts b/frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts new file mode 100644 index 0000000000..14fd5e24e3 --- /dev/null +++ b/frontend/src/lib/components/graph/renderers/nodes/assetNode.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, vi } from 'vitest' + +// Mock heavy transitive imports pulled in by AssetNode.svelte's instance script +vi.mock('monaco-editor', () => ({})) +vi.mock('$lib/components/meltComponents', () => ({ Tooltip: {} })) +vi.mock('../../../ExploreAssetButton.svelte', () => ({ + default: {}, + assetCanBeExplored: () => false +})) +vi.mock('$lib/components/icons/AssetGenericIcon.svelte', () => ({ default: {} })) +vi.mock('$lib/components/assets/AssetColumnBadges.svelte', () => ({ default: {} })) +vi.mock('./NodeWrapper.svelte', () => ({ default: {} })) + +import { computeAssetNodes } from './AssetNode.svelte' + +function nodeWithAssets(id: string, assets: any[]) { + return { id, position: { x: 0, y: 0 }, data: { assets } } +} + +describe('computeAssetNodes (WIN-1998)', () => { + it('produces unique node and edge ids when a module lists the same asset twice', () => { + // Two assets with identical kind+path (e.g. read twice, or r + rw) — both + // display as inputs. Before the fix these collided on the same node id and + // crashed SvelteFlow with `each_key_duplicate`. + const dup = { kind: 'resource', path: 'f/foo/bar', access_type: 'r' } + const { newAssetNodes, newAssetEdges } = computeAssetNodes([ + nodeWithAssets('moduleA', [{ ...dup }, { ...dup }]) + ]) + + const nodeIds = newAssetNodes.map((n) => n.id) + expect(new Set(nodeIds).size).toBe(nodeIds.length) + + const edgeIds = newAssetEdges.map((e) => e.id) + expect(new Set(edgeIds).size).toBe(edgeIds.length) + }) + + it('does not emit overflow edges when there is no overflow node (<=3 assets)', () => { + const { newAssetNodes, newAssetEdges } = computeAssetNodes([ + nodeWithAssets('moduleB', [{ kind: 'resource', path: 'f/a/x', access_type: 'r' }]) + ]) + + // No overflow node should be created for a single asset... + expect(newAssetNodes.some((n) => n.type === 'assetsOverflowed')).toBe(false) + // ...and therefore no dangling edge should reference a missing overflow node. + const nodeIdSet = new Set(newAssetNodes.map((n) => n.id).concat('moduleB')) + for (const e of newAssetEdges) { + expect(nodeIdSet.has(e.source as string)).toBe(true) + expect(nodeIdSet.has(e.target as string)).toBe(true) + } + }) +}) From 2553fbfe31417bd985e7994eac695bf918f97ce2 Mon Sep 17 00:00:00 2001 From: centdix <40307056+centdix@users.noreply.github.com> Date: Thu, 28 May 2026 18:52:26 +0200 Subject: [PATCH 15/52] feat: add deepseek fim support (#9365) --- backend/windmill-ai/src/ai_providers.rs | 3 +- backend/windmill-ai/src/proxy/fim.rs | 116 ++++++++++++++++-- backend/windmill-api/src/ai.rs | 24 +++- .../copilot/autocomplete/request.ts | 4 +- frontend/src/lib/components/copilot/fim.ts | 39 ++++++ .../src/lib/components/copilot/lib.test.ts | 44 +++++++ frontend/src/lib/components/copilot/lib.ts | 23 +--- frontend/src/lib/components/copilot/utils.ts | 5 +- .../workspaceSettings/AISettings.svelte | 5 +- 9 files changed, 219 insertions(+), 44 deletions(-) create mode 100644 frontend/src/lib/components/copilot/fim.ts diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index b568c0accb..fb29454ca5 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -27,6 +27,7 @@ lazy_static::lazy_static! { } pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; pub const GOOGLE_AI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; /// Empty string signals BedrockClient::from_env() to use the region from AWS environment/config @@ -106,7 +107,7 @@ impl AIProvider { Ok(azure_base_path.unwrap_or("https://api.openai.com/v1".to_string())) } - AIProvider::DeepSeek => Ok("https://api.deepseek.com/v1".to_string()), + AIProvider::DeepSeek => Ok(DEEPSEEK_BASE_URL.to_string()), AIProvider::GoogleAI => Ok(GOOGLE_AI_BASE_URL.to_string()), AIProvider::Groq => Ok("https://api.groq.com/openai/v1".to_string()), AIProvider::OpenRouter => Ok("https://openrouter.ai/api/v1".to_string()), diff --git a/backend/windmill-ai/src/proxy/fim.rs b/backend/windmill-ai/src/proxy/fim.rs index 2d14fd23ce..3645476441 100644 --- a/backend/windmill-ai/src/proxy/fim.rs +++ b/backend/windmill-ai/src/proxy/fim.rs @@ -3,12 +3,13 @@ use serde::Deserialize; use serde_json::json; use windmill_common::error::{Error, Result}; -use crate::ai_providers::AIProvider; +use crate::ai_providers::{AIProvider, DEEPSEEK_BASE_URL}; #[derive(Debug, Eq, PartialEq)] pub struct FimProxyTransform { pub body: Bytes, pub path: String, + pub base_url: Option, } #[derive(Deserialize)] @@ -22,19 +23,49 @@ struct FimRequest { } pub fn supports_native_fim(provider: &AIProvider) -> bool { - matches!(provider, AIProvider::Mistral) + matches!(provider, AIProvider::Mistral | AIProvider::DeepSeek) +} + +fn deepseek_fim_base_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + let deepseek_root_base_url = DEEPSEEK_BASE_URL + .strip_suffix("/v1") + .unwrap_or(DEEPSEEK_BASE_URL); + + if trimmed == DEEPSEEK_BASE_URL || trimmed == deepseek_root_base_url { + return format!("{deepseek_root_base_url}/beta"); + } + + if let Some(prefix) = trimmed.strip_suffix("/v1") { + return format!("{prefix}/beta"); + } + + trimmed.to_string() } pub fn maybe_transform_fim_request( provider: &AIProvider, path: &str, + base_url: &str, body: &[u8], ) -> Result> { - if path.contains("fim/completions") && !supports_native_fim(provider) { - transform_fim_to_chat_completions(body).map(Some) - } else { - Ok(None) + if !path.contains("fim/completions") { + return Ok(None); } + + if matches!(provider, AIProvider::DeepSeek) { + return Ok(Some(FimProxyTransform { + body: Bytes::copy_from_slice(body), + path: "completions".to_string(), + base_url: Some(deepseek_fim_base_url(base_url)), + })); + } + + if !supports_native_fim(provider) { + return transform_fim_to_chat_completions(body).map(Some); + } + + Ok(None) } fn transform_fim_to_chat_completions(body: &[u8]) -> Result { @@ -64,7 +95,11 @@ fn transform_fim_to_chat_completions(body: &[u8]) -> Result { let body = serde_json::to_vec(&chat_req) .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; - Ok(FimProxyTransform { body: Bytes::from(body), path: "chat/completions".to_string() }) + Ok(FimProxyTransform { + body: Bytes::from(body), + path: "chat/completions".to_string(), + base_url: None, + }) } #[cfg(test)] @@ -73,11 +108,62 @@ mod tests { #[test] fn mistral_keeps_native_fim_request() { - let transformed = - maybe_transform_fim_request(&AIProvider::Mistral, "fim/completions", br#"{}"#).unwrap(); + let transformed = maybe_transform_fim_request( + &AIProvider::Mistral, + "fim/completions", + "https://api.mistral.ai/v1", + br#"{}"#, + ) + .unwrap(); assert!(transformed.is_none()); assert!(supports_native_fim(&AIProvider::Mistral)); + assert!(supports_native_fim(&AIProvider::DeepSeek)); + assert!(!supports_native_fim(&AIProvider::OpenAI)); + } + + #[test] + fn deepseek_fim_base_url_uses_beta_endpoint() { + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com/v1"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com/v1/"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://proxy.example/deepseek/v1"), + "https://proxy.example/deepseek/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://proxy.example/deepseek/beta"), + "https://proxy.example/deepseek/beta" + ); + } + + #[test] + fn deepseek_fim_request_uses_beta_completions_endpoint() { + let body = br#"{"model":"deepseek-v4-pro","prompt":"return ","suffix":";"}"#; + let transformed = maybe_transform_fim_request( + &AIProvider::DeepSeek, + "fim/completions", + DEEPSEEK_BASE_URL, + body, + ) + .unwrap() + .expect("DeepSeek FIM should be routed to the beta completions endpoint"); + + assert_eq!(transformed.path, "completions"); + assert_eq!( + transformed.base_url.as_deref(), + Some("https://api.deepseek.com/beta") + ); + assert_eq!(transformed.body, Bytes::copy_from_slice(body)); } #[test] @@ -85,6 +171,7 @@ mod tests { let transformed = maybe_transform_fim_request( &AIProvider::OpenAI, "fim/completions", + "https://api.openai.com/v1", br#"{ "model": "gpt-4.1", "prompt": "fn main() {", @@ -96,6 +183,7 @@ mod tests { .expect("OpenAI FIM should be transformed"); assert_eq!(transformed.path, "chat/completions"); + assert_eq!(transformed.base_url, None); let body: serde_json::Value = serde_json::from_slice(&transformed.body).unwrap(); assert_eq!(body["model"], "gpt-4.1"); @@ -111,9 +199,13 @@ mod tests { #[test] fn invalid_fim_body_is_bad_request() { - let err = - maybe_transform_fim_request(&AIProvider::OpenAI, "fim/completions", br#"{"model": 1}"#) - .unwrap_err(); + let err = maybe_transform_fim_request( + &AIProvider::OpenAI, + "fim/completions", + "https://api.openai.com/v1", + br#"{"model": 1}"#, + ) + .unwrap_err(); assert!(matches!(err, Error::BadRequest(_))); } diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 81f601e53f..21059df18a 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -627,7 +627,7 @@ async fn proxy( check_scopes(&authed, || format!("resources:read:{}", resource_path))?; } - let credentials = match workspace_cache { + let mut credentials = match workspace_cache { Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => { request_cache.credentials } @@ -758,11 +758,23 @@ async fn proxy( } }; - if let Some(fim_transform) = maybe_transform_fim_request(&provider, &ai_path, &body)? { - tracing::debug!( - "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", - provider - ); + if let Some(fim_transform) = + maybe_transform_fim_request(&provider, &ai_path, &credentials.base_url, &body)? + { + if fim_transform.base_url.is_some() { + tracing::debug!( + "Routing native FIM request through provider-specific endpoint for {:?}", + provider + ); + } else { + tracing::debug!( + "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", + provider + ); + } + if let Some(base_url) = fim_transform.base_url { + credentials.base_url = base_url; + } body = fim_transform.body; ai_path = fim_transform.path; } diff --git a/frontend/src/lib/components/copilot/autocomplete/request.ts b/frontend/src/lib/components/copilot/autocomplete/request.ts index ef29ba9a19..cc3987ebe5 100644 --- a/frontend/src/lib/components/copilot/autocomplete/request.ts +++ b/frontend/src/lib/components/copilot/autocomplete/request.ts @@ -30,9 +30,9 @@ export async function autocompleteRequest( throw new Error('No code completion model selected') } - // Only add context lines for Mistral (native FIM) - other providers use chat completion + // Only add context lines for native FIM providers - other providers use chat completion // too much context degrades significantly the quality of the completion - if (providerModel.provider === 'mistral') { + if (providerModel.provider === 'mistral' || providerModel.provider === 'deepseek') { let commentSymbol = getCommentSymbol(context.scriptLang) let contextLines = comment( commentSymbol, diff --git a/frontend/src/lib/components/copilot/fim.ts b/frontend/src/lib/components/copilot/fim.ts new file mode 100644 index 0000000000..16c5d40431 --- /dev/null +++ b/frontend/src/lib/components/copilot/fim.ts @@ -0,0 +1,39 @@ +import type { AIProvider } from '$lib/gen' +import { z } from 'zod' + +const chatFimResponseSchema = z.object({ + choices: z.array( + z.object({ + message: z.object({ + content: z.string().optional() + }), + finish_reason: z.string().optional() + }) + ) +}) + +const deepseekFimResponseSchema = z.object({ + choices: z.array( + z.object({ + text: z.string().optional(), + finish_reason: z.string().optional() + }) + ) +}) + +export function parseFimCompletionChoice( + body: unknown, + provider: AIProvider +): { content: string | undefined; finish_reason: string | undefined } | undefined { + if (provider === 'deepseek') { + const parsedBody = deepseekFimResponseSchema.parse(body) + const choice = parsedBody.choices[0] + return choice ? { content: choice.text, finish_reason: choice.finish_reason } : undefined + } + + const parsedBody = chatFimResponseSchema.parse(body) + const choice = parsedBody.choices[0] + return choice + ? { content: choice.message.content, finish_reason: choice.finish_reason } + : undefined +} diff --git a/frontend/src/lib/components/copilot/lib.test.ts b/frontend/src/lib/components/copilot/lib.test.ts index f1eec17cbb..369987d53f 100644 --- a/frontend/src/lib/components/copilot/lib.test.ts +++ b/frontend/src/lib/components/copilot/lib.test.ts @@ -9,7 +9,9 @@ import { buildAssistantToolCallMessage, getReasoningContentDelta } from './chat/openaiReasoning' +import { parseFimCompletionChoice } from './fim' import { getDefaultChatTemperature, modelDisallowsSamplingParams } from './modelConfig' +import { supportsAutocomplete } from './utils' type AssistantMessageWithReasoning = ChatCompletionMessageParam & { role: 'assistant' @@ -43,6 +45,48 @@ describe('modelConfig', () => { }) }) +describe('fim autocomplete', () => { + it('allows DeepSeek v4 pro and Codestral autocomplete models', () => { + expect(supportsAutocomplete('codestral-latest')).toBe(true) + expect(supportsAutocomplete('Codestral-2501')).toBe(true) + expect(supportsAutocomplete('codestral-embed')).toBe(false) + expect(supportsAutocomplete('deepseek-v4-pro')).toBe(true) + expect(supportsAutocomplete('deepseek-chat')).toBe(false) + }) + + it('parses chat-shaped native FIM responses', () => { + expect( + parseFimCompletionChoice( + { + choices: [ + { + message: { content: 'cache[key] = factory()' }, + finish_reason: 'stop' + } + ] + }, + 'mistral' + ) + ).toEqual({ content: 'cache[key] = factory()', finish_reason: 'stop' }) + }) + + it('parses DeepSeek native FIM completion responses', () => { + expect( + parseFimCompletionChoice( + { + choices: [ + { + text: 'items?.length ?? 0', + finish_reason: 'stop' + } + ] + }, + 'deepseek' + ) + ).toEqual({ content: 'items?.length ?? 0', finish_reason: 'stop' }) + }) +}) + describe('openaiReasoning', () => { it('reads provider-specific reasoning_content deltas', () => { expect( diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index de87579a23..00d2334acc 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -16,7 +16,6 @@ import { OpenAPI, ResourceService, type Script } from '../../gen' import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts' import { getDefaultChatTemperature } from './modelConfig' import { formatResourceTypes } from './utils' -import { z } from 'zod' import { processToolCall, type Tool, type ToolCallbacks } from './chat/shared' import { getNonStreamingOpenAIResponsesCompletion, @@ -36,6 +35,7 @@ import { buildAssistantToolCallMessage, getReasoningContentDelta } from './chat/openaiReasoning' +import { parseFimCompletionChoice } from './fim' export const SUPPORTED_LANGUAGES = new Set(Object.keys(GEN_CONFIG.prompts)) @@ -74,7 +74,7 @@ export const AI_PROVIDERS: Record = { }, deepseek: { label: 'DeepSeek', - defaultModels: ['deepseek-chat', 'deepseek-reasoner'] + defaultModels: ['deepseek-v4-pro', 'deepseek-chat', 'deepseek-reasoner'] }, googleai: { label: 'Google AI', @@ -816,17 +816,6 @@ export async function getNonStreamingCompletion( return response } -const mistralFimResponseSchema = z.object({ - choices: z.array( - z.object({ - message: z.object({ - content: z.string().optional() - }), - finish_reason: z.string() - }) - ) -}) - export const FIM_MAX_TOKENS = 256 const FIM_MAX_LINES = 8 export async function getFimCompletion( @@ -864,12 +853,10 @@ export async function getFimCompletion( ) const body = await response.json() - const parsedBody = mistralFimResponseSchema.parse(body) + const choice = parseFimCompletionChoice(body, providerModel.provider) - const choice = parsedBody.choices[0] - - if (choice && choice.message.content !== undefined) { - let lines = choice.message.content.split('\n') + if (choice?.content !== undefined) { + let lines = choice.content.split('\n') // If finish_reason is 'length', remove the last line if (choice.finish_reason === 'length') { diff --git a/frontend/src/lib/components/copilot/utils.ts b/frontend/src/lib/components/copilot/utils.ts index 8e5150cda6..50f6513b26 100644 --- a/frontend/src/lib/components/copilot/utils.ts +++ b/frontend/src/lib/components/copilot/utils.ts @@ -171,10 +171,9 @@ export function yamlStringifyExceptKeys(obj: any, keys: string[]) { /** * Checks if a model supports FIM (Fill-in-the-Middle) autocomplete. - * Currently only Codestral models (non-embedding) support this. + * Currently Codestral models (non-embedding) and DeepSeek FIM support this. */ export function supportsAutocomplete(model: string): boolean { const lower = model.toLowerCase() - return lower.includes('codestral') && !lower.includes('embed') + return (lower.includes('codestral') && !lower.includes('embed')) || lower === 'deepseek-v4-pro' } - diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index dd3cc6e0d0..922492d6ac 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -353,7 +353,7 @@ {#if showWorkspaceOverrideEditor}
- {#each Object.entries(AI_PROVIDERS) as [provider, details]} + {#each Object.entries(AI_PROVIDERS) as [provider, details] (provider)}
From 889101b7f04884408833beb05f813e78f9a9862b Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Thu, 28 May 2026 19:52:36 +0200 Subject: [PATCH 16/52] chore(main): release 1.712.0 (#9340) * chore(main): release 1.712.0 * Apply automatic changes --------- Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com> --- CHANGELOG.md | 23 ++ backend/Cargo.lock | 366 ++++++++---------- backend/Cargo.toml | 4 +- .../parsers/windmill-parser-wasm/Cargo.lock | 48 +-- .../parsers/windmill-parser-wasm/Cargo.toml | 2 +- backend/windmill-api/openapi.yaml | 2 +- benchmarks/lib.ts | 2 +- cli/src/main.ts | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- lsp/Pipfile | 2 +- openflow.openapi.yaml | 2 +- .../WindmillClient/WindmillClient.psd1 | 2 +- python-client/wmill/pyproject.toml | 2 +- typescript-client/jsr.json | 2 +- typescript-client/package.json | 2 +- version.txt | 2 +- 17 files changed, 232 insertions(+), 237 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbf6cb922..583a79cd82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.712.0](https://github.com/windmill-labs/windmill/compare/v1.711.0...v1.712.0) (2026-05-28) + + +### Features + +* add deepseek fim support ([#9365](https://github.com/windmill-labs/windmill/issues/9365)) ([2553fbf](https://github.com/windmill-labs/windmill/commit/2553fbfe31417bd985e7994eac695bf918f97ce2)) +* deploy raw apps from global chat ([#9349](https://github.com/windmill-labs/windmill/issues/9349)) ([dec58e6](https://github.com/windmill-labs/windmill/commit/dec58e6c4f55062b42a752c43c89ef05903e713a)) +* inject active editor into global chat ([#9361](https://github.com/windmill-labs/windmill/issues/9361)) ([9e7eaf3](https://github.com/windmill-labs/windmill/commit/9e7eaf36847ad3a004ec84e8b7d4784771b7b451)) +* **queue:** duration-weighted fairness admission ([#9334](https://github.com/windmill-labs/windmill/issues/9334)) ([045d120](https://github.com/windmill-labs/windmill/commit/045d12043e7c99830ef90bc0da798c94e2094711)) +* warn when custom instance db is shared across workspaces ([#9359](https://github.com/windmill-labs/windmill/issues/9359)) ([a9e5140](https://github.com/windmill-labs/windmill/commit/a9e514099585e5ee72df21bd551a223cceb20fb0)) + + +### Bug Fixes + +* **cli:** redact encryption_key diff in stdout by default ([#9347](https://github.com/windmill-labs/windmill/issues/9347)) ([88056f8](https://github.com/windmill-labs/windmill/commit/88056f8d4c91c1d14d85a08851ecf0bd97e2260d)) +* **cli:** stop re-prompting on wmill refresh prompts ([#9357](https://github.com/windmill-labs/windmill/issues/9357)) ([c2b5ba8](https://github.com/windmill-labs/windmill/commit/c2b5ba8871abbbcff6de69c90e2f09fee70586c1)) +* **frontend:** close other sidebar menus when hovering Help ([#9354](https://github.com/windmill-labs/windmill/issues/9354)) ([da882c5](https://github.com/windmill-labs/windmill/commit/da882c54b21e3eaf2c1d1abccd0996b243d96dce)) +* **frontend:** prevent duplicate asset node ids crashing flow graph ([#9367](https://github.com/windmill-labs/windmill/issues/9367)) ([9a659b6](https://github.com/windmill-labs/windmill/commit/9a659b636d713ee8fdfbdad41c58bb3d7c79e0d9)) +* **frontend:** prevent MultiSelect crash on undefined value ([#9364](https://github.com/windmill-labs/windmill/issues/9364)) ([aea0061](https://github.com/windmill-labs/windmill/commit/aea00611c41379be2afdad0eedd608c9537d03f7)) +* **git-sync:** publish fork branch on only_create_branch from the CLI ([#9366](https://github.com/windmill-labs/windmill/issues/9366)) ([2fdc51e](https://github.com/windmill-labs/windmill/commit/2fdc51e62985fc755884436130bdd58e294247c8)) +* infer script arg schema when deploying via AI chat ([#9356](https://github.com/windmill-labs/windmill/issues/9356)) ([4efc372](https://github.com/windmill-labs/windmill/commit/4efc37212a98571214aba135b0fbb10dc263fd4f)) +* **monitor:** cleanup stale server_heartbeat background_task_state rows ([#9338](https://github.com/windmill-labs/windmill/issues/9338)) ([59ab038](https://github.com/windmill-labs/windmill/commit/59ab038d7718d8a4c25efa5928f42e1393ebbf40)) + ## [1.711.0](https://github.com/windmill-labs/windmill/compare/v1.710.1...v1.711.0) (2026-05-26) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c32e1d9c67..37fa905cb0 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1141,7 +1141,7 @@ dependencies = [ "http 1.4.1", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.24.2", "hyper-rustls 0.27.9", "hyper-util", @@ -1335,7 +1335,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "itoa", "matchit 0.8.4", @@ -1684,7 +1684,7 @@ dependencies = [ "hex", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -1786,13 +1786,13 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.0", + "brotli-decompressor 5.0.1", ] [[package]] @@ -1807,9 +1807,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -2202,7 +2202,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -3599,7 +3599,7 @@ dependencies = [ "hickory-resolver", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-util", "ipnet", @@ -3731,8 +3731,8 @@ dependencies = [ "proc-macro2", "quote", "stringcase", - "strum 0.27.2", - "strum_macros 0.27.2", + "strum", + "strum_macros", "syn 2.0.117", "thiserror 2.0.18", ] @@ -3802,7 +3802,7 @@ dependencies = [ "deno_error 0.6.1", "deno_tls", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-util", "log", @@ -4155,9 +4155,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -4364,7 +4364,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -5236,13 +5236,13 @@ dependencies = [ [[package]] name = "gosyn" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb37859fda6792e95231aef1c5838f4043ec0ee352d8313421e311c606df612" +checksum = "c99c1502d84229dc7ddb6af755f40ebe80e7e932fa78ecef979cedcf9999ba93" dependencies = [ "anyhow", - "strum 0.25.0", - "thiserror 1.0.69", + "strum", + "thiserror 2.0.18", "unic-ucd-category", ] @@ -5418,12 +5418,6 @@ dependencies = [ "http 1.4.1", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -5649,7 +5643,7 @@ dependencies = [ "futures", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.26.0", "hyper-tls", "hyper-tungstenite", @@ -5699,9 +5693,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "eb92f162bf56536459fc83c79b974bb12837acfed43d6bc370a7916d0ae15ecc" dependencies = [ "atomic-waker", "bytes", @@ -5729,7 +5723,7 @@ dependencies = [ "futures-util", "headers", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -5749,7 +5743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ "hex", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -5781,7 +5775,7 @@ checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "log", "rustls 0.22.4", @@ -5799,7 +5793,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "log", "rustls 0.23.35", @@ -5816,7 +5810,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -5831,7 +5825,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "native-tls", "tokio", @@ -5846,7 +5840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" dependencies = [ "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -5866,12 +5860,12 @@ dependencies = [ "futures-util", "http 1.4.1", "http-body 1.0.1", - "hyper 1.9.0", + "hyper 1.10.0", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -5887,7 +5881,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-util", "pin-project-lite", "tokio", @@ -6116,7 +6110,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.3", + "socket2 0.6.4", "widestring", "windows-registry", "windows-result 0.4.1", @@ -6144,7 +6138,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -6406,7 +6400,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-http-proxy", "hyper-rustls 0.27.9", "hyper-timeout", @@ -6638,14 +6632,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "bitflags 2.11.1", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.8.0", ] [[package]] @@ -7022,9 +7016,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -7117,9 +7111,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -7198,7 +7192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66f62cad7623a9cb6f8f64037f0c4f69c8db8e82914334a83c9788201c2c1bfa" dependencies = [ "darling 0.20.11", - "heck 0.5.0", + "heck", "num-bigint", "proc-macro-crate", "proc-macro-error2", @@ -7230,7 +7224,7 @@ dependencies = [ "percent-encoding", "rand 0.10.1", "serde", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tokio-native-tls", @@ -7396,7 +7390,7 @@ version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71f7c8ed6ba88a567ec6f7c4cad4a7a8465ab93b8cdaf89d3dc72347a83c2d1f" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro-error", "proc-macro2", "quote", @@ -7464,7 +7458,7 @@ dependencies = [ "dirs 5.0.1", "dirs-sys 0.4.1", "fancy-regex 0.14.0", - "heck 0.5.0", + "heck", "indexmap 2.14.0", "log", "lru 0.12.5", @@ -7720,7 +7714,7 @@ dependencies = [ "http-body-util", "httparse", "humantime", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "md-5 0.10.6", "parking_lot", @@ -8245,7 +8239,7 @@ dependencies = [ "arrow-schema", "arrow-select", "base64 0.22.1", - "brotli 8.0.2", + "brotli 8.0.3", "bytes", "chrono", "flate2", @@ -8698,7 +8692,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -8979,7 +8973,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls 0.23.35", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -9017,7 +9011,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -9302,9 +9296,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "7c7591fa2c6b601dfcfe5f043f65a1c39fcdf50efefcd7f1572e538c1f4b398d" dependencies = [ "bitflags 2.11.1", ] @@ -9426,7 +9420,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -9474,7 +9468,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-rustls 0.27.9", "hyper-util", "js-sys", @@ -9530,7 +9524,7 @@ dependencies = [ "futures", "getrandom 0.2.17", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "reqwest 0.13.1", "reqwest-middleware", "retry-policies", @@ -10862,9 +10856,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -11052,7 +11046,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "once_cell", "proc-macro2", @@ -11268,35 +11262,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -dependencies = [ - "strum_macros 0.25.3", -] - [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", + "strum_macros", ] [[package]] @@ -11305,7 +11277,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -12397,7 +12369,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.9.0", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tokio-util", "whoami", @@ -12594,9 +12566,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -12629,7 +12601,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -12661,7 +12633,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.0", "hyper-timeout", "hyper-util", "percent-encoding", @@ -13802,7 +13774,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-nats", @@ -13836,7 +13808,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "tikv-jemalloc-ctl", @@ -13883,7 +13855,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.711.0" +version = "1.712.0" dependencies = [ "async-stream", "async-trait", @@ -13916,7 +13888,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13929,7 +13901,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "argon2", @@ -13959,7 +13931,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -13990,7 +13962,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "time", @@ -14067,12 +14039,12 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "quick_cache", "serde", @@ -14090,7 +14062,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14103,7 +14075,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14129,7 +14101,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.711.0" +version = "1.712.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14139,7 +14111,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14156,7 +14128,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14178,7 +14150,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14201,7 +14173,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14217,11 +14189,11 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", - "hyper 1.9.0", + "hyper 1.10.0", "serde", "serde_json", "sql-builder", @@ -14238,7 +14210,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14259,7 +14231,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14273,7 +14245,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-nats", @@ -14305,14 +14277,14 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", "base64 0.22.1", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "serde", "serde_json", @@ -14330,7 +14302,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14348,7 +14320,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14370,7 +14342,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14390,13 +14362,13 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", "futures", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "lazy_static", "quick_cache", @@ -14420,7 +14392,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14448,7 +14420,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.711.0" +version = "1.712.0" dependencies = [ "lazy_static", "serde", @@ -14460,14 +14432,14 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.711.0" +version = "1.712.0" dependencies = [ "argon2", "axum 0.8.9", "chrono", "dashmap", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "serde", "serde_json", @@ -14485,7 +14457,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14499,13 +14471,13 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.711.0" +version = "1.712.0" dependencies = [ "axum 0.8.9", "chrono", "hex", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "magic-crypt", "regex", @@ -14513,7 +14485,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "uuid", @@ -14532,7 +14504,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.711.0" +version = "1.712.0" dependencies = [ "chrono", "lazy_static", @@ -14546,7 +14518,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14565,7 +14537,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.711.0" +version = "1.712.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14601,7 +14573,7 @@ dependencies = [ "globset", "hex", "hmac", - "hyper 1.9.0", + "hyper 1.10.0", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -14636,8 +14608,8 @@ dependencies = [ "sha2 0.10.9", "size", "sqlx", - "strum 0.27.2", - "strum_macros 0.27.2", + "strum", + "strum_macros", "sysinfo", "systemstat", "tar", @@ -14666,7 +14638,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.711.0" +version = "1.712.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14685,7 +14657,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.711.0" +version = "1.712.0" dependencies = [ "regex", "serde", @@ -14700,7 +14672,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14724,7 +14696,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "futures", @@ -14741,7 +14713,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.711.0" +version = "1.712.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14757,7 +14729,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -14778,7 +14750,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -14795,7 +14767,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "urlencoding", @@ -14809,7 +14781,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "arc-swap", @@ -14834,7 +14806,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-stream", @@ -14868,7 +14840,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "futures", @@ -14886,7 +14858,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.711.0" +version = "1.712.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14895,7 +14867,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -14907,7 +14879,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -14919,7 +14891,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "gosyn", @@ -14931,7 +14903,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -14943,7 +14915,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -14955,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "nu-parser", @@ -14966,7 +14938,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14949,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14989,7 +14961,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15000,7 +14972,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -15022,7 +14994,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -15034,7 +15006,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -15048,7 +15020,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15065,7 +15037,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -15078,7 +15050,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -15090,7 +15062,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -15108,7 +15080,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15124,7 +15096,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15140,7 +15112,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -15151,7 +15123,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -15189,7 +15161,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "const_format", @@ -15227,7 +15199,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.711.0" +version = "1.712.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15238,7 +15210,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -15246,7 +15218,7 @@ dependencies = [ "chrono", "futures", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "lazy_static", "quick_cache", "reqwest 0.13.1", @@ -15268,7 +15240,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15292,14 +15264,14 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "lazy_static", "rand 0.9.0", @@ -15325,7 +15297,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15358,7 +15330,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15378,7 +15350,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15412,7 +15384,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15424,7 +15396,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.0", "itertools 0.14.0", "lazy_static", "matchit 0.7.3", @@ -15448,7 +15420,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15471,7 +15443,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15495,7 +15467,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-nats", @@ -15519,7 +15491,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15554,7 +15526,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15582,7 +15554,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-trait", @@ -15607,7 +15579,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15618,7 +15590,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum 0.27.2", + "strum", "tracing", "uuid", "windmill-parser", @@ -15626,7 +15598,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-once-cell", @@ -15736,7 +15708,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.711.0" +version = "1.712.0" dependencies = [ "bytes", "futures", @@ -16371,7 +16343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -16382,7 +16354,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap 2.14.0", "prettyplease", "syn 2.0.117", @@ -16550,18 +16522,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "bce33a6288fa3f072a8c2c7d0f2fdbb90e28298f0135c1f99b96c3db2efcc60b" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "8fd425244944f4ab65ccff928e7323354c5a018c75838362fdce749dfad2ee1e" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e296f9fb6a..a4950571f2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.711.0" +version = "1.712.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.711.0" +version = "1.712.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 f06ab454a3..06f936e955 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.711.0" +version = "1.712.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.711.0" +version = "1.712.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.711.0" +version = "1.712.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.711.0" +version = "1.712.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index f49ea30ecd..2b0e060503 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.711.0" +version = "1.712.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 4576f655ea..04117d640d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.711.0 + version: 1.712.0 title: Windmill API contact: diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index deb8f1cf66..924052b688 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.711.0"; +export const VERSION = "v1.712.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/main.ts b/cli/src/main.ts index cc8f9e80b7..34c186182d 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -89,7 +89,7 @@ export { token, }; -export const VERSION = "1.711.0"; +export const VERSION = "1.712.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67be947bde..c59d3e5b35 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.712.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.712.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { diff --git a/frontend/package.json b/frontend/package.json index c3651f7daf..4f0c9ada75 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.712.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 199fbdf672..a8f34d6189 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.711.0" +wmill = ">=1.712.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 0ef3bbaa85..cb0f7d33d9 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.711.0 + version: 1.712.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index ee0f88f1b2..17ed31216b 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.711.0' + ModuleVersion = '1.712.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index ecc68d1c18..abbbed9857 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.711.0" +version = "1.712.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 c68b80d575..434effa5e0 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.711.0", + "version": "1.712.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 b4535d1f7b..c429b01434 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.711.0", + "version": "1.712.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "sideEffects": false, diff --git a/version.txt b/version.txt index 272c83ab90..9a8f9c885c 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.711.0 +1.712.0 From 2bf11dcb15540c538ea2ac3cf70dcbe589060b4e Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 29 May 2026 00:33:44 +0200 Subject: [PATCH 17/52] feat(oauth): support per-provider sandbox URLs (#9358) * feat(oauth): support per-provider sandbox URLs in registry + instance settings * fix(oauth): polish sandbox review nits (cc lookup, header label, ee ref) * refactor(oauth): drop dead build_oauth_clients duplicate in windmill-oauth * refactor(oauth): derive sandbox-capable provider list from registry * chore(docker): copy oauth_connect.json into frontend build stage * test(oauth): cover sandbox helpers (as_sandbox, canonical_name, resolve) * chore: update ee-repo-ref to 9297d8f790346e6a6ad540c7bca1a67f91ec11a2 This commit updates the EE repository reference after PR #595 was merged in windmill-ee-private. Previous ee-repo-ref: 3ab3eca9ac15ebab6db991e7964bc5e48ce21f42 New ee-repo-ref: 9297d8f790346e6a6ad540c7bca1a67f91ec11a2 Automated by sync-ee-ref workflow. --------- Co-authored-by: windmill-internal-app[bot] --- Dockerfile | 1 + backend/ee-repo-ref.txt | 2 +- backend/oauth_connect.json | 6 +- .../windmill-common/src/instance_config.rs | 15 + backend/windmill-oauth/src/lib.rs | 393 +++++++++--------- docker/RHEL8/Dockerfile | 1 + docker/RHEL9/Dockerfile | 1 + .../src/lib/components/AppConnectInner.svelte | 35 +- .../src/lib/components/AuthSettings.svelte | 50 ++- frontend/svelte.config.js | 3 +- 10 files changed, 282 insertions(+), 225 deletions(-) diff --git a/Dockerfile b/Dockerfile index e11cf9cecd..9062a4d9d8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index f457a34fb7..d4a79d49d1 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -a30079e75dc5b7d7413aa8ee20e40e80bfea9cbd +9297d8f790346e6a6ad540c7bca1a67f91ec11a2 diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index c9693b2311..d18c8c8d24 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -176,6 +176,10 @@ "token_url": "https://account.docusign.com/oauth/token", "scopes": [ "signature" - ] + ], + "sandbox": { + "auth_url": "https://account-d.docusign.com/oauth/auth", + "token_url": "https://account-d.docusign.com/oauth/token" + } } } diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 1872b52140..2239868982 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -586,6 +586,21 @@ pub struct OAuthConfig { pub req_body_auth: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub grant_types: Vec, + /// Optional URL overrides for the provider's sandbox environment. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, +} + +/// URL overrides for an OAuth provider's sandbox environment. +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] +pub struct OAuthSandboxOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_url: Option, } // --------------------------------------------------------------------------- diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index ea65a83ba4..874a859200 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -18,9 +18,7 @@ use std::collections::HashMap; use std::fmt::Debug; use anyhow::anyhow; -use base64::Engine; use hmac::Mac; -use itertools::Itertools; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sqlx::{Postgres, Transaction}; use tower_cookies::{Cookie, Cookies}; @@ -89,6 +87,76 @@ pub struct OAuthConfig { pub req_body_auth: Option, #[serde(default = "default_grant_types")] pub grant_types: Vec, + /// Optional URL overrides for the provider's sandbox environment. When + /// present and the admin has configured a `_sandbox` credentials + /// entry, `build_oauth_clients` registers a second client under that key. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, +} + +/// URL overrides for an OAuth provider's sandbox environment. Inherits +/// scopes, extra_params, etc. from the parent [`OAuthConfig`]. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct OAuthSandboxOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_url: Option, +} + +impl OAuthConfig { + /// Returns a copy of this config with sandbox URL overrides applied and + /// the nested `sandbox` field cleared. Returns `None` if no overrides are + /// set. + pub fn as_sandbox(&self) -> Option { + let sb = self.sandbox.as_ref()?; + let mut out = self.clone(); + out.sandbox = None; + if let Some(u) = &sb.auth_url { + out.auth_url = u.clone(); + } + if let Some(u) = &sb.token_url { + out.token_url = u.clone(); + } + if sb.userinfo_url.is_some() { + out.userinfo_url = sb.userinfo_url.clone(); + } + Some(out) + } +} + +/// Suffix appended to a provider name to identify its sandbox variant in the +/// instance credentials map and in `account.client`. +pub const SANDBOX_SUFFIX: &str = "_sandbox"; + +/// Strips [`SANDBOX_SUFFIX`] from a client name, returning the canonical +/// provider name. Returns the input unchanged if no suffix is present. +pub fn canonical_provider_name(client_name: &str) -> &str { + client_name + .strip_suffix(SANDBOX_SUFFIX) + .unwrap_or(client_name) +} + +/// Resolves a registry [`OAuthConfig`] for `client_name`, transparently +/// applying the `sandbox` override block when the name carries the sandbox +/// suffix (e.g. `docusign_sandbox` resolves to `docusign` with sandbox URLs +/// applied). Used so callers don't need to know whether a name is a sandbox +/// variant before looking it up. +pub fn resolve_registry_config( + static_configs: &HashMap, + client_name: &str, +) -> Option { + if let Some(cfg) = static_configs.get(client_name) { + return Some(cfg.clone()); + } + if client_name.ends_with(SANDBOX_SUFFIX) { + return static_configs + .get(canonical_provider_name(client_name)) + .and_then(|cfg| cfg.as_sandbox()); + } + None } /// OAuth client credentials @@ -181,181 +249,6 @@ pub struct OAuthCallback { pub state: String, } -/// Build all OAuth clients from configuration -pub async fn build_oauth_clients( - base_url: &str, - oauths_from_config: Option>, - connect_configs_json: &str, - login_configs_json: &str, -) -> anyhow::Result { - let connect_configs = - serde_json::from_str::>(connect_configs_json)?; - let login_configs = serde_json::from_str::>(login_configs_json)?; - - let oauths = if let Some(oauths) = oauths_from_config { - tracing::info!("Using OAuth clients from config: {oauths:?}"); - oauths - } else { - let path = "./oauth.json"; - let content: String = if let Ok(e) = std::env::var("OAUTH_JSON_AS_BASE64") { - std::str::from_utf8( - &base64::engine::general_purpose::STANDARD - .decode(e) - .map_err(to_anyhow)?, - )? - .to_string() - } else if std::path::Path::new(path).exists() { - std::fs::read_to_string(path).map_err(to_anyhow)? - } else { - tracing::warn!("oauth.json not found, no OAuth clients loaded"); - return Ok(AllClients { - logins: HashMap::new(), - connects: HashMap::new(), - slack: None, - }); - }; - - if content.is_empty() { - tracing::warn!("oauth.json is empty, no OAuth clients loaded"); - return Ok(AllClients { - logins: HashMap::new(), - connects: HashMap::new(), - slack: None, - }); - }; - match serde_json::from_str::>(&content) { - Ok(clients) => clients, - Err(e) => { - tracing::error!("deserializing oauth.json: {e}"); - HashMap::new() - } - } - .into_iter() - .collect() - }; - - tracing::info!("OAuth loaded clients: {}", oauths.keys().join(", ")); - - let logins = login_configs - .into_iter() - .filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1)))) - .chain(oauths.iter().filter_map(|x| { - x.1.login_config - .as_ref() - .map(|c| (x.0.clone(), (x.1, c.clone()))) - })) - .filter_map(|(k, (client_params, config))| { - let named_client = build_basic_client( - k.clone(), - config.clone(), - client_params.clone(), - true, - base_url, - None, - ); - named_client - .map(|named_client| { - ( - named_client.0, - ClientWithScopes { - client: named_client.1, - scopes: config.scopes.unwrap_or(vec![]), - extra_params: config.extra_params, - extra_params_callback: config.extra_params_callback, - allowed_domains: client_params.allowed_domains.clone(), - userinfo_url: config.userinfo_url, - display_name: client_params.display_name.clone(), - grant_types: client_params.grant_types.clone(), - }, - ) - }) - .map_err(|e| { - tracing::error!("Error building oauth client {k}: {e}"); - e - }) - .ok() - }) - .collect(); - - let connects = connect_configs - .into_iter() - .filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1)))) - .chain(oauths.iter().filter_map(|x| { - x.1.connect_config - .as_ref() - .map(|c| (x.0.clone(), (x.1, c.clone()))) - })) - .filter_map(|(k, (client_params, config))| { - let named_client = build_basic_client( - k.clone(), - config.clone(), - client_params.clone(), - false, - base_url, - if k == "supabase_wizard" { - Some(format!("{base_url}/oauth/callback_supabase")) - } else { - None - }, - ); - named_client - .map(|named_client| { - ( - named_client.0, - ClientWithScopes { - client: named_client.1, - scopes: config.scopes.unwrap_or(vec![]), - extra_params: config.extra_params, - extra_params_callback: config.extra_params_callback, - allowed_domains: None, - userinfo_url: None, - display_name: client_params.display_name.clone(), - grant_types: client_params.grant_types.clone(), - }, - ) - }) - .map_err(|e| { - tracing::error!("Error building oauth client {k}: {e}"); - e - }) - .ok() - }) - .collect(); - - let slack = oauths - .get("slack") - .map(|v| { - build_basic_client( - "slack".to_string(), - OAuthConfig { - auth_url: "https://slack.com/oauth/v2/authorize".to_string(), - token_url: "https://slack.com/api/oauth.v2.access".to_string(), - userinfo_url: None, - scopes: None, - extra_params: None, - extra_params_callback: None, - req_body_auth: None, - grant_types: vec!["authorization_code".to_string()], - }, - v.clone(), - false, - base_url, - Some(format!("{base_url}/oauth/callback_slack")), - ) - .map(|x| x.1) - .map_err(|e| { - tracing::error!("Error building oauth slack client: {e}"); - e - }) - .ok() - }) - .flatten(); - - let all_clients = AllClients { logins, connects, slack }; - tracing::debug!("Final oauth config: {all_clients:#?}"); - Ok(all_clients) -} - /// Build a basic OAuth client from configuration pub fn build_basic_client( name: String, @@ -433,38 +326,29 @@ pub async fn build_client_credentials_oauth_client( let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone()) .map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?; - let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config { - if !config.auth_url.is_empty() && !config.token_url.is_empty() { - config.clone() - } else { - let static_configs = - serde_json::from_str::>(connect_configs_json) - .map_err(|e| { - error::Error::InternalErr(format!( - "Failed to parse oauth_connect.json: {}", - e - )) - })?; - - static_configs.get(client_name).cloned().ok_or_else(|| { - error::Error::BadRequest(format!( - "OAuth configuration not found for '{}' in either global settings or static config", - client_name - )) - })? - } - } else { - let static_configs = - serde_json::from_str::>(connect_configs_json).map_err( - |e| error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)), - )?; - - static_configs.get(client_name).cloned().ok_or_else(|| { + let parse_static_configs = || { + serde_json::from_str::>(connect_configs_json).map_err(|e| { + error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)) + }) + }; + let resolve_from_registry = |client_name: &str| -> error::Result { + let static_configs = parse_static_configs()?; + resolve_registry_config(&static_configs, client_name).ok_or_else(|| { error::Error::BadRequest(format!( "OAuth configuration not found for '{}' in either global settings or static config", client_name )) - })? + }) + }; + + let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config { + if !config.auth_url.is_empty() && !config.token_url.is_empty() { + config.clone() + } else { + resolve_from_registry(client_name)? + } + } else { + resolve_from_registry(client_name)? }; if let Some(override_url) = cc_token_url_override { @@ -905,4 +789,103 @@ mod tests { let verifier = SlackVerifier::new("test_secret").unwrap(); assert!(verifier.verify("123", "body", "wrong_sig").is_err()); } + + #[test] + fn canonical_provider_name_strips_sandbox_suffix() { + assert_eq!(canonical_provider_name("docusign_sandbox"), "docusign"); + assert_eq!(canonical_provider_name("docusign"), "docusign"); + assert_eq!(canonical_provider_name(""), ""); + // Only strips the suffix once; trailing suffix on already-canonical name. + assert_eq!( + canonical_provider_name("foo_sandbox_sandbox"), + "foo_sandbox" + ); + } + + fn sample_oauth_config(with_sandbox: bool) -> OAuthConfig { + OAuthConfig { + auth_url: "https://account.example.com/oauth/auth".to_string(), + token_url: "https://account.example.com/oauth/token".to_string(), + userinfo_url: Some("https://account.example.com/userinfo".to_string()), + scopes: Some(vec!["signature".to_string()]), + extra_params: None, + extra_params_callback: None, + req_body_auth: None, + grant_types: default_grant_types(), + sandbox: with_sandbox.then(|| OAuthSandboxOverride { + auth_url: Some("https://account-d.example.com/oauth/auth".to_string()), + token_url: Some("https://account-d.example.com/oauth/token".to_string()), + userinfo_url: None, + }), + } + } + + #[test] + fn as_sandbox_returns_none_when_no_override() { + assert!(sample_oauth_config(false).as_sandbox().is_none()); + } + + #[test] + fn as_sandbox_overlays_urls_and_inherits_rest() { + let resolved = sample_oauth_config(true).as_sandbox().unwrap(); + // URLs overridden by sandbox block + assert_eq!( + resolved.auth_url, + "https://account-d.example.com/oauth/auth" + ); + assert_eq!( + resolved.token_url, + "https://account-d.example.com/oauth/token" + ); + // userinfo_url not in override → inherits from parent + assert_eq!( + resolved.userinfo_url, + Some("https://account.example.com/userinfo".to_string()) + ); + // Scopes/grant_types inherited from parent + assert_eq!(resolved.scopes, Some(vec!["signature".to_string()])); + assert_eq!(resolved.grant_types, default_grant_types()); + // Nested sandbox field cleared on the resolved config + assert!(resolved.sandbox.is_none()); + } + + #[test] + fn resolve_registry_config_direct_lookup() { + let mut registry = HashMap::new(); + registry.insert("docusign".to_string(), sample_oauth_config(true)); + + let resolved = resolve_registry_config(®istry, "docusign").unwrap(); + assert_eq!(resolved.auth_url, "https://account.example.com/oauth/auth"); + // Direct lookup returns the entry as-is (sandbox block still attached). + assert!(resolved.sandbox.is_some()); + } + + #[test] + fn resolve_registry_config_sandbox_fallback() { + let mut registry = HashMap::new(); + registry.insert("docusign".to_string(), sample_oauth_config(true)); + + let resolved = resolve_registry_config(®istry, "docusign_sandbox").unwrap(); + // Sandbox-suffixed lookup resolves to parent's sandbox-overlaid config. + assert_eq!( + resolved.auth_url, + "https://account-d.example.com/oauth/auth" + ); + assert!(resolved.sandbox.is_none()); + } + + #[test] + fn resolve_registry_config_missing_returns_none() { + let registry: HashMap = HashMap::new(); + assert!(resolve_registry_config(®istry, "docusign").is_none()); + assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none()); + } + + #[test] + fn resolve_registry_config_sandbox_without_block_returns_none() { + let mut registry = HashMap::new(); + // Parent exists but has no sandbox override. + registry.insert("docusign".to_string(), sample_oauth_config(false)); + assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none()); + } } diff --git a/docker/RHEL8/Dockerfile b/docker/RHEL8/Dockerfile index cb5f36cef5..500050de67 100644 --- a/docker/RHEL8/Dockerfile +++ b/docker/RHEL8/Dockerfile @@ -30,6 +30,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/docker/RHEL9/Dockerfile b/docker/RHEL9/Dockerfile index 6d96804381..a0fff8dd91 100644 --- a/docker/RHEL9/Dockerfile +++ b/docker/RHEL9/Dockerfile @@ -30,6 +30,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 4f0a5a76b2..7b6ed37ecc 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -74,6 +74,16 @@ let value: string = $state('') let valueToken: TokenResponse | undefined = undefined let connects: string[] | undefined = $state(undefined) + + const SANDBOX_SUFFIX = '_sandbox' + function stripSandboxSuffix(name: string): string { + return name.endsWith(SANDBOX_SUFFIX) ? name.slice(0, -SANDBOX_SUFFIX.length) : name + } + // `resourceType` is always the canonical type (e.g. `docusign`) so resource + // rows are uniform. `connectClient` carries the suffixed OAuth client name + // (e.g. `docusign_sandbox`) used to look up credentials/URLs at runtime + // and stored on `account.client` so token refresh hits the right endpoint. + let connectClient: string = $state('') let connectsManual: { key: string; img?: string; instructions: string[] }[] | undefined = $state(undefined) let args: any = $state({}) @@ -152,7 +162,9 @@ description = '' labels = undefined wsSpecific = false - resourceType = rt ?? '' + const rawRt = rt ?? '' + connectClient = rawRt + resourceType = stripSandboxSuffix(rawRt) valueToken = undefined // Reset client credentials state @@ -163,7 +175,7 @@ tokenUrl = '' await loadConnects() - manual = !connects?.includes(resourceType) + manual = !connects?.includes(connectClient) if (manual && express) { dispatch('error', 'Express OAuth setup is not available for non OAuth resource types') return @@ -312,7 +324,8 @@ sendUserToast(data.error, true) step = 2 } else if (data.type === 'success') { - resourceType = data.resource_type + connectClient = data.resource_type + resourceType = stripSandboxSuffix(connectClient) value = data.res.access_token! valueToken = data.res responseExtra = data.extra ?? {} @@ -325,7 +338,7 @@ } async function getScopesAndParams() { - const connect = await OauthService.getOauthConnect({ client: resourceType }) + const connect = await OauthService.getOauthConnect({ client: connectClient }) scopes = connect.scopes ?? [] extra_params = Object.entries(connect.extra_params ?? {}) as [string, string][] @@ -401,7 +414,7 @@ } const tokenResponse = await OauthService.connectClientCredentials({ - client: resourceType, + client: connectClient, requestBody }) @@ -428,7 +441,7 @@ * Requires user interaction and consent * Opens popup for user to authenticate with OAuth provider */ - const url = new URL(`/api/oauth/connect/${resourceType}`, window.location.origin) + const url = new URL(`/api/oauth/connect/${connectClient}`, window.location.origin) url.searchParams.append('scopes', scopes.join('+')) if (extra_params.length > 0) { extra_params.forEach(([key, value]) => url.searchParams.append(key, value)) @@ -490,7 +503,7 @@ const accountData: any = { refresh_token: valueToken.refresh_token ?? '', expires_in: valueToken.expires_in, - client: resourceType, + client: connectClient, grant_type: valueToken.grant_type || 'authorization_code' } @@ -602,6 +615,7 @@ ) step = 1 resourceType = '' + connectClient = '' } } @@ -660,10 +674,11 @@ +
{/if} -
- - -
{#if isSideBySide} -
-
- - {#snippet leftHeader()} - Before - {/snippet} - -
+
+ {#if beforeMissing} + + Before (no prior version) + + {:else} +
+ (beforeContentHeight = h)} + > + {#snippet leftHeader()} + Before + {/snippet} + +
+ {/if}
-
-
- - {#snippet leftHeader()} - After - {/snippet} - -
+
+ {#if afterMissing} + + After (flow deleted) + + {:else} +
+ (afterContentHeight = h)} + > + {#snippet leftHeader()} + After + {/snippet} + +
+ {/if}
@@ -219,7 +299,7 @@ editMode={false} download={false} scroll={false} - minHeight={400} + minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)} triggerNode={false} />
@@ -231,3 +311,31 @@

Loading graphs...

{/if} + + diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 710ce9bd5d..9dc36e3503 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -81,7 +81,7 @@ import { writable } from 'svelte/store' import { defaultScriptLanguages, processLangs } from '$lib/scripts' import DefaultScripts from './DefaultScripts.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import EditorHeader from './EditorHeader.svelte' import LabelsInput from './LabelsInput.svelte' @@ -134,7 +134,9 @@ onSaveDraftError, onSaveDraft, onNavigate, - disableAi + disableAi, + initialTestPanelCollapsed = false, + initialPathChosen = false }: ScriptBuilderProps = $props() export function getInitialAndModifiedValues(): SavedAndModifiedValue { @@ -626,17 +628,23 @@ if (!disableHistoryChange) { history.replaceState(history.state, '', `/scripts/edit/${script.path}`) } - if ( + // "Stay" deploys (explicit "Deploy & Stay here" or lib scripts) keep the + // editor in place rather than navigating to the deployed item. + const stayHere = stay || (script.auto_kind === 'lib' && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language)) - ) { + if (stayHere) { + // Re-pin parent_hash so the next deploy's conflict check is against + // the version we just wrote. script.parent_hash = newHash - sendUserToast('Deployed') - } else { - onDeploy?.({ path: script.path, hash: newHash }) } + // Always notify on a successful deploy; the consumer decides whether to + // navigate (route) or stay + sync the preview (session). Previously the + // stay/lib branch skipped onDeploy, so session previews didn't sync after + // a "Deploy & Stay here" or lib-script deploy. + onDeploy?.({ path: script.path, hash: newHash, stay: stayHere }) } catch (error) { onDeployError?.({ path: script.path, error }) sendUserToast(`Error while saving the script: ${error.body || error.message}`, true) @@ -793,6 +801,12 @@ loadingDraft = false } + // Inside an AI session pane (which injects an aiChatManager via context) the + // extra deploy-dropdown options — Deploy & Stay here, Fork, Edit in workspace + // fork, Exit & See details, Export — don't make sense: the session always + // stays put and is already scoped to a fork. Only "Show diff" is kept. + const inSessionPane = !!getContext('aiChatManager') + function computeDropdownItems( initialPath: string, savedScript: NewScriptWithDraftAndDraftTriggers | undefined, @@ -801,26 +815,30 @@ let dropdownItems: { label: string; onClick: () => void }[] = initialPath != '' && customUi?.topBar?.extraDeployOptions != false ? [ - { - label: 'Deploy & Stay here', - onClick: () => { - handleEditScript(true) - } - }, - { - label: 'Fork', - onClick: () => { - window.open(`/scripts/add?template=${initialPath}`) - } - }, - ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ...(!inSessionPane ? [ { - label: 'Edit in workspace fork', + label: 'Deploy & Stay here', onClick: () => { - window.open(buildForkEditUrl('script', initialPath)) + handleEditScript(true) } - } + }, + { + label: 'Fork', + onClick: () => { + window.open(`/scripts/add?template=${initialPath}`) + } + }, + ...(!isCloudHosted() && !isRuleActive('DisableWorkspaceForking') + ? [ + { + label: 'Edit in workspace fork', + onClick: () => { + window.open(buildForkEditUrl('script', initialPath)) + } + } + ] + : []) ] : []), ...(customUi?.topBar?.diff !== false && savedScript && diffDrawer @@ -852,7 +870,10 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.auto_kind + ...(!inSessionPane && + !script.draft_only && + script.kind === 'script' && + !script.auto_kind ? [ { label: 'Exit & See details', @@ -862,7 +883,7 @@ } ] : []), - ...(isWorkflowAsCode(script.content, script.language) + ...(!inSessionPane && isWorkflowAsCode(script.content, script.language) ? [ { label: 'Export as YAML/JSON', @@ -875,7 +896,11 @@ ] : [] - if (dropdownItems.length === 0 && isWorkflowAsCode(script.content, script.language)) { + if ( + !inSessionPane && + dropdownItems.length === 0 && + isWorkflowAsCode(script.content, script.language) + ) { dropdownItems = [ { label: 'Export as YAML/JSON', @@ -901,7 +926,11 @@ } let path: Path | undefined = $state(undefined) - let dirtyPath = $state(false) + // Seed "path is already chosen" so the summary→path auto-slug (which only + // runs for new scripts with initialPath == '') doesn't clobber a path the + // caller pre-assigned. The session preview opens AI-created scripts as new + // (empty initialPath) but with a path the AI already picked. + let dirtyPath = $state(initialPathChosen) let selectedTab: 'metadata' | 'runtime' | 'ui' | 'triggers' = $state( (() => { @@ -2091,6 +2120,7 @@ bind:assets={script.assets} bind:modules={script.modules} enablePreprocessorSnippet + {initialTestPanelCollapsed} />
{:else} diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index 9fe173f0ba..a281b266da 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -160,6 +160,11 @@ modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean + // When true the right-hand test/run pane mounts collapsed. The user + // can still expand it via `toggleTestPanel`. Defaults to false so the + // regular /scripts/edit route keeps its current open-by-default UX; + // the session preview opts in to save vertical real estate. + initialTestPanelCollapsed?: boolean } let { @@ -193,7 +198,8 @@ assets = $bindable(), modules = $bindable(undefined), editorBarRight, - enablePreprocessorSnippet = false + enablePreprocessorSnippet = false, + initialTestPanelCollapsed = false }: Props = $props() let initialArgs = structuredClone($state.snapshot(args)) @@ -1360,8 +1366,11 @@ // dynamic minimum below — so when the editor shrinks, the displayed test // pane grows to honor the new minimum without needing an effect. The code // pane's size is purely derived from it (100 - test). - let rawTestPanelSize = $state(30) - let storedTestPanelSize = untrack(() => rawTestPanelSize) + // `initialTestPanelCollapsed` seeds the raw value at 0 (collapsed) while + // keeping the "remembered" size at 30, so the user's first toggle expands + // the pane to a sensible width rather than 0. + let rawTestPanelSize = $state(untrack(() => (initialTestPanelCollapsed ? 0 : 30))) + let storedTestPanelSize = 30 const testPanelSize = $derived( rawTestPanelSize === 0 ? 0 : Math.max(rawTestPanelSize, testPaneMinPercent) ) diff --git a/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte new file mode 100644 index 0000000000..2ee01b4607 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte @@ -0,0 +1,162 @@ + + + +{#if kind === 'flow'} +
+ +
+{:else if hasContent} +
+ + + + +
+ {#if contentTab === 'content'} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {/if} +
+
+{:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} +
+ +
+ {/await} +{/if} diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index abee584afa..9136f52205 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -17,6 +17,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte' import SearchItems from '$lib/components/SearchItems.svelte' import { onMount, untrack } from 'svelte' import { @@ -30,6 +31,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level type WorkspaceItem, type WorkspaceItemKind } from './workspacePicker' + import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter' + import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' type Kind = WorkspaceItemKind type Item = WorkspaceItem @@ -72,8 +75,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // Sibling-popover open: melt-ui's `openFocus` runs once during the close→open // transition; the picker may not be mounted yet. Retry after settle. + // Also kicks off the initial scope's fetch — drill/goUp do the same from + // their respective branches, so `ensureLoaded` is always a callback + // reaction to user navigation, never a reactive consequence. onMount(() => { const t = setTimeout(focus, 50) + const initial = untrack(() => scope) + if (initial) { + if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(initial.kind) + } return () => clearTimeout(t) }) @@ -82,6 +93,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let scope = $state(untrack(() => initialScope)) let filter = $state('') + /** + * Canonical entry point for changing the picker's scope. Triggers the + * fetch for the kind(s) the new scope needs at the same point in time. + * Replaces the older "react to `scope` change via `$effect`" wiring, + * which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the + * effect ended up subscribed to the signal it fills — every fetch + * result re-fired it. With explicit callbacks the fetch is tied to + * the user's action, never to a reactive consequence of that action. + */ + function setScope(next: Scope) { + scope = next + if (!next) return + if (next.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(next.kind) + } + /** Tracks whether the last user action was mouse movement (true) or * keyboard nav (false). When false, row `mouseenter` events are ignored * — prevents the cursor from stealing the keyboard-driven highlight as @@ -90,10 +117,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * mounts under a stationary cursor doesn't clobber `initialHighlight`. */ let mouseActive = $state(false) - // Seed from cache so kinds already fetched in this session render on the - // first frame. Read once at mount: melt-ui mounts a fresh picker per - // popover open, so workspace changes are picked up at the next open - // without needing this seed to be reactive. + // Seed from the last fetched snapshot so kinds already fetched in this + // session render on the first frame. Each entry is replaced once + // `loadKind` returns fresh data — stale-while-revalidate, so deploys and + // AI-created drafts surface on the next open without explicit cache + // busting. let loaded = $state>>( (() => { if (!$workspaceStore) return {} @@ -109,8 +137,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level async function ensureLoaded(kind: Kind) { if (!$workspaceStore) return - if (loaded[kind]) return - loadingKind[kind] = true + // Always re-fetch. If we have nothing cached, show a spinner; if we do, + // keep displaying it and quietly swap to fresh data when it lands. + // `loaded[kind]` is read inside `untrack(...)` because this function is + // reachable from the search `$effect` below — without the untrack, + // that effect would subscribe to the signal `ensureLoaded` fills, and + // each `loaded[kind] = items` (proxy `set` notifies even when the ref + // is unchanged from cache) would refire it → runaway loop. Drill + // navigation goes through `setScope` directly so it isn't affected. + if (!untrack(() => loaded[kind])) loadingKind[kind] = true try { const items = await loadKind($workspaceStore, kind) loaded[kind] = items @@ -119,13 +154,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level } } - // Fetch the scope's kind on entry to a non-root level. The `'all'` scope - // needs every kind loaded since it merges items across them. - $effect(() => { - if (!scope) return - if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k) - else ensureLoaded(scope.kind) - }) + // Chat tools and session editor previews write drafts through + // `UserDraft` (workspace-scoped, localStorage-backed). Merge those into + // the picker so users can navigate to in-flight items that haven't been + // deployed yet. Filter to kinds the picker actually displays. + // + // Gated on the same dev flag as the rest of the sessions feature: without + // it there are no sessions, so the only UserDrafts present are the + // standalone editors' autosaves — surfacing those in the breadcrumb picker + // would be surprising (they'd appear as navigable items that 404 on the + // backend draft fetch). When the flag is off this is a no-op. + const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const + function aiDraftsForKind(k: Kind): Item[] { + if (!isGlobalAiEnabled()) return [] + if (!$workspaceStore) return [] + const targetType = KIND_TO_DRAFT_TYPE[k] + return listGlobalDrafts($workspaceStore) + .filter((d) => d.type === targetType) + .map((d) => ({ + path: d.path, + summary: d.summary ?? '', + kind: k, + // `raw_app` lives on the draft envelope for legacy/raw-app distinction. + raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined + })) + } // Searching is global → load every kind. $effect(() => { @@ -140,6 +193,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level leaves: Item[] } + /** Merge AI-created in-memory drafts into a kind's list. The AI may have + * scaffolded a script/flow/app via chat tools without the user saving + * yet — those drafts should be navigable from the picker. Existing items + * (same path) win to keep the backend's metadata (summary etc.). */ + function withAiDrafts(items: Item[], k: Kind): Item[] { + const ai = aiDraftsForKind(k) + if (ai.length === 0) return items + const known = new Set(items.map((it) => it.path)) + return items.concat(ai.filter((d) => !known.has(d.path))) + } + /** Inject the currently-edited item into a kind's list at its live path, * dropping the saved entry when a draft rename is in progress. Other kinds * pass through untouched. */ @@ -207,7 +271,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * cached. */ function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] { if (!kinds.includes(k)) return [] - const items = withCurrent(list ?? [], k) + const items = withAiDrafts(withCurrent(list ?? [], k), k) if (items.length === 0) return [] return buildTreeFromItems(items) } @@ -219,7 +283,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * one folder hierarchy. Each leaf still carries its real kind, so the row * icon and `editPathFor` routing still work; folders contain a mix. */ const allTree = $derived.by(() => { - const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k)) + const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k)) return merged.length === 0 ? [] : buildTreeFromItems(merged) }) @@ -255,7 +319,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let allItems = $derived( kinds.flatMap((k) => - withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` })) + withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({ + ...it, + _key: `${k}:${it.path}` + })) ) ) @@ -383,9 +450,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level function drill(entry: Entry) { if (entry.type === 'kind') { - scope = { kind: entry.kind } + setScope({ kind: entry.kind }) } else if (entry.type === 'dir') { - scope = { kind: entry.kind, dir: entry.node.fullPath } + setScope({ kind: entry.kind, dir: entry.node.fullPath }) } else { pick(entry.item) } @@ -397,13 +464,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // just left, so the user sees where they came from. if (!scope.dir) { const leaving = kindKey(scope.kind) - scope = undefined + setScope(undefined) highlightedKey = leaving return } const leaving = dirKey(scope.kind, scope.dir) const parent = parentDirPath(scope.dir) - scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind } + setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }) highlightedKey = leaving } @@ -528,32 +595,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level {#snippet leafRow(it: Item, secondary: string, baseClass: string)} {@const key = leafKey(it)} - {@const isHl = key === highlightedKey} - {@const isCur = isCurrent(it)} - + /> {/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte new file mode 100644 index 0000000000..8ddd3fa317 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -0,0 +1,148 @@ + + + + + +{#if href} + + +
+ {#if summary} +
{summary}
+
{secondary}
+ {:else} +
{secondary}
+ {/if} +
+ {#if extras} +
+ {@render extras()} +
+ {/if} +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index d6b2a7818d..cd9acdbffa 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -3,7 +3,7 @@ const bubble = createBubbler() import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import { twMerge } from 'tailwind-merge' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -79,20 +79,29 @@ gotoFn = (path: string, opt?: Record) => window.history.pushState(null, '', path), unsavedConfirmationModal, onSavedNewAppPath, + onNavigate, initialRevs }: AppEditorProps = $props() migrateApp(untrack(() => app)) + // Inside a session pane the AIChatManager is injected via context. Sessions + // have their own state machinery (sessionRuntime + per-fork backend), and + // the user-facing $workspaceStore stays on the main workspace even when + // the session is editing in a fork — so a UserDraft handle here would + // share its LS key with the regular /apps/edit route and clobber both + // sides' autosaves. Skip UserDraft entirely in that case. + const inSessionPane = !!getContext('aiChatManager') + const appDraftPath = newApp ? '' : (path ?? '') - const appDraftHandle = UserDraft.use('app', appDraftPath) + const appDraftHandle = inSessionPane ? undefined : UserDraft.use('app', appDraftPath) // Prefer the persisted autosave over the prop when both exist (e.g. // /apps/add reload: the route always initializes `app` to an empty // template, but the user's last session is sitting in LS under the // empty-path entry). The route is responsible for wiping the entry // (`UserDraft.remove`) when it wants to force a fresh start — // `?nodraft=true`, template/hub loads, etc. - const stateApp = $state(untrack(() => appDraftHandle.draft ?? app)) + const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app)) const appStore = writable(stateApp) // Captured once on mount: the load-time revs are only used as the // seed meta on the very first persist of this entry. After that the @@ -112,6 +121,7 @@ let firstMirror = true $effect(() => { readFieldsRecursively(stateApp) + if (!appDraftHandle) return untrack(() => { // Resolve the meta to attach BEFORE the wipe — the wipe clears // in-memory meta and would otherwise force-seed `initialRevs` @@ -884,6 +894,7 @@ rightPanelHidden={rightPanelSize === 0} bottomPanelHidden={runnablePanelSize === 0} {onSavedNewAppPath} + {onNavigate} onShowLeftPanel={() => showLeftPanel()} onShowRightPanel={() => showRightPanel()} onShowBottomPanel={() => showBottomPanel()} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 7a55f28860..5bbd0d17c7 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -64,7 +64,7 @@ import DebugPanel from './contextPanel/DebugPanel.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' - import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' import { goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' @@ -110,6 +110,7 @@ onHideRightPanel?: () => void onHideLeftPanel?: () => void onHideBottomPanel?: () => void + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void } let { @@ -130,7 +131,8 @@ onShowBottomPanel, onHideLeftPanel, onHideRightPanel, - onHideBottomPanel + onHideBottomPanel, + onNavigate = undefined }: Props = $props() /** Mirror of the path the user is editing in the pen popover. Initialized @@ -170,6 +172,14 @@ const { history, jobsDrawerOpen, refreshComponents } = getContext('AppEditorContext') + // Sessions inject an AIChatManager via context; AppEditor skips its + // UserDraft handle in that case, so the cleanup calls here must skip too + // (otherwise we'd wipe a non-session tab's autosave at the same path). The + // session-side equivalent is the View's `onDeploy` → + // `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads + // the preview to the deployed version. + const inSessionPane = !!getContext('aiChatManager') + const loading = $state({ publish: false, save: false, @@ -229,7 +239,7 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) onSavedNewAppPath?.(path) } catch (e) { sendUserToast('Error creating app', e) @@ -313,7 +323,6 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) - invalidatePicker($workspaceStore!, 'app') invalidateWorkspacePaths($workspaceStore!) savedApp = { summary: $summary, @@ -330,7 +339,7 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) if ($appPath !== npath) { onSavedNewAppPath?.(npath) } @@ -406,7 +415,7 @@ // The initial draft was promoted to a real path on the backend — // drop the autosave keyed on the prior (possibly empty) path so // a future "+ App" click opens on a clean slate. - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) onSavedNewAppPath?.(newEditedPath) } catch (e) { sendUserToast('Error saving initial draft', e) @@ -497,7 +506,7 @@ } sendUserToast('Draft saved') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) loading.saveDraft = false if (newApp || savedApp.draft_only) { onSavedNewAppPath?.(newEditedPath || path) @@ -1006,7 +1015,7 @@ bind:path={newEditedPath} savedPath={$appPath || newPath || undefined} kind="app" - onNavigate={(item) => goto(editPathFor(item))} + onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} />
{#if $app} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 02ce70f64f..2294554f3e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -139,7 +139,7 @@ }) $effect(() => { - appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl()) + appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl()) }) @@ -264,10 +264,10 @@ policy.execution_mode = e.detail ? 'anonymous' : 'publisher' setPublishState() }} - disabled={appPath == ''} + disabled={!savedApp} />
- {#if appPath == ''} + {#if !savedApp} {:else if secretUrlHref}
diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 2fe6536aa8..64b08d621e 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -164,6 +164,8 @@ export interface AppEditorProps { gotoFn?: (path: string, opt?: Record | undefined) => void unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void + /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** * Backend revs at the load that produced `app`. Used as the seed * `UserDraft` meta on the first local autosave: until the handle has diff --git a/frontend/src/lib/components/common/EditableInput.svelte b/frontend/src/lib/components/common/EditableInput.svelte index e772247c0a..e85625c58f 100644 --- a/frontend/src/lib/components/common/EditableInput.svelte +++ b/frontend/src/lib/components/common/EditableInput.svelte @@ -78,6 +78,15 @@ this component just proposes new values. }) } + // External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap + // stays active for a brief window after the menu closes — focusing our + // input during that window causes checkFocusIn to slam focus back out, which + // fires onblur=save and instantly closes the edit. A 50ms defer is enough + // for Melt's trap to release. + export function edit() { + setTimeout(startEditing, 50) + } + function save() { // Re-entry guard: Enter calls `save()` and sets `editing = false`, // which unmounts the `` and synchronously fires its `blur` diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index 07ccd1efb1..c6366113c9 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -3,30 +3,61 @@ import { untrack } from 'svelte' import { type ScriptLang } from '$lib/gen' import { dbSchemas, userStore, workspaceStore } from '$lib/stores' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() import { base } from '$lib/base' import HideButton from '$lib/components/apps/editor/settingsPanel/HideButton.svelte' import { SUPPORTED_CHAT_SCRIPT_LANGUAGES } from './script/core' import { copilotInfo, copilotSessionModel } from '$lib/aiStore' + let { + hideHeader = false, + hideModeSelector = false, + forceDisabled = false, + forceDisabledMessage = '', + wideLayout = false, + emptyHint, + inputPreface + }: { + hideHeader?: boolean + hideModeSelector?: boolean + // External "you can't type here" override. Used by sessions when + // the session's committed workspace was deleted/archived so the + // chat is effectively read-only until the user moves or discards + // the session. Wins over the internal disabled derivation. + forceDisabled?: boolean + forceDisabledMessage?: string + // Forwarded to AIChatDisplay. When true, the messages / input + // columns are centered in a max-w-3xl px-8 box. Sessions opt + // in; the narrow global-chat panel leaves it off. + wideLayout?: boolean + emptyHint?: import('svelte').Snippet + inputPreface?: import('svelte').Snippet + } = $props() + const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) const hasCopilot = $derived($copilotInfo.enabled) const disabled = $derived( - !hasCopilot || + forceDisabled || + !hasCopilot || (aiChatManager.mode === AIMode.SCRIPT && aiChatManager.scriptEditorOptions?.lang && !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang)) ) const disabledMessage = $derived( - !hasCopilot - ? isAdmin - ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` - : 'Ask an admin to enable Windmill AI in this workspace to use this chat' - : aiChatManager.mode === AIMode.SCRIPT && - aiChatManager.scriptEditorOptions?.lang && - !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) - ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` - : '' + forceDisabled + ? forceDisabledMessage + : !hasCopilot + ? isAdmin + ? `Enable Windmill AI in your [workspace settings](${base}/workspace_settings?tab=ai) to use this chat` + : 'Ask an admin to enable Windmill AI in this workspace to use this chat' + : aiChatManager.mode === AIMode.SCRIPT && + aiChatManager.scriptEditorOptions?.lang && + !SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(aiChatManager.scriptEditorOptions.lang) + ? `Windmill AI does not support the ${aiChatManager.scriptEditorOptions.lang} language yet.` + : '' ) const suggestions = [ @@ -53,6 +84,10 @@ aiChatManager.sendRequest(options) } + export function focusInput() { + aiChatDisplay?.focusInput() + } + const historyManager = aiChatManager.historyManager let aiChatDisplay: AIChatDisplay | undefined = $state(undefined) @@ -129,4 +164,9 @@ {disabled} {disabledMessage} {suggestions} + {hideHeader} + {hideModeSelector} + {wideLayout} + {emptyHint} + {inputPreface} > diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 6e2f21ce96..5b0e549dd0 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -61,7 +61,7 @@ import { runChatLoop } from './chatLoop' import type { ReviewChangesOpts } from './monaco-adapter' import { getCurrentModel, tryGetCurrentModel, getCombinedCustomPrompt } from '$lib/aiStore' import type { WorkspaceMutationTarget } from './workspaceTools' -import { globalTools, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' +import { globalToolsFor, prepareGlobalSystemMessage, prepareGlobalUserMessage } from './global/core' import { isGlobalAiEnabled } from './global/gate' // If the estimated token usage is greater than the model context window - the threshold, we delete the oldest message @@ -208,13 +208,26 @@ export class AIChatManager { private userQuestionCallbacks = new Map void>() private appDatatablesRefreshTimeout: ReturnType | undefined = undefined + disabledModes: Partial> = $state({}) + // Set by AI sessions. Enables the session-only preview tools (open_preview / + // get_preview_status) and their system-prompt guidance in GLOBAL mode; the + // global side-panel chat leaves it false so those tools aren't offered. + isSessionChat = false + // The session this manager belongs to (session chats only). Carried into the + // tool `helpers` in GLOBAL mode so the preview/deploy tools dispatch to THIS + // session rather than the UI-active one — keeps backgrounded sessions isolated. + sessionId: string | undefined = undefined + allowedModes: Record = $derived({ - script: this.flowAiChatHelpers === undefined && this.scriptEditorOptions !== undefined, - flow: this.flowAiChatHelpers !== undefined, - app: this.appAiChatHelpers !== undefined, - navigator: true, - ask: true, - API: true, + script: + this.flowAiChatHelpers === undefined && + this.scriptEditorOptions !== undefined && + !this.disabledModes.script, + flow: this.flowAiChatHelpers !== undefined && !this.disabledModes.flow, + app: this.appAiChatHelpers !== undefined && !this.disabledModes.app, + navigator: !this.disabledModes.navigator, + ask: !this.disabledModes.ask, + API: !this.disabledModes.API, // Dev-only gate. See `./global/gate.ts` for how to enable. global: isAIModeVisible(AIMode.GLOBAL) }) @@ -495,9 +508,11 @@ export class AIChatManager { this.helpers = {} } else if (mode === AIMode.GLOBAL) { const customPrompt = getCombinedCustomPrompt(mode) - this.systemMessage = prepareGlobalSystemMessage(customPrompt) - this.tools = [...globalTools] - this.helpers = {} + this.systemMessage = prepareGlobalSystemMessage(customPrompt, { + previewTools: this.isSessionChat + }) + this.tools = globalToolsFor({ sessionPreview: this.isSessionChat }) + this.helpers = this.isSessionChat ? { sessionId: this.sessionId } : {} } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) @@ -795,6 +810,12 @@ export class AIChatManager { } } + // Optional pre-flight hook called once per send, after validation but + // before any UI state mutates or backend calls go out. Sessions use + // this to commit/materialise the workspace (creating a staged fork via + // the API) so the first message targets the correct workspace. + beforeSend?: () => Promise | void + sendRequest = async ( options: { removeDiff?: boolean @@ -819,6 +840,24 @@ export class AIChatManager { if (!this.instructions.trim()) { return } + if (this.beforeSend) { + try { + await this.beforeSend() + } catch (e) { + // beforeSend commits the session's workspace before the first + // message hits the backend. If it throws, sending anyway would + // silently target the wrong workspace (typically the parent), so + // abort and tell the user — their message text stays in the input. + console.error('AIChatManager beforeSend hook failed', e) + sendUserToast( + `Could not prepare the session before sending: ${ + e instanceof Error ? e.message : String(e) + }. Your message was not sent — please try again.`, + true + ) + return + } + } try { const oldSelectedContext = this.contextManager?.getSelectedContext() ?? [] if (this.mode === AIMode.SCRIPT || this.mode === AIMode.FLOW) { diff --git a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte index 27adb56ef3..2ab480a706 100644 --- a/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte +++ b/frontend/src/lib/components/copilot/chat/AiChatLayout.svelte @@ -76,7 +76,7 @@ onClick={() => onMenuOpen?.()} startIcon={{ icon: Menu }} iconOnly - > + />
{@render children?.()} @@ -96,5 +96,13 @@ {/if} {:else} - {@render children?.()} +
+ {@render children?.()} +
{/if} diff --git a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte index e59ed4b513..5b4258d4e7 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -3,9 +3,16 @@ import { CircleHelp } from 'lucide-svelte' import Button from '$lib/components/common/button/Button.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' - import { aiChatManager } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' import type { UserQuestionDisplay } from './shared' + // Sessions inject a per-pane `AIChatManager` via context; outside of + // sessions getAiChatManager falls back to the global singleton. Without + // this, answers clicked inside a session would dispatch to the singleton's + // pending callbacks map (which doesn't have the session manager's question + // callback), and the AI loop would stall. + const aiChatManager = getAiChatManager() + interface Props { toolCallId: string userQuestion: UserQuestionDisplay diff --git a/frontend/src/lib/components/copilot/chat/ChatMode.svelte b/frontend/src/lib/components/copilot/chat/ChatMode.svelte index 5a40c49e36..89b714b80f 100644 --- a/frontend/src/lib/components/copilot/chat/ChatMode.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatMode.svelte @@ -2,7 +2,10 @@ import { ChevronDown } from 'lucide-svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import Button from '$lib/components/common/button/Button.svelte' - import { aiChatManager, AIMode } from './AIChatManager.svelte' + import { AIMode } from './AIChatManager.svelte' + import { getAiChatManager } from './aiChatManagerContext' + + const aiChatManager = getAiChatManager() const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode' diff --git a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte index 4df548192a..65abb0e1c5 100644 --- a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte +++ b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte @@ -1,7 +1,9 @@ diff --git a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte index 2b9f4a09f1..56a594ff1e 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleComponent.svelte @@ -867,6 +867,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { @@ -930,6 +931,7 @@ bind:this={editor} class="h-full relative" code={flowModule.value.content} + syncExternalCode scriptLang={flowModule?.value?.language} automaticLayout={true} cmdEnterAction={async () => { diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 563a387184..fbe07ac7b8 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -200,6 +200,9 @@ markRemovedAsShadowed?: boolean controlsPosition?: 'top' | 'bottom' outerDivClass?: string + /** Fires when the computed graph height changes. Diff views can use + * this to equalize heights of side-by-side graphs. */ + onHeight?: (height: number) => void } let { @@ -273,7 +276,8 @@ onMoveMultiple = undefined, movingIds = undefined, controlsPosition = 'top', - outerDivClass = '' + outerDivClass = '', + onHeight = undefined }: Props = $props() // Initialize note manager with fine-grained reactivity @@ -759,6 +763,7 @@ const computed = maxBottom - minY height = Math.max(Math.min(computed, maxHeight ?? computed), minHeight) } + onHeight?.(height) } $effect(() => { diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 7249f0ae05..8121ca8b70 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -66,6 +66,9 @@ } | undefined diffDrawer?: DiffDrawer | undefined + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void + /** Fired after a successful deploy; the session preview reloads on it. */ + onDeploy?: (e: { path: string }) => void /** Initial collapsed state for the file/runnable sidebar. The user's * toggled preference is persisted under `sidebarStorageKey`; this prop * only seeds the very first open. */ @@ -75,6 +78,14 @@ * preference. */ sidebarStorageKey?: string liveEditorDraftStoragePath?: string + /** Initial value for the "Split with Preview" tab-bar toggle. Defaults + * to `true` (split mode, preview always pinned to the right). Set + * `false` when the editor mounts inside a context that wants single- + * view by default with the Preview tab selected — e.g. session + * previews, where the editor pane is already narrow. The user can + * still toggle the mode after mount; this prop only seeds the + * initial state. */ + defaultSplitWithPreview?: boolean } let { @@ -88,9 +99,12 @@ newPath = undefined, savedApp = $bindable(undefined), diffDrawer = undefined, + onNavigate, + onDeploy = undefined, defaultSidebarCollapsed = false, sidebarStorageKey = 'raw-app-sidebar-collapsed', - liveEditorDraftStoragePath = undefined + liveEditorDraftStoragePath = undefined, + defaultSplitWithPreview = true }: Props = $props() export const version: number | undefined = undefined @@ -225,7 +239,9 @@ } let tabs: TabItem[] = $state([previewTab]) let activeTabId: string = $state(PREVIEW_TAB_ID) - let splitWithPreview: boolean = $state(true) + // Seed from the prop, then own the state locally so the user's toggle + // after mount sticks even if the prop reference changes. + let splitWithPreview: boolean = $state(untrack(() => defaultSplitWithPreview)) const activeTabKind = $derived<'file' | 'runnable' | 'preview'>( activeTabId === PREVIEW_TAB_ID ? 'preview' @@ -255,11 +271,23 @@ const showRunnable = $derived(activeTabKind === 'runnable') // Mount the UI Builder iframe the first time a file is shown (paneA has // width then; mounting it at 0-width breaks the VS Code workbench), and - // keep it mounted so tab switches don't reload it. + // keep it mounted so tab switches don't reload it. Mount it as soon as + // either pane needs it: `showSource` for the source-editor view, OR the + // preview tab is active — the Preview iframe is fed by `preview` + // postMessages bundled by the UI Builder iframe, so it needs to be + // mounted even when the user opens the editor straight on Preview (e.g. + // session previews seeded with `defaultSplitWithPreview=false`). let iframeShouldMount = $state(false) $effect(() => { - if (showSource) iframeShouldMount = true + if (showSource || activeTabKind === 'preview') iframeShouldMount = true }) + // Width of the editor area (both inner panes). The UI Builder iframe is + // pre-mounted while it's the inactive tab so the editor is ready instantly; + // but the VS Code workbench inside crashes if it boots at 0 size. So while + // inactive we keep the iframe at this real width and hide it with + // `visibility` instead of collapsing it — Monaco boots correctly and + // revealing a file is just an unhide (no reload, no relayout, no latency). + let editorAreaWidth = $state(0) // Inner pane sizes are a pure function of mode + active tab → derived. // `paneARatio` is the user's last manual split drag (set by rememberPaneDrag). @@ -994,7 +1022,11 @@ ensureFileTab(selectedDocument) // Don't auto-activate — the user's tab choice wins. // But if no file tab is currently active, fall in line. - if (activeTabKind === 'preview' && tabs.length === 2) { + // Skip this auto-activation in single-view-with-preview + // mode (the caller seeded `defaultSplitWithPreview=false` + // because Preview is the intended starting tab); the + // iframe's first setActiveDocument shouldn't fight that. + if (splitWithPreview && activeTabKind === 'preview' && tabs.length === 2) { activateTab(id) } } @@ -1158,9 +1190,14 @@ }) }) - // Open a default file on mount (boots the iframe; avoids a blank preview). - // Layout isn't persisted — each open starts fresh in split mode. + // Open a default file on mount (boots the iframe in split mode and gives + // the user something to edit on the left). When the caller seeded + // `defaultSplitWithPreview=false` we instead want the Preview tab as the + // only-visible / active surface, so skip the file-tab activation — the + // iframe still boots via `populateFiles`/`setFilesInIframe` even without + // a selected document. onMount(() => { + if (!splitWithPreview) return if (tabs.length === 1) { const def = pickDefaultFile(files) if (def) activateTab(ensureFileTab(def)) @@ -1332,6 +1369,8 @@ {data} {runnables} {getBundle} + {onNavigate} + {onDeploy} canUndo={historyManager.canUndo} canRedo={historyManager.canRedo} onUndo={handleUndo} @@ -1415,6 +1454,7 @@ Preview previously hid every tab. -->
-
+ +
{#if iframeShouldMount}