diff --git a/.github/workflows/git-sync-test.yml b/.github/workflows/git-sync-test.yml index 09f69bb701..12b75932ce 100644 --- a/.github/workflows/git-sync-test.yml +++ b/.github/workflows/git-sync-test.yml @@ -8,6 +8,7 @@ on: - "backend/windmill-git-sync/**" - "backend/windmill-api-integration-tests/tests/git_sync*" - "backend/ee-repo-ref.txt" + - "backend/windmill-common/src/workspaces.rs" - "integration_tests/test/git_sync_test.py" - ".github/workflows/git-sync-test.yml" pull_request: @@ -16,6 +17,7 @@ on: - "backend/windmill-git-sync/**" - "backend/windmill-api-integration-tests/tests/git_sync*" - "backend/ee-repo-ref.txt" + - "backend/windmill-common/src/workspaces.rs" - "integration_tests/test/git_sync_test.py" - ".github/workflows/git-sync-test.yml" @@ -49,7 +51,7 @@ jobs: echo "$CHANGED_FILES" # Direct git sync file changes — always relevant - if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then + if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then echo "should_run=true" >> "$GITHUB_OUTPUT" echo "Relevant: direct git sync file changes" exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbf6cb922..193472c590 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [1.714.0](https://github.com/windmill-labs/windmill/compare/v1.713.1...v1.714.0) (2026-06-02) + + +### Features + +* add global ai chat test tools ([#9391](https://github.com/windmill-labs/windmill/issues/9391)) ([5c20d6b](https://github.com/windmill-labs/windmill/commit/5c20d6b4f79f2ccc1987ce7fdaf74e6b8f697846)) +* add workspace datatable tools to global AI chat mode ([#9395](https://github.com/windmill-labs/windmill/issues/9395)) ([943ef6e](https://github.com/windmill-labs/windmill/commit/943ef6eb2089f4b744cfa7945ce47f7f3b361ec7)) +* **flow-ai:** constrain flow-group colors to the NoteColor palette ([#9343](https://github.com/windmill-labs/windmill/issues/9343)) ([e4213c1](https://github.com/windmill-labs/windmill/commit/e4213c1ab8c448f492f372580f5c9df37e33fffc)) +* **frontend:** surface local drafts in drawer editors with an unsaved-changes banner ([#9335](https://github.com/windmill-labs/windmill/issues/9335)) ([075faab](https://github.com/windmill-labs/windmill/commit/075faabf3bba16a10a02ae3973008e5a13473085)) +* handle CTRL_BREAK_EVENT for graceful shutdown on Windows ([#9400](https://github.com/windmill-labs/windmill/issues/9400)) ([2e14456](https://github.com/windmill-labs/windmill/commit/2e1445616a412c5112ad2247b4087c7ddc218845)) +* refine ask-user-question chat display and keyboard nav ([#9392](https://github.com/windmill-labs/windmill/issues/9392)) ([1275487](https://github.com/windmill-labs/windmill/commit/1275487f028d4c74a9eeb18981ed05c225505be0)) +* sessions page with isolated AI chat + flow editor ([#9034](https://github.com/windmill-labs/windmill/issues/9034)) ([eadeac2](https://github.com/windmill-labs/windmill/commit/eadeac248bd022c2796cfe638eb617c6143b8fc4)) + + +### Bug Fixes + +* **cli:** make encryption key push non-interactive-safe + add --skip-reencrypt-on-key-change ([#9402](https://github.com/windmill-labs/windmill/issues/9402)) ([e356bb1](https://github.com/windmill-labs/windmill/commit/e356bb1f5df92eca3fbb0ca2114b9f4c32d4c496)) +* **cli:** stop git-sync promotion deploys from dropping triggers/schedules ([#9403](https://github.com/windmill-labs/windmill/issues/9403)) ([24e3ef2](https://github.com/windmill-labs/windmill/commit/24e3ef27be8498fb820c228a52febf6a0a91b487)) +* **frontend:** align Monaco editor font size with text-xs ([#9161](https://github.com/windmill-labs/windmill/issues/9161)) ([de76668](https://github.com/windmill-labs/windmill/commit/de76668c10c04abe8771a8ca7bba7b2259819a1c)) +* resolve username rename failing on apps with runnable deps ([#9401](https://github.com/windmill-labs/windmill/issues/9401)) ([e8ad53d](https://github.com/windmill-labs/windmill/commit/e8ad53dae92597f5a1a8b76f38a7d8c24f578a47)) + + +### Performance Improvements + +* **python:** add --compile-bytecode to uv pip install ([#9393](https://github.com/windmill-labs/windmill/issues/9393)) ([c19441b](https://github.com/windmill-labs/windmill/commit/c19441bc8cb2da064e4ad44d77dc04ab8bbb22ec)) + +## [1.713.1](https://github.com/windmill-labs/windmill/compare/v1.713.0...v1.713.1) (2026-06-01) + + +### Bug Fixes + +* **api:** handle multi-version scripts when removing granular ACL ([#9388](https://github.com/windmill-labs/windmill/issues/9388)) ([9d9c503](https://github.com/windmill-labs/windmill/commit/9d9c5038ce8b0016320a670c434ef9063cb40441)) + +## [1.713.0](https://github.com/windmill-labs/windmill/compare/v1.712.0...v1.713.0) (2026-05-31) + + +### Features + +* **flows:** preserve step/subflow worker tags under a custom-tagged flow ([#9375](https://github.com/windmill-labs/windmill/issues/9375)) ([f0301b1](https://github.com/windmill-labs/windmill/commit/f0301b1605cee5fba4024803555333e6fa5c40ee)) +* **oauth:** support per-provider sandbox URLs ([#9358](https://github.com/windmill-labs/windmill/issues/9358)) ([2bf11dc](https://github.com/windmill-labs/windmill/commit/2bf11dcb15540c538ea2ac3cf70dcbe589060b4e)) + + +### Bug Fixes + +* **ai:** validate token_url for SSRF in OAuth credentials flow ([#9385](https://github.com/windmill-labs/windmill/issues/9385)) ([4b06881](https://github.com/windmill-labs/windmill/commit/4b06881918b76c5a411cc70b318e46efcc1393a7)) +* **api:** authorize and harden log-file reading endpoints ([#9368](https://github.com/windmill-labs/windmill/issues/9368)) ([bb90f4c](https://github.com/windmill-labs/windmill/commit/bb90f4ce83a0e60af219b11c12ab4fe1d13f47a4)) +* **apps:** make public apps opt into cross-origin isolation via wm_coep (GIT-884) ([#9374](https://github.com/windmill-labs/windmill/issues/9374)) ([2c0c2c4](https://github.com/windmill-labs/windmill/commit/2c0c2c467f163cd24c14c7be2db07af9cf2ce020)) +* **auth:** enforce monotonic privilege on user token lifecycle endpoints ([#9371](https://github.com/windmill-labs/windmill/issues/9371)) ([2ddf93d](https://github.com/windmill-labs/windmill/commit/2ddf93de96622b2a1b2b6f59398a7a1f59360efd)) +* batch encryption-key rotation into one git-sync job ([#9355](https://github.com/windmill-labs/windmill/issues/9355)) ([04a0897](https://github.com/windmill-labs/windmill/commit/04a08976aec4ba9b0516350316df303e9f96bfd3)) +* **cli:** preserve user drafts on sync push and permissioned-as ([#9381](https://github.com/windmill-labs/windmill/issues/9381)) ([b0c3b01](https://github.com/windmill-labs/windmill/commit/b0c3b01d31b0ab3a6566e1f5fec60e3e230cfadb)) +* **frontend:** sanitize user markdown to prevent stored XSS ([#9386](https://github.com/windmill-labs/windmill/issues/9386)) ([def01b8](https://github.com/windmill-labs/windmill/commit/def01b8ff6f331cc36ce02b947adc31c766042c4)) +* **security:** re-pin cached hub scripts to CVE-patched versions (+ HUB_BASE_URL override for cache mode) ([#9387](https://github.com/windmill-labs/windmill/issues/9387)) ([edf340c](https://github.com/windmill-labs/windmill/commit/edf340c4d4f18b16b142cb7deb67afa586f10946)) + +## [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/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/ai_evals/README.md b/ai_evals/README.md index 6982d70da9..d5fe1661a2 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -56,7 +56,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose bun run cli -- run flow --record -GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro +GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-3-flash-preview WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview bun run cli -- run global global-test1-script-create bun run cli -- run cli bun-hello-script @@ -88,15 +88,16 @@ Today: - `sonnet` - `opus` - `4o` -- `gemini-flash` -- `gemini-pro` +- `gpt-5.5` - `gemini-3-flash-preview` - `gemini-3.1-pro-preview` +- `deepseek-v4-flash` +- `deepseek-v4-pro` Notes: -- the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5` -- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases +- the command also prints accepted alias spellings such as `gpt-4o`, `gpt-55`, `claude-opus-4.6`, and `claude-haiku-4.5` +- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, Gemini, and DeepSeek-backed aliases - `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there - the judge model is separate and currently defaults to `claude-sonnet-4-6` @@ -142,6 +143,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 +199,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 5e00dd6f34..058adc3644 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -7,8 +7,12 @@ 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 { 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"; @@ -24,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; @@ -38,6 +57,7 @@ export interface GlobalEvalResult { export interface GlobalEvalOptions { workspaceFixtures?: BenchmarkWorkspaceRunnables; + liveEditorDrafts?: GlobalLiveEditorDraftFixture[]; model?: string; maxIterations?: number; provider?: AIProvider; @@ -55,19 +75,26 @@ export async function runGlobalEval( options.workspaceRoot ?? (await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-"))); - globalDraftStore.clearDrafts(workspaceRoot); + 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, - getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }), + getOutput: () => ({ drafts: listGlobalDrafts(workspaceRoot) }), onAssistantMessageStart: options.runContext?.onAssistantMessageStart, onAssistantToken: options.runContext?.onAssistantChunk, onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd, @@ -94,7 +121,8 @@ export async function runGlobalEval( tokenUsage: rawResult.tokenUsage, }; } finally { - globalDraftStore.clearDrafts(workspaceRoot); + clearGlobalDrafts(workspaceRoot); + clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); unregisterBenchmarkWorkspaceRunnables(workspaceRoot); if (!options.workspaceRoot) { await rm(workspaceRoot, { recursive: true, force: true }); @@ -102,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/adapters/frontend/core/shared/providerConfig.test.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts index 819300bd62..01a55e048e 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts @@ -21,9 +21,9 @@ describe("proxy helpers", () => { describe("resolveEvalModelProvider", () => { it("infers googleai from Gemini model ids", () => { - expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({ + expect(resolveEvalModelProvider("gemini-3-flash-preview")).toEqual({ provider: "googleai", - model: "gemini-2.5-flash", + model: "gemini-3-flash-preview", }); }); @@ -35,9 +35,11 @@ describe("resolveEvalModelProvider", () => { }); it("preserves an explicit provider", () => { - expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({ + expect( + resolveEvalModelProvider("gemini-3.1-pro-preview", "googleai"), + ).toEqual({ provider: "googleai", - model: "gemini-2.5-pro", + model: "gemini-3.1-pro-preview", }); }); }); 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/flow.yaml b/ai_evals/cases/flow.yaml index af7added38..5f4abafc48 100644 --- a/ai_evals/cases/flow.yaml +++ b/ai_evals/cases/flow.yaml @@ -8,6 +8,9 @@ args: a: 4 b: 5 + toolExpect: + requiredToolsUsed: + - test_run_flow judgeChecklist: - "the flow takes `a` and `b` as inputs" - "the main step is named `sum_numbers`" @@ -25,6 +28,9 @@ args: a: 2 b: 3 + toolExpect: + requiredToolsUsed: + - test_run_flow judgeChecklist: - "the flow takes `a` and `b` as inputs" - "the main step is named `sum_numbers`" @@ -42,6 +48,9 @@ args: a: 7 b: 8 + toolExpect: + requiredToolsUsed: + - test_run_flow judgeChecklist: - "the parent flow takes `a` and `b` as inputs" - "the main step is named `call_add_numbers`" @@ -426,6 +435,7 @@ - return_schedule_status toolExpect: requiredToolsUsed: + - test_run_flow - create_schedule toolCallArgs: - tool: create_schedule @@ -453,6 +463,7 @@ - webhook_response toolExpect: requiredToolsUsed: + - test_run_flow - create_trigger toolCallArgs: - tool: create_trigger diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index b4526f8a85..732ca7f2e9 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -87,3 +87,534 @@ - 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 + +- 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 + +- id: global-test15-human-postgres-resource + prompt: |- + I'm wiring the eval reporting database into this workspace. + Can you stage a Postgres connection for it in the shared evals/global folder? + Use host `reports-db.internal`, port 5432, database `evals_reporting`, user `report_reader`, and password `pg-redacted-reporting-password`. + Keep the credentials safe. + This is just draft work for now. + runtime: + maxTurns: 10 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - report + - password + valueIncludes: + - "true" + - report + - type: resource + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - report + valueIncludes: + - postgres + - reports-db.internal + - "5432" + - evals_reporting + - report_reader + - "$var:" + valueExcludes: + - pg-redacted-reporting-password + toolExpect: + requiredToolsUsed: + - write_variable + - search_resource_types + - write_resource + forbiddenToolsUsed: + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - pg-redacted-reporting-password + skipJudge: true + judgeChecklist: + - creates a Postgres resource draft for the eval reporting database + - creates a secret variable draft for the database password + - puts the drafts in sensible eval/global reporting-related paths + - uses the requested host, port, database, and user + - references the secret variable from the resource instead of embedding the password + - leaves the work as a draft only + +- id: global-test16-human-visible-variable + prompt: |- + We keep reusing a 30 day trial cutoff in eval notification jobs. + Can you stage that as a normal workspace variable in the shared evals/global folder, with a short description so people know what it controls? + It is not a secret. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - trial + valueIncludes: + - "30" + - "false" + - trial + toolExpect: + requiredToolsUsed: + - write_variable + forbiddenToolsUsed: + - write_resource + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates exactly one non-secret variable draft for the trial cutoff + - stores the value 30 + - chooses a sensible eval/global path related to trials or notifications + - includes a useful description of what the value controls + - does not create resources, schedules, triggers, or deployed workspace changes + +- id: global-test17-human-schedule-existing-helper + prompt: |- + The workspace already has a report digest helper. + Can you stage a weekday 8:30 AM UTC run for it with `dry_run` turned on? + I only want the schedule draft for review. + initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: schedule + pathIncludes: + - digest + valueIncludes: + - f/evals/global/send_report_digest + - UTC + - dry_run + - "true" + toolExpect: + requiredToolsUsed: + - list_workspace_items + - write_schedule + forbiddenToolsUsed: + - write_script + - write_flow + - write_resource + - write_variable + - write_trigger + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - finds the existing report digest helper rather than creating a new script or flow + - creates one schedule draft for that helper + - schedules it for weekdays around 08:30 UTC + - passes dry_run as true + - leaves only the schedule draft for review + +- id: global-test18-human-slack-resource-with-secret + prompt: |- + I'm preparing Slack notifications for eval failures. + Can you stage a Slack connection in the shared evals/global folder? + The bot token is `xoxb-redacted-test-token`; keep it safe. + Don't deploy anything yet. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - slack + - token + valueIncludes: + - "true" + - type: resource + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - slack + valueIncludes: + - slack + - "$var:" + valueExcludes: + - xoxb-redacted-test-token + toolExpect: + requiredToolsUsed: + - write_variable + - search_resource_types + - write_resource + forbiddenToolsUsed: + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - xoxb-redacted-test-token + skipJudge: true + judgeChecklist: + - creates a secret variable draft for the Slack bot token placeholder + - creates a Slack resource draft that references the secret variable instead of embedding the token + - keeps both drafts under a sensible eval/global Slack-related path + - does not create schedules, triggers, or deployed workspace changes diff --git a/ai_evals/cases/script.yaml b/ai_evals/cases/script.yaml index feae74dcda..f0910e939a 100644 --- a/ai_evals/cases/script.yaml +++ b/ai_evals/cases/script.yaml @@ -5,6 +5,9 @@ Keep it simple and do not add external dependencies. initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json + toolExpect: + requiredToolsUsed: + - test_run_script judgeChecklist: - uses the existing `name` input - returns a plain greeting string @@ -20,6 +23,7 @@ expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json toolExpect: requiredToolsUsed: + - test_run_script - create_schedule toolCallArgs: - tool: create_schedule @@ -44,6 +48,7 @@ expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json toolExpect: requiredToolsUsed: + - test_run_script - create_trigger toolCallArgs: - tool: create_trigger 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..05e2f1527b 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -14,6 +14,21 @@ describe("loadCases", () => { }, }, }); + expect(caseEntry?.toolExpect).toEqual({ + requiredToolsUsed: ["test_run_flow"], + }); + }); + + it("loads script and flow test tool expectations", async () => { + const scriptCases = await loadCases("script"); + const flowCases = await loadCases("flow"); + + expect(scriptCases.find((entry) => entry.id === "script-test1-greet-user")?.toolExpect).toEqual({ + requiredToolsUsed: ["test_run_script"], + }); + expect(flowCases.find((entry) => entry.id === "flow-test0-sum-two-numbers")?.toolExpect).toEqual({ + requiredToolsUsed: ["test_run_flow"], + }); }); it("loads the workspace-flow preference benchmark case", async () => { @@ -203,6 +218,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( @@ -210,7 +253,7 @@ describe("loadCases", () => { ); expect(caseEntry?.toolExpect).toEqual({ - requiredToolsUsed: ["create_schedule"], + requiredToolsUsed: ["test_run_script", "create_schedule"], toolCallArgs: [ { tool: "create_schedule", diff --git a/ai_evals/core/models.test.ts b/ai_evals/core/models.test.ts index a11fe40530..ba53c24592 100644 --- a/ai_evals/core/models.test.ts +++ b/ai_evals/core/models.test.ts @@ -2,15 +2,22 @@ import { describe, expect, it } from "bun:test"; import { resolveEvalModel } from "./models"; describe("resolveEvalModel", () => { + it("supports GPT-5.5 aliases for frontend evals", () => { + expect(resolveEvalModel("flow", "gpt-5.5").frontend).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + expect(resolveEvalModel("app", "gpt-55").frontend).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + expect(resolveEvalModel("script", "5.5").frontend).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + }); + it("supports Gemini aliases for frontend evals", () => { - expect(resolveEvalModel("flow", "gemini").frontend).toEqual({ - provider: "googleai", - model: "gemini-2.5-flash", - }); - expect(resolveEvalModel("app", "gemini-pro").frontend).toEqual({ - provider: "googleai", - model: "gemini-2.5-pro", - }); expect( resolveEvalModel("script", "gemini-3-flash-preview").frontend, ).toEqual({ @@ -37,8 +44,8 @@ describe("resolveEvalModel", () => { }); it("rejects Gemini aliases for cli evals", () => { - expect(() => resolveEvalModel("cli", "gemini")).toThrow( - "Model gemini-flash is not supported for cli mode", + expect(() => resolveEvalModel("cli", "gemini-3-flash-preview")).toThrow( + "Model gemini-3-flash-preview is not supported for cli mode", ); }); }); diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 054a27bc1c..295cd36135 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -88,21 +88,12 @@ export const EVAL_MODELS: EvalModelSpec[] = [ }, }, { - id: "gemini-flash", - label: "Gemini 2.5 Flash", - aliases: ["gemini", "gemini-flash", "gemini-2.5-flash"], + id: "gpt-5.5", + label: "GPT-5.5", + aliases: ["gpt-5.5", "gpt-55", "5.5"], frontend: { - provider: "googleai", - model: "gemini-2.5-flash", - }, - }, - { - id: "gemini-pro", - label: "Gemini 2.5 Pro", - aliases: ["gemini-pro", "gemini-2.5-pro"], - frontend: { - provider: "googleai", - model: "gemini-2.5-pro", + provider: "openai", + model: "gpt-5.5", }, }, { 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 2b42a0dfc5..ecc46591fc 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[]; @@ -324,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/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/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/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" + } + } + } + } + ] + } + } + ] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/report_digest_script.json b/ai_evals/fixtures/frontend/global/initial/report_digest_script.json new file mode 100644 index 0000000000..832b06712f --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/report_digest_script.json @@ -0,0 +1,23 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/send_report_digest", + "summary": "Build and send the eval report digest", + "description": "Returns a dry-run summary for eval report digest notifications.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "required": ["dry_run"] + }, + "content": "export async function main(dry_run: boolean) {\n return { dry_run, sent: !dry_run, message: dry_run ? 'Preview digest' : 'Digest sent' }\n}\n" + } + ] + } +} 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'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/.sqlx/query-b876f26ce90e30c3510eacddb03d9dd26fac05d18183f2d623ac91ea3876dd5c.json b/backend/.sqlx/query-b876f26ce90e30c3510eacddb03d9dd26fac05d18183f2d623ac91ea3876dd5c.json new file mode 100644 index 0000000000..e1a3640463 --- /dev/null +++ b/backend/.sqlx/query-b876f26ce90e30c3510eacddb03d9dd26fac05d18183f2d623ac91ea3876dd5c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account)\n VALUES ($1, $2, $3, true, '', '{}'::jsonb, NULL)\n ON CONFLICT (workspace_id, path) DO UPDATE SET value = EXCLUDED.value\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "b876f26ce90e30c3510eacddb03d9dd26fac05d18183f2d623ac91ea3876dd5c" +} diff --git a/backend/.sqlx/query-bf601a919de299e44e6e418b2e711e24909fc206b7fd48541483e6316fe002d5.json b/backend/.sqlx/query-bf601a919de299e44e6e418b2e711e24909fc206b7fd48541483e6316fe002d5.json new file mode 100644 index 0000000000..6ca4cbb60f --- /dev/null +++ b/backend/.sqlx/query-bf601a919de299e44e6e418b2e711e24909fc206b7fd48541483e6316fe002d5.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT j.id, j.args\n FROM v2_job j\n JOIN v2_job_queue q ON j.id = q.id\n WHERE j.runnable_path = $1\n AND j.kind = 'deploymentcallback'\n AND j.workspace_id = 'test-workspace'\n ORDER BY j.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "args", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "bf601a919de299e44e6e418b2e711e24909fc206b7fd48541483e6316fe002d5" +} diff --git a/backend/.sqlx/query-f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886.json b/backend/.sqlx/query-f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886.json deleted file mode 100644 index d352d3d69d..0000000000 --- a/backend/.sqlx/query-f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886" -} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c32e1d9c67..e39f97cd1f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1141,14 +1141,14 @@ dependencies = [ "http 1.4.1", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.24.2", "hyper-rustls 0.27.9", "hyper-util", "pin-project-lite", "rustls 0.21.12", "rustls 0.23.35", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -1335,7 +1335,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "itoa", "matchit 0.8.4", @@ -1531,7 +1531,7 @@ dependencies = [ "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -1551,7 +1551,7 @@ dependencies = [ "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -1684,7 +1684,7 @@ dependencies = [ "hex", "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "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", @@ -2056,14 +2056,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -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.1", "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.1", "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", @@ -4984,9 +4984,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ "cc", "cfg-if", @@ -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.1", "hyper-rustls 0.26.0", "hyper-tls", "hyper-tungstenite", @@ -5699,9 +5693,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -5729,7 +5723,7 @@ dependencies = [ "futures-util", "headers", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "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.1", "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.1", "hyper-util", "log", "rustls 0.22.4", @@ -5799,11 +5793,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "log", "rustls 0.23.35", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -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.1", "hyper-util", "pin-project-lite", "tokio", @@ -5831,7 +5825,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "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.1", "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.1", "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.1", "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.1", "hyper-http-proxy", "hyper-rustls 0.27.9", "hyper-timeout", @@ -6601,9 +6595,9 @@ dependencies = [ [[package]] name = "libgssapi-sys" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7518e6902e94f92e7c7271232684b60988b4bd813529b4ef9d97aead96956ae8" +checksum = "5103ac4557eacd36ff678b654b943f8966d3db9688fbd180a0b4c5464759ce17" dependencies = [ "bindgen 0.71.1", "pkg-config", @@ -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.1", ] [[package]] @@ -6681,9 +6675,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -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.1", "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.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" 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.1", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -9438,7 +9432,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls 0.23.35", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "rustls-pki-types", "serde", "serde_json", @@ -9474,7 +9468,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "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.1", "reqwest 0.13.1", "reqwest-middleware", "retry-policies", @@ -9962,9 +9956,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", @@ -10012,7 +10006,7 @@ dependencies = [ "log", "once_cell", "rustls 0.23.35", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "rustls-platform-verifier-android", "rustls-webpki 0.103.13", "security-framework 3.7.0", @@ -10211,15 +10205,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -10313,12 +10298,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "seahash" version = "4.1.0" @@ -10636,24 +10615,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", @@ -10729,6 +10707,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -10862,9 +10846,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 +11036,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "once_cell", "proc-macro2", @@ -11268,35 +11252,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 +11267,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 +12359,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.9.0", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tokio-util", "whoami", @@ -12446,9 +12408,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" dependencies = [ "either", "futures-util", @@ -12542,7 +12504,7 @@ dependencies = [ "httparse", "rand 0.8.5", "ring 0.17.14", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -12594,9 +12556,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 +12591,7 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-timeout", "hyper-util", "percent-encoding", @@ -12661,13 +12623,13 @@ dependencies = [ "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", "prost", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "socket2 0.5.10", "tokio", "tokio-rustls 0.26.4", @@ -13032,9 +12994,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typetag" @@ -13197,9 +13159,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -13364,9 +13326,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -13802,7 +13764,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-nats", @@ -13836,7 +13798,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "tikv-jemalloc-ctl", @@ -13883,7 +13845,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.711.0" +version = "1.714.0" dependencies = [ "async-stream", "async-trait", @@ -13916,7 +13878,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13929,7 +13891,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "argon2", @@ -13959,7 +13921,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -13990,7 +13952,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "time", @@ -14067,12 +14029,12 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "lazy_static", "quick_cache", "serde", @@ -14090,7 +14052,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14103,7 +14065,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14129,7 +14091,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.711.0" +version = "1.714.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14139,7 +14101,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14156,7 +14118,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14178,7 +14140,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14201,7 +14163,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14217,11 +14179,11 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", - "hyper 1.9.0", + "hyper 1.10.1", "serde", "serde_json", "sql-builder", @@ -14238,7 +14200,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14259,7 +14221,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14273,7 +14235,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-nats", @@ -14305,14 +14267,14 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "axum 0.8.9", "base64 0.22.1", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "lazy_static", "serde", "serde_json", @@ -14330,7 +14292,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14348,7 +14310,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14370,7 +14332,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14390,13 +14352,13 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", "futures", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "itertools 0.14.0", "lazy_static", "quick_cache", @@ -14420,7 +14382,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14448,7 +14410,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.711.0" +version = "1.714.0" dependencies = [ "lazy_static", "serde", @@ -14460,14 +14422,14 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.711.0" +version = "1.714.0" dependencies = [ "argon2", "axum 0.8.9", "chrono", "dashmap", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "lazy_static", "serde", "serde_json", @@ -14485,7 +14447,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14499,13 +14461,13 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.711.0" +version = "1.714.0" dependencies = [ "axum 0.8.9", "chrono", "hex", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "lazy_static", "magic-crypt", "regex", @@ -14513,7 +14475,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "uuid", @@ -14532,7 +14494,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.711.0" +version = "1.714.0" dependencies = [ "chrono", "lazy_static", @@ -14546,7 +14508,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14565,7 +14527,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.711.0" +version = "1.714.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14601,7 +14563,7 @@ dependencies = [ "globset", "hex", "hmac", - "hyper 1.9.0", + "hyper 1.10.1", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -14636,8 +14598,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 +14628,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.711.0" +version = "1.714.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14685,7 +14647,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.711.0" +version = "1.714.0" dependencies = [ "regex", "serde", @@ -14700,7 +14662,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14724,7 +14686,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "futures", @@ -14741,7 +14703,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.711.0" +version = "1.714.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14757,7 +14719,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -14778,7 +14740,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -14795,7 +14757,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "urlencoding", @@ -14809,7 +14771,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "arc-swap", @@ -14834,7 +14796,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-stream", @@ -14868,7 +14830,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "futures", @@ -14886,7 +14848,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.711.0" +version = "1.714.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14895,7 +14857,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -14907,7 +14869,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde_json", @@ -14919,7 +14881,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "gosyn", @@ -14931,7 +14893,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -14943,7 +14905,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde_json", @@ -14955,7 +14917,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "nu-parser", @@ -14966,7 +14928,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14977,7 +14939,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14989,7 +14951,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15000,7 +14962,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-recursion", @@ -15022,7 +14984,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde_json", @@ -15034,7 +14996,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -15048,7 +15010,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15065,7 +15027,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -15078,7 +15040,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde", @@ -15090,7 +15052,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -15108,7 +15070,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15124,7 +15086,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15140,7 +15102,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde", @@ -15151,7 +15113,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-recursion", @@ -15189,7 +15151,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "const_format", @@ -15227,7 +15189,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.711.0" +version = "1.714.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15238,7 +15200,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-recursion", @@ -15246,7 +15208,7 @@ dependencies = [ "chrono", "futures", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "lazy_static", "quick_cache", "reqwest 0.13.1", @@ -15268,7 +15230,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15292,14 +15254,14 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", "chrono", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "itertools 0.14.0", "lazy_static", "rand 0.9.0", @@ -15325,7 +15287,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15358,7 +15320,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15378,7 +15340,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15412,7 +15374,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15424,7 +15386,7 @@ dependencies = [ "hex", "hmac", "http 1.4.1", - "hyper 1.9.0", + "hyper 1.10.1", "itertools 0.14.0", "lazy_static", "matchit 0.7.3", @@ -15448,7 +15410,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15471,7 +15433,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15495,7 +15457,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-nats", @@ -15519,7 +15481,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15554,7 +15516,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15582,7 +15544,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-trait", @@ -15607,7 +15569,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "bitflags 2.11.1", @@ -15618,7 +15580,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum 0.27.2", + "strum", "tracing", "uuid", "windmill-parser", @@ -15626,7 +15588,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-once-cell", @@ -15736,7 +15698,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.711.0" +version = "1.714.0" dependencies = [ "bytes", "futures", @@ -16371,7 +16333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -16382,7 +16344,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 +16512,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index e296f9fb6a..32ae3ce1fc 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.711.0" +version = "1.714.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.714.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 3c4cbb02ae..4e1f516570 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -327d23f7438968a21bac9fd42e7f6f027c61477c +3742e0659c5e97aab03b9efeea14cd94a3ac658a diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index c9693b2311..36f565fc2b 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -176,6 +176,23 @@ "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" + } + }, + "salesforce": { + "auth_url": "https://login.salesforce.com/services/oauth2/authorize", + "token_url": "https://login.salesforce.com/services/oauth2/token", + "scopes": [ + "api", + "refresh_token", + "offline_access" + ], + "sandbox": { + "auth_url": "https://test.salesforce.com/services/oauth2/authorize", + "token_url": "https://test.salesforce.com/services/oauth2/token" + } } } diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index f06ab454a3..aa0b618798 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.714.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.711.0" +version = "1.714.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.711.0" +version = "1.714.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.711.0" +version = "1.714.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index f49ea30ecd..ec9a764a03 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.714.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index 87285e2ae1..29bea050ed 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -258,6 +258,15 @@ pub fn main() -> anyhow::Result<()> { } async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { + // The `cache` CLI mode never connects to the DB, so HUB_BASE_URL keeps its + // compiled default. Allow overriding it via env so the prebuild cache step can + // be pointed at a private/staging hub (e.g. a local proxy for testing). + if let Ok(hub_base_url) = std::env::var("HUB_BASE_URL") { + if !hub_base_url.is_empty() { + tracing::info!("Overriding hub base url from env: {hub_base_url}"); + windmill_common::HUB_BASE_URL.store(std::sync::Arc::new(hub_base_url)); + } + } let file_path = file_path.unwrap_or("./hubPaths.json".to_string()); let mut file = File::open(&file_path) .await @@ -567,6 +576,7 @@ fn print_help() { println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup"); println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool"); println!(" SYNC_CACHED_RT = false Sync cached resource types to admins workspace on server start"); + println!(" HUB_BASE_URL = https://hub.windmill.dev Hub to fetch scripts from in `cache` mode (server/worker use the DB setting instead)"); println!(); println!("Notes:"); println!("- Advanced and less commonly used settings are managed via the database and are omitted here."); diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index d6d49c8d85..48a50ef9b9 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -451,6 +451,7 @@ def main(): preserve_on_behalf_of: None, ws_error_handler_muted: None, labels: None, + skip_draft_deletion: None, }) .send() .await @@ -513,6 +514,7 @@ def main(): custom_path: None, preserve_on_behalf_of: None, labels: None, + skip_draft_deletion: None, }) .send() .await 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/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index b568c0accb..52d04910de 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -20,13 +20,14 @@ where lazy_static::lazy_static! { static ref OPENAI_AZURE_BASE_PATH: Option = std::env::var("OPENAI_AZURE_BASE_PATH").ok(); - static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") + pub static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") .ok() .map(|v| v == "true" || v == "1") .unwrap_or(false); } 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/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..3645476441 --- /dev/null +++ b/backend/windmill-ai/src/proxy/fim.rs @@ -0,0 +1,212 @@ +use bytes::Bytes; +use serde::Deserialize; +use serde_json::json; +use windmill_common::error::{Error, Result}; + +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)] +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 | 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") { + 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 { + 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(), + base_url: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mistral_keeps_native_fim_request() { + 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] + fn openai_fim_request_is_transformed_to_chat_completion() { + let transformed = maybe_transform_fim_request( + &AIProvider::OpenAI, + "fim/completions", + "https://api.openai.com/v1", + 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"); + 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"); + 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", + "https://api.openai.com/v1", + 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-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 99fa677cee..734753a0e0 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -235,6 +235,202 @@ where Ok(()) } +/// Returns the caller's "real" scope restrictions: every scope other than +/// `if_jobs:filter_tags:` tag filters. `None` means the token is unscoped and +/// has the full privileges of its user; `Some` means it is restricted to the +/// returned scopes. An empty or filter-tags-only scope list is treated as +/// unscoped, mirroring `check_scopes`/`check_route_access`. +fn scope_restrictions(scopes: Option<&[String]>) -> Option> { + let restrictions: Vec<&String> = scopes? + .iter() + .filter(|s| !s.starts_with("if_jobs:filter_tags:")) + .collect(); + (!restrictions.is_empty()).then_some(restrictions) +} + +/// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes +/// a credential on behalf of `authed`: the resulting credential must never be +/// more privileged than the caller's own token. +/// +/// - An unscoped caller may grant any scopes (this is the existing UI/CLI flow). +/// - A scope-restricted caller may only grant scopes that are a subset of its +/// own, and may never produce an unscoped credential. +/// +/// Without this, a `users:write` token could create or rescope a token to be +/// unscoped, and a `users:read` token could refresh into an unscoped session — +/// escaping its own restrictions. +pub fn ensure_scopes_within_caller( + authed: &ApiAuthed, + requested_scopes: Option<&[String]>, +) -> error::Result<()> { + if let Some(caller_restrictions) = scope_restrictions(authed.scopes.as_deref()) { + let Some(requested_restrictions) = scope_restrictions(requested_scopes) else { + return Err(Error::PermissionDenied( + "A scope-restricted token cannot create or update a token with broader (unscoped) \ + privileges" + .to_string(), + )); + }; + + // MCP scopes (`mcp:all`, `mcp:favorites`, `mcp:scripts:*`, etc.) use a + // custom format that ScopeDefinition::from_scope_string parses + // permissively but the MCP runtime interprets via its own parser + // (parse_mcp_scopes). The two views disagree — e.g. the generic parser + // accepts `mcp:scripts` as an unrestricted-resource scope, while the + // MCP runtime ignores it as unrecognized but interprets `mcp:scripts:*` + // as granting all scripts. So generic containment would silently allow + // `mcp:scripts` → `mcp:scripts:*` (a widening). Legitimate MCP token + // issuance goes through the OAuth gateway (mcp/oauth_server.rs), not + // these user-token endpoints, so require byte-identical match for MCP + // scopes here rather than trying to mirror MCP semantics in two places. + // Unparseable non-MCP caller scopes are intentionally dropped + // (fail-closed): a caller scope that fails to parse can only narrow + // the set of requested scopes that get covered, never widen it. + // Unparseable requested scopes surface as `BadRequest`, which is what + // we want — the client is sending garbage. + let parsed_caller: Vec = caller_restrictions + .iter() + .filter(|s| !s.starts_with("mcp:")) + .filter_map(|s| ScopeDefinition::from_scope_string(s).ok()) + .collect(); + let caller_mcp: std::collections::HashSet<&str> = caller_restrictions + .iter() + .filter(|s| s.starts_with("mcp:")) + .map(|s| s.as_str()) + .collect(); + + for requested in requested_restrictions { + if requested.starts_with("mcp:") { + if !caller_mcp.contains(requested.as_str()) { + return Err(Error::PermissionDenied(format!( + "A scope-restricted token cannot grant MCP scope '{requested}' unless the \ + caller holds the same scope verbatim" + ))); + } + continue; + } + let requested_scope = ScopeDefinition::from_scope_string(requested)?; + let covered = parsed_caller + .iter() + .any(|caller_scope| scope_contains(caller_scope, &requested_scope)); + if !covered { + return Err(Error::PermissionDenied(format!( + "A scope-restricted token cannot grant scope '{requested}' which exceeds its \ + own scopes" + ))); + } + } + } + + // `if_jobs:filter_tags:` fences which job tags a token can run on (enforced + // at job operations as `v2_job.tag = ANY(...)`), and is checked independently + // of domain/action/resource subset. A caller restricted by filter_tags must + // not be able to mint or rescope a credential that drops or widens the fence + // — even if the caller has no other scope restrictions (filter_tags-only + // tokens otherwise look "unscoped" to `scope_restrictions`). + if let Some(caller_tags) = first_filter_tags(authed.scopes.as_deref()) { + let Some(requested_tags) = first_filter_tags(requested_scopes) else { + return Err(Error::PermissionDenied( + "A token restricted by if_jobs:filter_tags cannot mint or rescope a token that \ + drops the tag restriction" + .to_string(), + )); + }; + let caller_set: std::collections::HashSet<&str> = caller_tags.iter().copied().collect(); + for tag in &requested_tags { + if !caller_set.contains(tag) { + return Err(Error::PermissionDenied(format!( + "A token restricted by if_jobs:filter_tags cannot grant tag '{tag}' which is \ + not within its own filter_tags" + ))); + } + } + } + + Ok(()) +} + +/// Tags from the first `if_jobs:filter_tags:` scope, matching the +/// semantics of [`get_scope_tags`] (which is what the job runtime consults). +/// Returns `None` if no such scope is present. +fn first_filter_tags(scopes: Option<&[String]>) -> Option> { + scopes?.iter().find_map(|s| { + s.strip_prefix("if_jobs:filter_tags:") + .map(|tags| tags.split(',').collect()) + }) +} + +/// Whether `caller` grants at least everything `requested` grants (directional +/// containment). +/// +/// This is intentionally NOT `ScopeDefinition::includes`: that method answers +/// "does this scope grant access to a required action" using OR semantics over +/// resources (any overlap counts, and a `*` on either side matches), which is +/// correct for access checks but unsafe for subset checks — it would let a +/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or +/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that +/// EVERY requested resource is covered by SOME caller resource. +fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool { + if caller.domain != requested.domain { + return false; + } + + // write subsumes read; otherwise the action must match exactly. + match (caller.action.as_str(), requested.action.as_str()) { + (c, r) if c == r || (c == "write" && r == "read") => {} + _ => return false, + } + + if caller.domain == "jobs" && caller.action == "run" { + match (&caller.kind, &requested.kind) { + (Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => { + return false + } + // Caller pinned to a kind, but the request covers any kind. + (Some(_), None) => return false, + _ => {} + } + } + + match (&caller.resource, &requested.resource) { + // Caller is unrestricted on resources: covers everything. + (None, _) => true, + // Caller is resource-restricted but the request is not: broader. + (Some(_), None) => false, + (Some(caller_resources), Some(requested_resources)) => { + resource_set_contains(caller_resources, requested_resources) + } + } +} + +/// Every resource in `requested` must be covered by some resource in `caller`. +fn resource_set_contains(caller: &[String], requested: &[String]) -> bool { + if caller.iter().any(|r| r == "*") { + return true; + } + requested + .iter() + .all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req))) +} + +/// Directional: does the single caller resource pattern cover `requested`? +/// `caller` may be an exact path or a `/*` subtree wildcard; `requested` +/// may itself be a subtree wildcard, in which case the whole requested subtree +/// must fall within the caller's subtree. +fn resource_covers(caller: &str, requested: &str) -> bool { + if caller == requested { + return true; + } + let Some(prefix) = caller.strip_suffix("/*") else { + // An exact caller resource only covers itself (handled above). + return false; + }; + let requested_base = requested.strip_suffix("/*").unwrap_or(requested); + requested_base == prefix + || (requested_base.starts_with(prefix) + && requested_base.as_bytes().get(prefix.len()) == Some(&b'/')) +} + /// Returns a predicate that checks whether `path` is within the token's /// scope for `{domain}:{action}:{path}`. For tokens without scope /// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes), @@ -574,6 +770,13 @@ impl NewToken { } } +/// Low-level token mint shared by trusted callers (the user-facing +/// `tokens/create` handler and internal mints such as native-trigger webhook +/// tokens). It does NOT enforce that `token_config.scopes` is within the +/// caller's own scopes — callers exposed to untrusted input must call +/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally +/// skip it, since their scopes derive from the action being authorized, not the +/// caller's token). pub async fn create_token_internal( tx: &mut sqlx::PgConnection, db: &DB, @@ -908,4 +1111,278 @@ mod tests { assert!(allowed("u/alice/foo")); assert!(!allowed("u/alice/bar")); } + + fn opt_scopes(scopes: Option>) -> Option> { + scopes.map(|v| v.into_iter().map(String::from).collect()) + } + + // Regression tests for WIN-1999: scoped user tokens must not be able to + // mint or rescope credentials with broader privileges than themselves. + + #[test] + fn unscoped_caller_can_grant_anything() { + let authed = authed_with_scopes(None); + assert!(ensure_scopes_within_caller(&authed, None).is_ok()); + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn filter_tags_only_caller_is_unrestricted_on_domain_action_dimension() { + // The domain/action/resource subset check treats filter-tags-only as + // unrestricted, mirroring check_scopes/check_route_access. The tag + // dimension is checked separately (see filter_tags_dimension_is_monotonic). + let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"])); + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["users:write", "if_jobs:filter_tags:default"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn filter_tags_dimension_is_monotonic() { + // Caller restricted to tag fence "a" cannot drop the fence … + let single = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a"])); + assert!(ensure_scopes_within_caller(&single, None).is_err()); + assert!( + ensure_scopes_within_caller(&single, opt_scopes(Some(vec!["users:read"])).as_deref()) + .is_err(), + "minting a token without filter_tags must be rejected" + ); + // … cannot widen to a tag it lacks … + assert!(ensure_scopes_within_caller( + &single, + opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref() + ) + .is_err()); + // … and cannot mint a token fenced on a disjoint tag. + assert!(ensure_scopes_within_caller( + &single, + opt_scopes(Some(vec!["if_jobs:filter_tags:b"])).as_deref() + ) + .is_err()); + // Narrowing or matching the tag fence is allowed. + let multi = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a,b"])); + assert!(ensure_scopes_within_caller( + &multi, + opt_scopes(Some(vec!["if_jobs:filter_tags:a"])).as_deref() + ) + .is_ok()); + assert!(ensure_scopes_within_caller( + &multi, + opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref() + ) + .is_ok()); + // A caller with a real scope plus a tag fence cannot drop just the fence. + let mixed = authed_with_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])); + assert!(ensure_scopes_within_caller( + &mixed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_err()); + assert!(ensure_scopes_within_caller( + &mixed, + opt_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])).as_deref() + ) + .is_ok()); + // An unrestricted caller may grant filter_tags freely. + let unscoped = authed_with_scopes(None); + assert!(ensure_scopes_within_caller( + &unscoped, + opt_scopes(Some(vec!["if_jobs:filter_tags:x"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn scoped_caller_cannot_mint_unscoped_token() { + // Primitive 2 in the report: a users:write token minting an unscoped token. + let authed = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller(&authed, None).is_err()); + // Empty scope list is effectively unscoped and must also be rejected. + assert!(ensure_scopes_within_caller(&authed, Some(&[])).is_err()); + // A scope list of only tag filters is effectively unscoped too. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["if_jobs:filter_tags:default"])).as_deref() + ) + .is_err()); + } + + #[test] + fn scoped_caller_cannot_remove_its_own_scopes() { + // Primitive 3 in the report: a users:write token setting its scopes to null. + let authed = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller(&authed, None).is_err()); + } + + #[test] + fn scoped_caller_cannot_grant_scope_it_lacks() { + let authed = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_err()); + } + + #[test] + fn scoped_caller_can_grant_subset_of_own_scopes() { + let authed = authed_with_scopes(Some(vec!["users:write", "jobs:run:scripts"])); + // Equal scope is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_ok()); + // write implies read, so a narrower read scope is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["users:read"])).as_deref() + ) + .is_ok()); + // Tag filters narrow further and are always permitted. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["users:read", "if_jobs:filter_tags:default"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn scoped_caller_cannot_broaden_resource_scope() { + let authed = authed_with_scopes(Some(vec!["scripts:read:f/team/*"])); + // Narrower resource within the subtree is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/team/sub"])).as_deref() + ) + .is_ok()); + // A nested subtree within the caller's subtree is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/team/sub/*"])).as_deref() + ) + .is_ok()); + // The subtree root itself is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/team"])).as_deref() + ) + .is_ok()); + // A path outside the subtree is rejected. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/other/x"])).as_deref() + ) + .is_err()); + // read caller cannot grant write. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:write:f/team/db"])).as_deref() + ) + .is_err()); + } + + #[test] + fn mcp_scopes_require_byte_identical_match() { + // Regression for the access-grant-OR vs runtime-MCP-parser confusion: + // ScopeDefinition treats `mcp:scripts` as an unrestricted-resource scope + // and `mcp:scripts:*` as a strictly narrower one, so generic containment + // would silently allow widening. The MCP runtime however ignores + // `mcp:scripts` (unrecognized) while `mcp:scripts:*` grants all scripts. + // Legitimate MCP token issuance is the OAuth gateway, not these + // user-token endpoints, so MCP scopes must match the caller verbatim. + + // The bypass the reviewer flagged: malformed `mcp:scripts` would widen + // into the real `mcp:scripts:*` under generic containment. + let bypass = authed_with_scopes(Some(vec!["users:write", "mcp:scripts"])); + assert!(ensure_scopes_within_caller( + &bypass, + opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref() + ) + .is_err()); + + // A caller without any MCP scope cannot grant one (widening on the MCP + // dimension), even if the rest of the requested scopes are within reach. + let no_mcp = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller( + &no_mcp, + opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref() + ) + .is_err()); + + // Byte-identical MCP scope passes; an additional non-matching MCP scope + // alongside it does not. + let mcp_caller = authed_with_scopes(Some(vec!["mcp:scripts:*"])); + assert!(ensure_scopes_within_caller( + &mcp_caller, + opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref() + ) + .is_ok()); + assert!(ensure_scopes_within_caller( + &mcp_caller, + opt_scopes(Some(vec!["mcp:scripts:*", "mcp:flows:*"])).as_deref() + ) + .is_err()); + + // Even a narrowing within MCP semantics (`mcp:all` → `mcp:scripts:*`) + // is rejected by the byte-identical rule. This is intentional — these + // endpoints are not the legitimate path for narrowing MCP tokens. + let mcp_all = authed_with_scopes(Some(vec!["mcp:all"])); + assert!(ensure_scopes_within_caller( + &mcp_all, + opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref() + ) + .is_err()); + } + + #[test] + fn scoped_caller_cannot_escalate_to_wildcard_or_superset() { + // Regression for the access-grant-OR vs subset-containment confusion: + // ScopeDefinition::includes would (incorrectly) allow all of these. + let star = authed_with_scopes(Some(vec!["scripts:read:f/team/a"])); + // Minting `*` from a single-path scope must be rejected. + assert!(ensure_scopes_within_caller( + &star, + opt_scopes(Some(vec!["scripts:read:*"])).as_deref() + ) + .is_err()); + // Minting a broader subtree must be rejected. + assert!(ensure_scopes_within_caller( + &star, + opt_scopes(Some(vec!["scripts:read:f/team/*"])).as_deref() + ) + .is_err()); + + // A comma-separated list that adds an uncovered resource must be rejected, + // even though one element overlaps the caller's scope. + let list = authed_with_scopes(Some(vec!["scripts:read:f/team/a"])); + assert!(ensure_scopes_within_caller( + &list, + opt_scopes(Some(vec!["scripts:read:f/team/a,f/other/b"])).as_deref() + ) + .is_err()); + + // A subset of a multi-resource caller scope is allowed. + let multi = authed_with_scopes(Some(vec!["scripts:read:f/team/a,f/team/b"])); + assert!(ensure_scopes_within_caller( + &multi, + opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref() + ) + .is_ok()); + + // A wildcard caller covers any subset, but not `*`-less escalation rules apply + // only when the caller itself lacks `*`. + let wildcard = authed_with_scopes(Some(vec!["scripts:read:*"])); + assert!(ensure_scopes_within_caller( + &wildcard, + opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref() + ) + .is_ok()); + } } diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 8a42bce88e..546bda32f0 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -558,13 +558,17 @@ async fn create_flow( w_id ).execute(&mut *tx).await?; - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - nf.path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !nf.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + nf.path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -1157,13 +1161,17 @@ async fn update_flow( })?; } - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - flow_path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !nf.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + flow_path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -2031,6 +2039,7 @@ mod tests { })), preprocessor_module: None, same_worker: false, + preserve_step_tags: false, skip_expr: None, cache_ttl: None, cache_ignore_s3_path: None, diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index 3f0549f4e8..1f88c33e9f 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -383,6 +383,8 @@ async fn remove_granular_acl( // workspace export. let table = if kind == "raw_app" { "app" } else { kind }; // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function. + // LIMIT 1: `script` shares (workspace_id, path) across versions, so `old` can + // return >1 row, which would break the scalar subquery in RETURNING. let obj_o = sqlx::query_scalar::<_, bool>(&format!( "WITH old AS ( SELECT extra_perms->$1 as old_write FROM {table} @@ -390,7 +392,7 @@ async fn remove_granular_acl( ) UPDATE {table} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1 - RETURNING (SELECT old_write FROM old)::bool" + RETURNING (SELECT old_write FROM old LIMIT 1)::bool" )) .bind(&owner) .bind(path) diff --git a/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs b/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs new file mode 100644 index 0000000000..8edfd39d61 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs @@ -0,0 +1,358 @@ +/*! + * Integration test for workspace encryption key rotation triggering git sync. + * + * Regression test for windmill-labs/windmill#9344 — re-encrypting all secret + * variables on workspace key change must dispatch a git-sync job that carries + * every re-encrypted variable plus the encryption_key entry, so repos with + * Secrets sync enabled receive the new ciphertexts in one commit. + * + * Run with enterprise features: + * ```bash + * cargo test --test workspace_encryption_key_git_sync --features enterprise,private + * ``` + */ + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use std::time::Duration; + +#[allow(unused_imports)] +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +#[allow(dead_code)] +async fn create_git_repo_resource(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + r#" + INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/test_git_repo', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') + ON CONFLICT (workspace_id, path) DO NOTHING + "#, + ) + .bind(json!({ + "url": "https://github.com/test/test.git", + "branch": "main", + "token": "test-token" + })) + .execute(db) + .await?; + Ok(()) +} + +#[allow(dead_code)] +async fn create_folder(db: &Pool, name: &str) -> anyhow::Result<()> { + sqlx::query( + r#" + INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) + VALUES ('test-workspace', $1, $1, ARRAY['u/test-user'], '{}'::jsonb, 'test-user') + ON CONFLICT (workspace_id, name) DO NOTHING + "#, + ) + .bind(name) + .execute(db) + .await?; + Ok(()) +} + +#[allow(dead_code)] +async fn create_sync_script(db: &Pool, path: &str) -> anyhow::Result { + let hash: i64 = rand::random::().unsigned_abs() as i64; + sqlx::query( + r#" + INSERT INTO script (workspace_id, hash, path, summary, description, content, + created_by, language, kind, lock) + VALUES ('test-workspace', $1, $2, 'sync script', '', + 'export function main(items: any[]) { return { synced: items.length }; }', + 'test-user', 'bun', 'script', '') + "#, + ) + .bind(hash) + .bind(path) + .execute(db) + .await?; + Ok(hash) +} + +#[allow(dead_code)] +async fn setup_git_sync_config(db: &Pool, sync_script_path: &str) -> anyhow::Result<()> { + // Include Variable + Secret + Key so the encryption rotation has a reason + // to push every re-encrypted variable. Anchor include_path to root so all + // u/... and f/... paths pass the regex filter. + let git_sync_config = json!({ + "include_type": ["variable", "secret", "key"], + "include_path": ["**"], + "repositories": [{ + "script_path": sync_script_path, + "git_repo_resource_path": "$res:u/test-user/test_git_repo", + "use_individual_branch": false, + "group_by_folder": false + }] + }); + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + git_sync_config, + "test-workspace" + ) + .execute(db) + .await?; + Ok(()) +} + +/// Insert N secret variables, encrypting their values with the workspace's +/// current key so the re-encryption path can decrypt them. +#[allow(dead_code)] +async fn insert_secret_variables(db: &Pool, paths: &[&str]) -> anyhow::Result<()> { + use windmill_common::variables::{build_crypt, encrypt}; + let mc = build_crypt(db, "test-workspace").await?; + for path in paths { + let plaintext = format!("secret-value-for-{path}"); + let encrypted = encrypt(&mc, &plaintext); + sqlx::query!( + r#" + INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account) + VALUES ($1, $2, $3, true, '', '{}'::jsonb, NULL) + ON CONFLICT (workspace_id, path) DO UPDATE SET value = EXCLUDED.value + "#, + "test-workspace", + path, + encrypted, + ) + .execute(db) + .await?; + } + Ok(()) +} + +#[derive(Debug)] +#[allow(dead_code)] +struct DeploymentCallbackJob { + id: uuid::Uuid, + args: Option, +} + +/// Poll until at least `min_count` deployment-callback jobs exist for the +/// script path, or the timeout elapses. Returns whatever was found. +#[allow(dead_code)] +async fn wait_for_deployment_callbacks( + db: &Pool, + script_path: &str, + min_count: usize, + timeout: Duration, +) -> anyhow::Result> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let rows = sqlx::query_as!( + DeploymentCallbackJob, + r#" + SELECT j.id, j.args + FROM v2_job j + JOIN v2_job_queue q ON j.id = q.id + WHERE j.runnable_path = $1 + AND j.kind = 'deploymentcallback' + AND j.workspace_id = 'test-workspace' + ORDER BY j.created_at DESC + "#, + script_path, + ) + .fetch_all(db) + .await?; + if rows.len() >= min_count || tokio::time::Instant::now() >= deadline { + return Ok(rows); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_encryption_key_rotation_dispatches_batched_git_sync( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + // Setup git sync repo + sync script (folder/path encodes the hub min-version) + create_folder(&db, "28103").await?; + create_git_repo_resource(&db).await?; + let sync_script_path = "f/28103/test_sync_script_encryption"; + create_sync_script(&db, sync_script_path).await?; + setup_git_sync_config(&db, sync_script_path).await?; + + let secret_paths = [ + "u/test-user/secret_a", + "u/test-user/secret_b", + "u/test-user/secret_c", + ]; + insert_secret_variables(&db, &secret_paths).await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + // 64-char alphanumeric per the route's WORKSPACE_KEY_REGEXP + let new_key = "a".repeat(64); + let resp = authed(client().post(format!("{base}/encryption_key"))) + .json(&json!({"new_key": new_key, "skip_reencrypt": false})) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "set_encryption_key failed: {}", + resp.text().await? + ); + + // The git-sync dispatch runs in a tokio::spawn'd task. Poll up to a few + // seconds for the deployment callback to land in the queue. + let jobs = + wait_for_deployment_callbacks(&db, sync_script_path, 1, Duration::from_secs(5)).await?; + assert_eq!( + jobs.len(), + 1, + "expected exactly one batched deployment callback job, got {}", + jobs.len() + ); + let job = &jobs[0]; + + let args = job.args.as_ref().expect("job should have args"); + let items = args + .get("items") + .and_then(|v| v.as_array()) + .expect("args.items should be a JSON array"); + + // Expect exactly one job carrying the Key entry + every re-encrypted variable + assert_eq!( + items.len(), + secret_paths.len() + 1, + "expected {} items (key + {} variables) in a single sync job, got {} — items: {:#?}", + secret_paths.len() + 1, + secret_paths.len(), + items.len(), + items + ); + + let mut variable_paths: Vec = Vec::new(); + let mut saw_key = false; + for item in items { + let path_type = item.get("path_type").and_then(|v| v.as_str()).unwrap_or(""); + let path = item.get("path").and_then(|v| v.as_str()).unwrap_or(""); + match path_type { + "variable" => variable_paths.push(path.to_string()), + "key" => saw_key = true, + other => panic!("unexpected path_type in batch: {other}"), + } + } + assert!( + saw_key, + "expected a path_type=key entry in items: {:#?}", + items + ); + variable_paths.sort(); + let mut expected: Vec = secret_paths.iter().map(|s| s.to_string()).collect(); + expected.sort(); + assert_eq!( + variable_paths, expected, + "items array should contain every re-encrypted secret variable" + ); + + // Secrets sync is enabled (ObjectType::Secret in include_type), so the sync + // script should be invoked with skip_secret=false. + let skip_secret = args + .get("skip_secret") + .and_then(|v| v.as_bool()) + .expect("args.skip_secret should be set when batch carries variables"); + assert!( + !skip_secret, + "skip_secret should be false when Secret is included in the repo's types" + ); + + Ok(()) +} + +/// Regression test for the non-debouncing fallback: a workspace whose sync +/// script predates hub version 28103 must still receive git-sync jobs for the +/// encryption_key entry and every re-encrypted secret. Before the fallback was +/// added, the batch path `continue`d past such repos and queued nothing, +/// silently leaving the repo stale after a key rotation. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_encryption_key_rotation_falls_back_without_debouncing( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + // Folder/path encodes a hub version BELOW 28103, so + // is_script_meets_min_version(28103) is false → debouncing unsupported. + create_folder(&db, "28000").await?; + create_git_repo_resource(&db).await?; + let sync_script_path = "f/28000/test_sync_script_legacy"; + create_sync_script(&db, sync_script_path).await?; + setup_git_sync_config(&db, sync_script_path).await?; + + let secret_paths = ["u/test-user/secret_a", "u/test-user/secret_b"]; + insert_secret_variables(&db, &secret_paths).await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + let new_key = "b".repeat(64); + let resp = authed(client().post(format!("{base}/encryption_key"))) + .json(&json!({"new_key": new_key, "skip_reencrypt": false})) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "set_encryption_key failed: {}", + resp.text().await? + ); + + // Legacy fallback pushes one job per item (flat args, no `items` array): + // the Key entry + one per re-encrypted variable. + let expected = secret_paths.len() + 1; + let jobs = + wait_for_deployment_callbacks(&db, sync_script_path, expected, Duration::from_secs(5)) + .await?; + assert_eq!( + jobs.len(), + expected, + "expected {expected} legacy deployment-callback jobs (key + {} variables), got {} — a repo on an old sync script must not be silently skipped", + secret_paths.len(), + jobs.len() + ); + + let mut variable_paths: Vec = Vec::new(); + let mut saw_key = false; + for job in &jobs { + let args = job.args.as_ref().expect("job should have args"); + // Legacy format: flat fields, never an `items` array. + assert!( + args.get("items").is_none(), + "fallback jobs must use the flat legacy format, not an items array: {args:#?}" + ); + let path_type = args.get("path_type").and_then(|v| v.as_str()).unwrap_or(""); + let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); + match path_type { + "variable" => variable_paths.push(path.to_string()), + "key" => saw_key = true, + other => panic!("unexpected path_type in fallback job: {other}"), + } + } + assert!(saw_key, "expected a path_type=key fallback job"); + variable_paths.sort(); + let mut expected_paths: Vec = secret_paths.iter().map(|s| s.to_string()).collect(); + expected_paths.sort(); + assert_eq!( + variable_paths, expected_paths, + "fallback must queue a job for every re-encrypted secret variable" + ); + + Ok(()) +} diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index faf8592d24..b2ed92ad8f 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -739,6 +739,9 @@ async fn is_noop_deploy_against_parent( // caller-intent flag (auto-resolve parent), not script state auto_parent: _, labels, + // caller-intent flag (preserve user drafts on CLI/git-sync deploys); + // transient, never persisted, does not change what the script *is* + skip_draft_deletion: _, } = ns; if path != &parent.path { @@ -927,6 +930,9 @@ async fn create_script_internal<'c>( } } let script_path = ns.path.clone(); + // Caller-intent: CLI / git-sync deploys ask us to preserve any existing + // user draft at this path instead of wiping it as part of the deploy. + let skip_draft_deletion = ns.skip_draft_deletion.unwrap_or(false); let hash = ScriptHash(hash_script(&ns)); let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await; @@ -1395,13 +1401,15 @@ async fn create_script_internal<'c>( let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", - p_path, - &w_id - ) - .execute(&mut *tx) - .await?; + if !skip_draft_deletion { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + p_path, + &w_id + ) + .execute(&mut *tx) + .await?; + } sqlx::query!( "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", @@ -1480,7 +1488,7 @@ async fn create_script_internal<'c>( tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; } } - } else { + } else if !skip_draft_deletion { sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", ns.path, 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-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 405efba4e5..95b9527a8e 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -1964,7 +1964,8 @@ async fn login( windmill_common::login_rate_limit::record_login_failure(&email); Err(Error::BadRequest("Invalid login".to_string())) } else { - let token = create_session_token(&email, super_admin, &mut tx, cookies).await?; + let token = + create_session_token(&email, super_admin, None, false, &mut tx, cookies).await?; let audit_author = AuditAuthor { email: email.clone(), @@ -2036,7 +2037,15 @@ async fn refresh_token( .await? .unwrap_or(false); - let new_token = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?; + let new_token = create_session_token( + &authed.email, + super_admin, + authed.scopes.as_deref(), + authed.read_only, + &mut tx, + cookies, + ) + .await?; audit_log( &mut *tx, @@ -2066,6 +2075,8 @@ lazy_static::lazy_static! { pub async fn create_session_token<'c>( email: &str, super_admin: bool, + scopes: Option<&[String]>, + read_only: bool, tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, cookies: Cookies, ) -> Result { @@ -2108,15 +2119,17 @@ pub async fn create_session_token<'c>( sqlx::query!( "INSERT INTO token - (token_hash, token_prefix, token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", + (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, read_only) + VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7, $8, $9)", t_hash, t_prefix, plaintext as Option<&str>, email, "session", &MAX_SESSION_VALIDITY_SECONDS.to_string(), - super_admin + super_admin, + scopes, + read_only, ) .execute(&mut **tx) .await?; @@ -2146,6 +2159,8 @@ async fn create_token( ) -> Result<(StatusCode, String)> { check_token_create_rate_limit(&authed.username)?; + windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?; + let mut tx = db.begin().await?; let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; @@ -2353,6 +2368,8 @@ async fn update_token_scopes( Path(token_prefix): Path, Json(req): Json, ) -> Result { + windmill_api_auth::ensure_scopes_within_caller(&authed, req.scopes.as_deref())?; + let mut tx = db.begin().await?; let updated: Option = sqlx::query_scalar!( diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 07ebcc7468..db60ae7fac 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -55,7 +55,10 @@ use windmill_common::{ use windmill_dep_map::scoped_dependency_map::{ DependencyDependent, DependencyMap, ScopedDependencyMap, }; -use windmill_git_sync::{handle_deployment_metadata, handle_fork_branch_creation, DeployedObject}; +use windmill_git_sync::{ + handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation, + DeployedObject, +}; use windmill_types::s3::LargeFileStorage; use hyper::StatusCode; @@ -3403,6 +3406,7 @@ async fn set_encryption_key( .execute(&mut *tx) .await?; + let mut reencrypted_secret_paths: Vec = Vec::new(); if !request.skip_reencrypt.unwrap_or(false) { // Build the new cipher directly from the key string, since the transaction // hasn't committed yet and build_crypt() would read the old key from the pool. @@ -3448,6 +3452,7 @@ async fn set_encryption_key( ) .execute(&mut *tx) .await?; + reencrypted_secret_paths.push(variable.path); } } @@ -3456,16 +3461,23 @@ async fn set_encryption_key( // Invalidate the cache only after the transaction has committed WORKSPACE_CRYPT_CACHE.remove(w_id.as_str()); - // Trigger git sync for encryption key changes - handle_deployment_metadata( + // Build the batch: one event for the encryption key itself plus one per + // re-encrypted secret variable. The batch entrypoint dispatches a single + // git-sync job per repo carrying all items, so repos with Secrets sync + // enabled receive the new ciphertexts in one commit. + let mut batch: Vec = Vec::with_capacity(reencrypted_secret_paths.len() + 1); + batch.push(DeployedObject::Key { key_type: "encryption_key".to_string() }); + for path in reencrypted_secret_paths { + batch.push(DeployedObject::Variable { path: path.clone(), parent_path: Some(path) }); + } + + handle_deployment_metadata_batch( &authed.email, &authed.username, &db, &w_id, - windmill_git_sync::DeployedObject::Key { key_type: "encryption_key".to_string() }, + batch, Some("Encryption key updated".to_string()), - false, - None, ) .await?; diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index fdec53098c..22014e1229 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.714.0 title: Windmill API contact: @@ -9751,6 +9751,9 @@ paths: type: boolean deployment_message: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." responses: "201": description: flow created @@ -9792,6 +9795,9 @@ paths: properties: deployment_message: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." responses: "200": @@ -10290,6 +10296,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." required: - path - value @@ -10342,6 +10351,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." required: - path - value @@ -10660,6 +10672,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." responses: "200": description: app updated @@ -10706,6 +10721,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." js: type: string css: @@ -21952,6 +21970,9 @@ components: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path." required: - path @@ -25475,6 +25496,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/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 6956192f04..0e096eb798 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}; @@ -108,6 +108,13 @@ lazy_static::lazy_static! { .timeout(std::time::Duration::from_secs(*AI_TIMEOUT_SECS)) .pool_max_idle_per_host(HTTP_POOL_MAX_IDLE_PER_HOST) .pool_idle_timeout(Some(std::time::Duration::from_secs(HTTP_POOL_IDLE_TIMEOUT_SECS))) + // The SSRF check in `get_base_url` only validates the configured `base_url`. + // reqwest follows up to 10 redirects by default and does not revalidate the + // hops, so a public base_url could 3xx the server into a private/internal + // address. Disable redirect following so the validated host is the only one + // we ever connect to. AI APIs respond directly and do not rely on redirects, + // so this holds even for ALLOW_PRIVATE_AI_BASE_URLS deployments. + .redirect(reqwest::redirect::Policy::none()) .user_agent("windmill/beta")) .build() .expect("Failed to build AI HTTP client - check system TLS configuration"); @@ -298,6 +305,22 @@ async fn get_token_using_oauth( resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?; resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?; resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?; + // Validate the resolved token_url against SSRF rules before issuing the request, + // mirroring the protection applied to base_url in `get_base_url` (same + // ALLOW_PRIVATE_AI_BASE_URLS opt-in). Without this a workspace member could + // point token_url at an internal/metadata address. + if !*windmill_ai::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS { + use windmill_common::ssrf::SsrfValidationError; + windmill_common::ssrf::validate_url_for_ssrf(&resource.token_url) + .await + .map_err(|e| match e { + e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!( + "{e}. If you need to use private/internal AI endpoints, \ + set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable" + )), + e => Error::from(e), + })?; + } let mut params = HashMap::new(); params.insert("grant_type", "client_credentials"); params.insert("scope", "https://cognitiveservices.azure.com/.default"); @@ -369,53 +392,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 +431,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 +524,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()); @@ -641,7 +650,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 } @@ -772,17 +781,25 @@ 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) { - 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; + 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; } let proxy_mode = proxy_execution_mode(&provider); diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index c377203f38..de55cb87ff 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -306,6 +306,11 @@ pub struct CreateApp { pub preserve_on_behalf_of: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this app must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } #[derive(Serialize, Deserialize)] @@ -319,6 +324,11 @@ pub struct EditApp { pub preserve_on_behalf_of: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this app must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } #[derive(Serialize, FromRow)] @@ -1338,13 +1348,17 @@ async fn create_app_internal<'a>( )); } } - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", - &app.path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !app.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + &app.path, + &w_id + ) + .execute(&mut *tx) + .await?; + } let id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels) @@ -1943,13 +1957,17 @@ async fn update_app_internal<'a>( ))); } }; - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", - path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !ns.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 875a1e80d2..71f9645272 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -7107,7 +7107,11 @@ pub async fn run_job_by_hash_inner( Ok((uuid, delete_after_use, delete_after_secs)) } -async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::Result { +async fn get_log_file( + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Path((w_id, file_p)): Path<(String, String)>, +) -> error::Result { if file_p.contains("..") { return Err(error::Error::BadRequest("Invalid path".to_string())); } @@ -7119,27 +7123,58 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R "Invalid path: must have exactly 2 components".to_string(), )); } - if Uuid::parse_str(parts[0]).is_err() { - return Err(error::Error::BadRequest( - "Invalid path: first component must be a valid UUID".to_string(), - )); - } + let job_id = Uuid::parse_str(parts[0]).map_err(|_| { + error::Error::BadRequest("Invalid path: first component must be a valid UUID".to_string()) + })?; if !parts[1].ends_with(".txt") { return Err(error::Error::BadRequest( "Invalid path: file must end with .txt".to_string(), )); } + // Authorization: the log file directory is the job id, so gate access the same + // way as get_job_logs — the caller must be able to read the job. Non-logged-in + // callers may only read logs of jobs created by the anonymous user. + let tags = opt_authed + .as_ref() + .map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec())) + .flatten(); + let created_by = sqlx::query_scalar!( + "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))", + job_id, + w_id, + tags.as_ref().map(|v| v.as_slice()) + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?; + if opt_authed.is_none() && created_by != "anonymous" { + return Err(error::Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), + )); + } + let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR); - if tokio::fs::metadata(&local_file).await.is_ok() { - let mut file = tokio::fs::File::open(local_file).await.map_err(to_anyhow)?; - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer).await.map_err(to_anyhow)?; - let res = Response::builder() - .header(http::header::CONTENT_TYPE, "text/plain") - .body(Body::from(bytes::Bytes::from(buffer))) - .unwrap(); - return Ok(res); + // SECURITY (defense in depth): refuse to read through a symlink so a planted + // symlink in the logs directory cannot be used to exfiltrate arbitrary files. + // `symlink_metadata` returns the link's own metadata without following it. + match tokio::fs::symlink_metadata(&local_file).await { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(error::Error::BadRequest("Invalid path".to_string())); + } + Ok(_) => { + let mut file = tokio::fs::File::open(&local_file) + .await + .map_err(to_anyhow)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer).await.map_err(to_anyhow)?; + let res = Response::builder() + .header(http::header::CONTENT_TYPE, "text/plain") + .body(Body::from(bytes::Bytes::from(buffer))) + .unwrap(); + return Ok(res); + } + Err(_) => {} } #[cfg(all(feature = "enterprise", feature = "parquet"))] diff --git a/backend/windmill-api/src/service_logs.rs b/backend/windmill-api/src/service_logs.rs index 57131d823e..b1902f7c1a 100644 --- a/backend/windmill-api/src/service_logs.rs +++ b/backend/windmill-api/src/service_logs.rs @@ -133,7 +133,18 @@ async fn get_log_file( } } } - let file = tokio::fs::read(format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path)).await; + let full_path = format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path); + // SECURITY (defense in depth): refuse to read through a symlink so a planted + // symlink in the logs directory cannot be used to exfiltrate arbitrary files. + // `symlink_metadata` returns the link's own metadata without following it. + match tokio::fs::symlink_metadata(&full_path).await { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(Error::BadRequest("Invalid path".to_string())); + } + Ok(_) => {} + Err(_) => return Err(Error::NotFound(format!("File {path} not found"))), + } + let file = tokio::fs::read(&full_path).await; if let Ok(bytes) = file { Ok(content_plain(Body::from(bytes::Bytes::from(bytes)))) } else { diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 2b93d59bc0..35728bc693 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -39,23 +39,43 @@ pub struct StaticFile(Uri); impl IntoResponse for StaticFile { fn into_response(self) -> Response { let original_path = self.0.path(); + let query = self.0.query(); let path = original_path.trim_start_matches('/'); - serve_path(path, original_path) + serve_path(path, original_path, query) } } #[cfg(feature = "static_frontend")] const TWO_HUNDRED: &str = "200.html"; -/// Check if the original path requires cross-origin isolation headers +/// Check if the original path requires cross-origin isolation headers. +/// /// These headers are needed for SharedArrayBuffer and TypeScript workers -/// Only enabled for /apps_raw paths (raw app editor) +/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`). +/// +/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep` +/// query param: a public (raw) app must set COEP to be embeddable as an iframe +/// inside a cross-origin-isolated page (which requires the embedded document to +/// also set COEP). It is opt-in rather than always-on because cross-origin +/// isolation also blocks subresources without CORP (e.g. external image URLs +/// or embeds used by classic apps), so we only enable it when the embedder +/// explicitly requests it. #[cfg(feature = "static_frontend")] -fn needs_cross_origin_isolation(original_path: &str) -> bool { - original_path.starts_with("/apps_raw/") || original_path.starts_with("/ui_builder/") +fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool { + original_path.starts_with("/apps_raw/") + || original_path.starts_with("/ui_builder/") + || ((original_path.starts_with("/public/") || original_path.starts_with("/a/")) + && query_has_flag(query, "wm_coep")) } -fn serve_path(path: &str, original_path: &str) -> Response { +/// Returns true if `query` contains the given flag key (with or without a +/// value), e.g. `?wm_coep`, `?wm_coep=on`, `?foo=1&wm_coep=1`. +#[cfg(feature = "static_frontend")] +fn query_has_flag(query: Option<&str>, flag: &str) -> bool { + query.is_some_and(|q| q.split('&').any(|kv| kv.split('=').next() == Some(flag))) +} + +fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response { if path.starts_with("api/") { return Response::builder().status(404).body(Body::empty()).unwrap(); } @@ -71,7 +91,7 @@ fn serve_path(path: &str, original_path: &str) -> Response { // Add cross-origin isolation headers only for paths that need them // (apps_raw editor needs SharedArrayBuffer for TypeScript workers) - if needs_cross_origin_isolation(original_path) { + if needs_cross_origin_isolation(original_path, query) { res = res .header("Cross-Origin-Opener-Policy", "same-origin") .header("Cross-Origin-Embedder-Policy", "require-corp") @@ -102,12 +122,68 @@ fn serve_path(path: &str, original_path: &str) -> Response { None if path.starts_with("_app/") => { Response::builder().status(404).body(Body::empty()).unwrap() } - None => serve_path(TWO_HUNDRED, original_path), + None => serve_path(TWO_HUNDRED, original_path, query), } #[cfg(not(feature = "static_frontend"))] { - let _ = original_path; // suppress unused warning + let _ = (original_path, query); // suppress unused warning Response::builder().status(404).body(Body::empty()).unwrap() } } + +#[cfg(all(test, feature = "static_frontend"))] +mod tests { + use super::*; + + #[test] + fn test_query_has_flag() { + assert!(query_has_flag(Some("wm_coep"), "wm_coep")); + assert!(query_has_flag(Some("wm_coep=on"), "wm_coep")); + assert!(query_has_flag(Some("foo=1&wm_coep=1"), "wm_coep")); + assert!(query_has_flag(Some("wm_coep&foo=1"), "wm_coep")); + assert!(!query_has_flag(Some("wm_coepx=1"), "wm_coep")); + assert!(!query_has_flag(Some("foo=wm_coep"), "wm_coep")); + assert!(!query_has_flag(Some(""), "wm_coep")); + assert!(!query_has_flag(None, "wm_coep")); + } + + #[test] + fn test_needs_cross_origin_isolation() { + // editor + bundler are always isolated, regardless of query + assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None)); + assert!(needs_cross_origin_isolation("/ui_builder/index.html", None)); + + // public apps (and custom paths) are isolated only when they opt in via wm_coep + assert!(needs_cross_origin_isolation( + "/public/ws/secret", + Some("wm_coep") + )); + assert!(needs_cross_origin_isolation( + "/public/ws/secret", + Some("wm_coep=on") + )); + assert!(needs_cross_origin_isolation( + "/a/ws/my/path", + Some("wm_coep=on") + )); + assert!(!needs_cross_origin_isolation("/public/ws/secret", None)); + assert!(!needs_cross_origin_isolation("/a/ws/my/path", None)); + assert!(!needs_cross_origin_isolation( + "/public/ws/secret", + Some("foo=1") + )); + + // unrelated paths never get the headers + assert!(!needs_cross_origin_isolation( + "/apps/get/foo", + Some("wm_coep") + )); + // `/api/` must not be caught by the `/a/` prefix + assert!(!needs_cross_origin_isolation( + "/api/version", + Some("wm_coep") + )); + assert!(!needs_cross_origin_isolation("/", None)); + } +} diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 58871866b1..d53998355b 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -484,13 +484,13 @@ async fn update_username_in_workpsace<'c>( ).execute(&mut **tx) .await?; - sqlx::query!( - r#"UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, - new_username, - old_username, - w_id - ).execute(&mut **tx) - .await?; + // NB: workspace_runnable_dependencies.app_path is intentionally NOT rewritten here. + // Its FK to app(path, workspace_id) is ON UPDATE CASCADE, so the `UPDATE app SET path` + // below propagates the new path automatically. Rewriting it manually here (before the + // app row is renamed) points the row at a not-yet-existing app path and violates + // fk_workspace_runnable_dependencies_app_path. (flow_path above DOES need the manual + // rewrite because flows are migrated via INSERT-new + DELETE-old, not UPDATE flow.path, + // so the cascade never fires for them.) sqlx::query!( r#"UPDATE workspace_runnable_dependencies SET runnable_path = REGEXP_REPLACE(runnable_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE runnable_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, 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-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 4b6abac8dd..7d74822807 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -283,6 +283,24 @@ pub async fn shutdown_signal( Ok(()) } + // Defined for the whole non-unix scope (not just windows) so it can be a + // plain `tokio::select!` branch: that macro does not accept `#[cfg(...)]` + // attributes on individual branches. On non-windows non-unix targets the + // future never resolves, so the branch is effectively inert there. + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + async fn ctrl_break() -> std::io::Result<()> { + #[cfg(windows)] + { + tokio::signal::windows::ctrl_break()?.recv().await; + Ok(()) + } + #[cfg(not(windows))] + { + std::future::pending::<()>().await; + Ok(()) + } + } + #[cfg(any(target_os = "linux", target_os = "macos"))] tokio::select! { _ = terminate() => { @@ -298,7 +316,12 @@ pub async fn shutdown_signal( #[cfg(not(any(target_os = "linux", target_os = "macos")))] tokio::select! { - _ = tokio::signal::ctrl_c() => {}, + _ = tokio::signal::ctrl_c() => { + tracing::info!("shutdown monitor received ctrl-c"); + }, + _ = ctrl_break() => { + tracing::info!("shutdown monitor received ctrl-break"); + }, _ = rx.recv() => { tracing::info!("shutdown monitor received killpill"); }, @@ -320,6 +343,9 @@ pub async fn shutdown_signal( _ = tokio::signal::ctrl_c() => { tracing::error!("2nd shutdown monitor received ctrl-c") }, + _ = ctrl_break() => { + tracing::error!("2nd shutdown monitor received ctrl-break") + }, } tracing::info!("Second terminate signal received, forcefully exiting"); diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index dd4c0bb42e..38cd444f29 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -393,6 +393,7 @@ pub async fn clone_script<'c>( modules: s.modules, auto_parent: None, labels: s.labels, + skip_draft_deletion: None, }; let new_hash = hash_script(&ns); 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-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index e81a24817f..e20a896103 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -157,7 +157,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28236/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28261/sync-script-to-git-repo-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. diff --git a/backend/windmill-git-sync/src/git_sync_oss.rs b/backend/windmill-git-sync/src/git_sync_oss.rs index ee2c09cf16..a4f82aba2e 100644 --- a/backend/windmill-git-sync/src/git_sync_oss.rs +++ b/backend/windmill-git-sync/src/git_sync_oss.rs @@ -33,3 +33,15 @@ pub async fn handle_fork_branch_creation<'c>( ) -> Result> { return Ok(vec![]); } + +#[cfg(not(feature = "private"))] +pub async fn handle_deployment_metadata_batch<'c>( + _email: &str, + _created_by: &str, + _db: &DB, + _w_id: &str, + _objs: Vec, + _deployment_message: Option, +) -> Result<()> { + return Ok(()); +} diff --git a/backend/windmill-git-sync/src/lib.rs b/backend/windmill-git-sync/src/lib.rs index efbc562cf9..b10f62e7a5 100644 --- a/backend/windmill-git-sync/src/lib.rs +++ b/backend/windmill-git-sync/src/lib.rs @@ -13,10 +13,14 @@ pub mod git_sync_ee; pub mod git_sync_oss; #[cfg(feature = "private")] -pub use git_sync_ee::{handle_deployment_metadata, handle_fork_branch_creation}; +pub use git_sync_ee::{ + handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation, +}; #[cfg(not(feature = "private"))] -pub use git_sync_oss::{handle_deployment_metadata, handle_fork_branch_creation}; +pub use git_sync_oss::{ + handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation, +}; #[derive(Clone, Debug)] pub enum DeployedObject { 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/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 4cac407adf..964fd558f1 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -5387,6 +5387,7 @@ async fn push_inner<'c, 'd>( cache_ttl: cache_ttl.map(|val| val as u32), cache_ignore_s3_path: cache_ignore_s3_path, same_worker: false, + preserve_step_tags: false, early_return: None, skip_expr: None, preprocessor_module: None, 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)] diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 8b60eb0d44..9335f60d4f 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -110,6 +110,11 @@ pub struct NewFlow { pub ws_error_handler_muted: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this flow must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } impl NewFlow { @@ -169,6 +174,13 @@ pub struct FlowValue { #[serde(default)] #[serde(skip_serializing_if = "is_default")] pub same_worker: bool, + // When the flow runs on a custom worker tag, by default that tag is propagated to + // (and overrides) every step, script and nested sub-flow. Set this to true to instead + // let steps that declare their own non-empty tag run on it; steps without their own tag + // still inherit the flow's tag. Defaults to false to preserve the historical behavior. + #[serde(default)] + #[serde(skip_serializing_if = "is_default")] + pub preserve_step_tags: bool, #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, #[serde(flatten)] diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index e9e9337e6d..c26947d8d6 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -540,9 +540,19 @@ pub struct NewScript { pub auto_parent: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this script must NOT delete an existing user draft at the same path. + /// Transient — never persisted. Deliberately excluded from `impl Hash` + /// below (it must not affect the version hash) and from the no-op + /// comparison in the deploy handler (it isn't part of what the script + /// *is*). See `is_noop_deploy_against_parent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } // IMPORTANT: update this Hash impl when adding fields to NewScript +// (exception: caller-intent flags like `skip_draft_deletion` are intentionally +// omitted — they must not influence the computed version hash) impl Hash for NewScript { fn hash(&self, state: &mut H) { self.path.hash(state); diff --git a/backend/windmill-worker/nsjail/download_deps.py.sh b/backend/windmill-worker/nsjail/download_deps.py.sh index ce056e7e76..13fc00ddf9 100755 --- a/backend/windmill-worker/nsjail/download_deps.py.sh +++ b/backend/windmill-worker/nsjail/download_deps.py.sh @@ -31,6 +31,7 @@ $PY_PATH $INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG --system --reinstall +--compile-bytecode " echo $CMD 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/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 4bec8d44bf..3895c823b7 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -2095,6 +2095,9 @@ async fn spawn_uv_install( "--no-cache", // If we invoke uv pip install, then we want to overwrite existing data "--reinstall", + // Compile .py to .pyc at install time so imports are fast even + // through read-only nsjail mounts (no in-memory compilation per job). + "--compile-bytecode", ]; if let Some(py_path) = py_path.as_ref() { diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 6e2deb7446..281a9785c2 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -2985,6 +2985,87 @@ struct PushNextFlowJobRec { // #[async_recursion] // #[instrument(level = "trace", skip_all)] +/// Resolve the worker tag for a flow's child job (step, nested sub-flow, preprocessor). +/// +/// A child normally inherits the parent flow job's tag so the whole flow runs on one worker +/// group. The exceptions, in order: +/// - the preprocessor step, or a flow running on the generic `flow` / `flow-{workspace}` tag, +/// always uses the child's own tag (`step_tag`); +/// - when the flow opts into `preserve_step_tags` and the child declares its own non-empty tag, +/// that tag is honored instead of being overridden by the flow tag; +/// - otherwise the child inherits the parent flow job's tag. +fn resolve_flow_step_tag( + is_preprocessor_step: bool, + flow_tag: &str, + workspace_id: &str, + preserve_step_tags: bool, + step_tag: Option<&str>, +) -> Option { + if is_preprocessor_step || flow_tag == "flow" || flow_tag == format!("flow-{}", workspace_id) { + step_tag.map(str::to_string) + } else if preserve_step_tags && step_tag.is_some_and(|t| !t.is_empty()) { + step_tag.map(str::to_string) + } else { + Some(flow_tag.to_string()) + } +} + +#[cfg(test)] +mod tag_resolution_tests { + use super::resolve_flow_step_tag; + + #[test] + fn step_inherits_custom_flow_tag_by_default() { + // Parent flow on a custom tag, step declares its own tag, preserve disabled: + // the step inherits the flow tag (historical behavior). + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", false, Some("worker-group-B")), + Some("worker-group-A".to_string()) + ); + } + + #[test] + fn step_keeps_own_tag_when_preserve_enabled() { + // The exact customer scenario: a sub-flow tagged worker-group-B run as a step of a + // flow tagged worker-group-A now runs on worker-group-B when preserve_step_tags is on. + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("worker-group-B")), + Some("worker-group-B".to_string()) + ); + } + + #[test] + fn untagged_step_inherits_flow_tag_even_when_preserve_enabled() { + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", true, None), + Some("worker-group-A".to_string()) + ); + // An empty tag counts as "no tag" and still inherits. + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("")), + Some("worker-group-A".to_string()) + ); + } + + #[test] + fn generic_flow_tag_always_uses_step_tag() { + for flow_tag in ["flow", "flow-w1"] { + assert_eq!( + resolve_flow_step_tag(false, flow_tag, "w1", false, Some("worker-group-B")), + Some("worker-group-B".to_string()) + ); + } + } + + #[test] + fn preprocessor_step_uses_step_tag() { + assert_eq!( + resolve_flow_step_tag(true, "worker-group-A", "w1", false, Some("worker-group-B")), + Some("worker-group-B".to_string()) + ); + } +} + async fn push_next_flow_job( flow_job: Arc, mut status: FlowStatus, @@ -4118,13 +4199,13 @@ async fn push_next_flow_job( .map(|x| x.into()); tracing::debug!(id = %flow_job.id, root_id = %job_root, "computed perms for job {i} of {len}"); - let tag = if step.is_preprocessor_step() - || (flow_job.tag == "flow" || flow_job.tag == format!("flow-{}", flow_job.workspace_id)) - { - payload_tag.tag.clone() - } else { - Some(flow_job.tag.clone()) - }; + let tag = resolve_flow_step_tag( + step.is_preprocessor_step(), + &flow_job.tag, + &flow_job.workspace_id, + flow.preserve_step_tags, + payload_tag.tag.as_deref(), + ); let (email, permissioned_as) = if let Some(on_behalf_of) = payload_tag.on_behalf_of.as_ref() { @@ -4822,6 +4903,7 @@ fn payload_from_modules<'a>( modules_node: Option, failure_module: Option<&Box>, same_worker: bool, + preserve_step_tags: bool, id: impl FnOnce() -> String, path: impl FnOnce() -> String, opt_empty_inner_flows: bool, @@ -4842,7 +4924,13 @@ fn payload_from_modules<'a>( } Some(JobPayload::RawFlow { - value: FlowValue { modules, failure_module, same_worker, ..Default::default() }, + value: FlowValue { + modules, + failure_module, + same_worker, + preserve_step_tags, + ..Default::default() + }, path: Some(path()), restarted_from: None, }) @@ -5144,6 +5232,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{i}", status.step), || format!("{}/forloop-{i}", flow_job.runnable_path()), true, @@ -5280,6 +5369,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || status.step.to_string(), || format!("{}/branchone-{}", flow_job.runnable_path(), branch_idx), true, @@ -5321,6 +5411,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{i}", status.step), || format!("{}/branchall-{}", flow_job.runnable_path(), i), false, @@ -5391,6 +5482,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{}", status.step, branch_status.branch), || { format!( @@ -5473,6 +5565,7 @@ async fn next_loop_iteration( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{}", status.step, ns.index), inner_path, true, diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index deb8f1cf66..dff2f0fa12 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.714.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index a6f85fde1c..6f0263c5cf 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -192,6 +192,8 @@ export async function pushApp( deployment_message: message, ...localAppBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -205,6 +207,8 @@ export async function pushApp( deployment_message: message, ...localAppBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -480,6 +484,8 @@ const command = new Command() on_behalf_of_email: email, } as any, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); log.info(colors.green(`Updated permissioned_as for app ${appPath} to ${email}`)); diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 5b9e880adc..fcfc68915e 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -466,6 +466,8 @@ export async function pushRawApp( summary: localApp.summary, policy: appForPolicy.policy, deployment_message: message, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, ...(localApp.custom_path ? { custom_path: localApp.custom_path } : {}), @@ -486,6 +488,8 @@ export async function pushRawApp( summary: localApp.summary, policy: appForPolicy.policy, deployment_message: message, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, ...(localApp.custom_path ? { custom_path: localApp.custom_path } : {}), diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 4717711ae5..c0e94c478d 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -224,6 +224,8 @@ export async function pushFlow( deployment_message: message, ...localFlowBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -237,6 +239,8 @@ export async function pushFlow( deployment_message: message, ...localFlowBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } catch (e) { @@ -1159,6 +1163,8 @@ const command = new Command() path: flowPath, on_behalf_of_email: email, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, } as any, }); log.info(colors.green(`Updated permissioned_as for flow ${flowPath} to ${email}`)); diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 808456f151..0299744056 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -758,6 +758,9 @@ async function createScript( workspace: Workspace ): Promise { const start = performance.now(); + // Preserve any user draft at this path: a CLI / git-sync deploy must not wipe + // an in-progress draft the way a UI "deploy from draft" intentionally does. + body = { ...body, skip_draft_deletion: true }; // skip_if_noop asks the backend to treat deploys identical to the parent // (same content, lockfile, and metadata) as a no-op, so the CLI does not // produce phantom git-sync / promotion commits on re-pushes. @@ -1796,6 +1799,8 @@ async function setPermissionedAs( parent_hash: remote.hash, on_behalf_of_email: email, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); log.info(colors.green(`Updated permissioned_as for script ${scriptPath} to ${email}`)); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 625fe69780..2070ceb590 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"; @@ -2503,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; } } @@ -3040,12 +3053,13 @@ export async function gitDeploy( ...(opts.extraIncludes ?? []), ...includes.extraIncludes, ], - includeSchedules: opts.includeSchedules || includes.includeSchedules, - includeGroups: opts.includeGroups || includes.includeGroups, - includeUsers: opts.includeUsers || includes.includeUsers, - includeTriggers: opts.includeTriggers || includes.includeTriggers, - includeSettings: opts.includeSettings || includes.includeSettings, - includeKey: opts.includeKey || includes.includeKey, + // Workspace-wide mode force-includes the deployed default-excluded kinds + // (full mirror). Individual-branch/promotion mode forces nothing — these + // keys stay ABSENT so pull resolves them from the promotion target's + // effective wmill.yaml filters. Spreading (not setting `false`) is what + // makes the deferral work: an explicit `false` would clobber the effective + // config in pull's Object.assign-based option merge. + ...includes.forcedIncludes, promotion, } as any); } @@ -3095,16 +3109,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( @@ -4022,6 +4042,10 @@ export async function push( originalWorkspaceSpecificPath, permissionedAsContext, isWsSpecific ? true : undefined, + { + noninteractive: (opts.yes ?? false) || !process.stdin.isTTY, + skipReencrypt: opts.skipReencryptOnKeyChange, + }, ); if (stateTarget) { @@ -4107,6 +4131,10 @@ export async function push( localFilePath, // Pass the actual local file path permissionedAsContext, isAddedWsSpecific ? true : undefined, + { + noninteractive: (opts.yes ?? false) || !process.stdin.isTTY, + skipReencrypt: opts.skipReencryptOnKeyChange, + }, ); if (stateTarget) { @@ -4663,6 +4691,10 @@ const command = new Command() .option("--include-groups", "Include syncing groups") .option("--include-settings", "Include syncing workspace settings") .option("--include-key", "Include workspace encryption key") + .option( + "--skip-reencrypt-on-key-change", + "When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.", + ) .option("--skip-branch-validation", "Skip git branch validation and prompts") .option("--json-output", "Output results in JSON format") .option( diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 5656cfe9d4..c4fda73b1b 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -88,6 +88,7 @@ export interface SyncOptions { includeGroups?: boolean; includeSettings?: boolean; includeKey?: boolean; + skipReencryptOnKeyChange?: boolean; skipBranchValidation?: boolean; message?: string; includes?: string[]; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 4b86cb3469..e4b618180f 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -445,11 +445,23 @@ export async function pushWorkspaceSettings( } } +export interface PushWorkspaceKeyOptions { + // True when no prompt may be shown (e.g. `--yes` was passed or stdin is not a + // TTY). In that case the re-encryption decision is taken from `skipReencrypt` + // / the WMILL_NO_REENCRYPT_ON_KEY_CHANGE env var instead of an interactive + // confirmation. + noninteractive?: boolean; + // Explicit re-encryption decision from `--skip-reencrypt-on-key-change`. + // When set it takes precedence over the prompt and the env var. + skipReencrypt?: boolean; +} + export async function pushWorkspaceKey( workspace: string, _path: string, key: string | undefined, - localKey: string + localKey: string, + opts?: PushWorkspaceKeyOptions ) { try { key = await wmill @@ -461,17 +473,46 @@ export async function pushWorkspaceKey( throw new Error(`Failed to get workspace encryption key: ${err}`); } if (localKey && key !== localKey) { - const confirm = await Confirm.prompt({ - message: - "The local workspace encryption key does not match the remote. Do you want to reencrypt all your secrets on the remote with the new key?\nSay 'no' if your local secrets are already encrypted with the new key (e.g. workspace/instance migration)\nOtherwise, say 'yes' and pull the secrets after the reencryption.\n", - default: true, - }); + // Changing the key on the remote means the existing ciphertexts (encrypted + // with the old key) become unreadable unless they are re-encrypted. By + // default we ask the backend to re-encrypt every secret variable with the + // new key, which preserves their plaintext values. The only reason to skip + // re-encryption is when the stored ciphertexts are *already* encrypted with + // the new key (e.g. a workspace/instance migration). + let reencrypt: boolean; + // Explicit choice via `--skip-reencrypt-on-key-change` or the env var wins + // over everything, regardless of interactivity. + const explicitSkip = + opts?.skipReencrypt || + (process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE ?? "").toLowerCase() === + "true"; + if (explicitSkip) { + reencrypt = false; + log.info( + "Workspace encryption key changed; leaving remote ciphertexts untouched (skip re-encryption requested)." + ); + } else if (opts?.noninteractive) { + // No TTY (or --yes) and no explicit skip: we can't prompt, so default to + // re-encrypting (matches the interactive default) to preserve secret + // values. Pass --skip-reencrypt-on-key-change (or set + // WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true) to opt out. + reencrypt = true; + log.info( + "Workspace encryption key changed; re-encrypting all remote secrets with the new key (non-interactive)." + ); + } else { + reencrypt = await Confirm.prompt({ + message: + "The local workspace encryption key does not match the remote. Do you want to reencrypt all your secrets on the remote with the new key?\nSay 'no' if your local secrets are already encrypted with the new key (e.g. workspace/instance migration)\nOtherwise, say 'yes' and pull the secrets after the reencryption.\n", + default: true, + }); + } log.debug(`Updating workspace encryption key...`); await wmill.setWorkspaceEncryptionKey({ workspace, requestBody: { new_key: localKey, - skip_reencrypt: !confirm, + skip_reencrypt: !reencrypt, }, }); } else { diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 714fdec222..8b6f21053d 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -10,7 +10,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "write-script-bash", description: "MUST use when writing Bash scripts.", languageKey: "bash" }, { name: "write-script-bigquery", description: "MUST use when writing BigQuery queries.", languageKey: "bigquery" }, { name: "write-script-bun", description: "MUST use when writing Bun/TypeScript scripts.", languageKey: "bun" }, - { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts.", languageKey: "bunnative" }, + { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker.", languageKey: "bunnative" }, { name: "write-script-csharp", description: "MUST use when writing C# scripts.", languageKey: "csharp" }, { name: "write-script-deno", description: "MUST use when writing Deno/TypeScript scripts.", languageKey: "deno" }, { name: "write-script-duckdb", description: "MUST use when writing DuckDB queries.", languageKey: "duckdb" }, @@ -19,7 +19,6 @@ export const SKILLS: SkillMetadata[] = [ { name: "write-script-java", description: "MUST use when writing Java scripts.", languageKey: "java" }, { name: "write-script-mssql", description: "MUST use when writing MS SQL Server queries.", languageKey: "mssql" }, { name: "write-script-mysql", description: "MUST use when writing MySQL queries.", languageKey: "mysql" }, - { name: "write-script-nativets", description: "MUST use when writing Native TypeScript scripts.", languageKey: "nativets" }, { name: "write-script-php", description: "MUST use when writing PHP scripts.", languageKey: "php" }, { name: "write-script-postgresql", description: "MUST use when writing PostgreSQL queries.", languageKey: "postgresql" }, { name: "write-script-powershell", description: "MUST use when writing PowerShell scripts.", languageKey: "powershell" }, @@ -926,7 +925,7 @@ ducklake(name: string = "main"): SqlTemplateFunction `, "write-script-bunnative": `--- name: write-script-bunnative -description: MUST use when writing Bun Native scripts. +description: MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker. --- ## CLI Commands @@ -966,13 +965,14 @@ Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes \`fetch\` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with \`//native\` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. \`./helper.ts\`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on \`fetch\` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, \`node:*\` modules, child processes, native addons) will not work on the native worker; use the regular \`bun\` language for those. ## Structure Export a single **async** function called \`main\`: \`\`\`typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -988,6 +988,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the \`RT\` namespace for resource types: \`\`\`typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -999,9 +1000,10 @@ Before using a resource type, check the \`rt.d.ts\` file in the project root to ## Imports -**No imports allowed.** Use the globally available \`fetch\` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides \`fetch\` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (\`node:fs\`, \`child_process\`, the \`Bun\` API, native modules) belongs in a regular \`bun\` script instead. Use the globally available \`fetch\` for HTTP: \`\`\`typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -1010,13 +1012,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +\`windmill-client\` is available for Windmill-specific primitives such as the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). Use \`fetch\` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: \`\`\`typescript +//native type Event = { kind: | "webhook" @@ -1049,6 +1052,7 @@ Windmill provides built-in support for S3-compatible storage operations. The \`w ### Receiving an S3Object as a script parameter \`\`\`typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -1060,6 +1064,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations \`\`\`typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 @@ -2976,682 +2981,6 @@ All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storag omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or \`csv\`). Use this for large result sets — rows stream directly to S3 instead of being buffered as the script return value. -`, - "write-script-nativets": `--- -name: write-script-nativets -description: MUST use when writing Native TypeScript scripts. ---- - -## CLI Commands - -Place scripts in a folder. - -After writing, tell the user which command fits what they want to do: - -- \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. -- \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. -- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". - -### Preview vs run — choose by intent, not habit - -If the user says "run the script", "try it", "test it", "does it work" while there are **local edits to the script file**, use \`script preview\`. Do NOT push the script to then \`script run\` it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes. - -Only use \`script run\` when: -- The user explicitly says "run the deployed version" / "run what's on the server". -- There is no local script being edited (you're just invoking an existing script). - -Only use \`sync push\` when: -- The user explicitly asks to deploy, publish, push, or ship. -- The preview has already validated the change and the user wants it in the workspace. - -### After writing — offer to test, don't wait passively - -If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. - -If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. - -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. - -For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called \`main\`: - -\`\`\`typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -\`\`\` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the \`RT\` namespace for resource types: - -\`\`\`typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -\`\`\` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. - -## Imports - -**No imports allowed.** Use the globally available \`fetch\` function: - -\`\`\`typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -\`\`\` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: - -\`\`\`typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -\`\`\` - - -# TypeScript SDK (windmill-client) - -Import: import * as wmill from 'windmill-client' - -workerHasInternalServer(): boolean - -/** - * Initialize the Windmill client with authentication token and base URL - * @param token - Authentication token (defaults to WM_TOKEN env variable) - * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable) - */ -setClient(token?: string, baseUrl?: string): void - -/** - * Create a client configuration from env variables - * @returns client configuration - */ -getWorkspace(): string - -/** - * Get a resource value by path - * @param path path of the resource, default to internal state path - * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error - * @returns resource value - */ -async getResource(path?: string, undefinedIfEmpty?: boolean): Promise - -/** - * Get the true root job id - * @param jobId job id to get the root job id from (default to current job) - * @returns root job id - */ -async getRootJobId(jobId?: string): Promise - -/** - * @deprecated Use runScriptByPath or runScriptByHash instead - */ -async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its path and wait for the result - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its hash and wait for the result - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Append a text to the result stream - * @param text text to append to the result stream - */ -appendToResultStream(text: string): void - -/** - * Stream to the result stream - * @param stream stream to stream to the result stream - */ -async streamResult(stream: AsyncIterable): Promise - -/** - * Run a flow synchronously by its path and wait for the result - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param verbose - Enable verbose logging - * @returns Flow execution result - */ -async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Wait for a job to complete and return its result - * @param jobId - ID of the job to wait for - * @param verbose - Enable verbose logging - * @returns Job result when completed - */ -async waitJob(jobId: string, verbose: boolean = false): Promise - -/** - * Get the result of a completed job - * @param jobId - ID of the completed job - * @returns Job result - */ -async getResult(jobId: string): Promise - -/** - * Get the result of a job if completed, or its current status - * @param jobId - ID of the job - * @returns Object with started, completed, success, and result properties - */ -async getResultMaybe(jobId: string): Promise - -/** - * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead - */ -async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its path - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its hash - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a flow asynchronously by its path - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) - * @returns Job ID of the created job - */ -async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise - -/** - * Resolve a resource value in case the default value was picked because the input payload was undefined - * @param obj resource value or path of the resource under the format \`$res:path\` - * @returns resource value - */ -async resolveDefaultResource(obj: any): Promise - -/** - * Get the state file path from environment variables - * @returns State path string - */ -getStatePath(): string - -/** - * Set a resource value by path - * @param path path of the resource to set, default to state path - * @param value new value of the resource to set - * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type - */ -async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise - -/** - * Set the state - * @param state state to set - * @deprecated use setState instead - */ -async setInternalState(state: any): Promise - -/** - * Set the state - * @param state state to set - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async setState(state: any, path?: string): Promise - -/** - * Set the progress - * Progress cannot go back and limited to 0% to 99% range - * @param percent Progress to set in % - * @param jobId? Job to set progress for - */ -async setProgress(percent: number, jobId?: any): Promise - -/** - * Get the progress - * @param jobId? Job to get progress from - * @returns Optional clamped between 0 and 100 progress value - */ -async getProgress(jobId?: any): Promise - -/** - * Set a flow user state - * @param key key of the state - * @param value value of the state - */ -async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise - -/** - * Get a flow user state - * @param path path of the variable - */ -async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise - -/** - * Get the internal state - * @deprecated use getState instead - */ -async getInternalState(): Promise - -/** - * Get the state shared across executions - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async getState(path?: string): Promise - -/** - * Get a variable by path - * @param path path of the variable - * @returns variable value - */ -async getVariable(path: string): Promise - -/** - * Set a variable by path, create if not exist - * @param path path of the variable - * @param value value of the variable - * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) - * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") - */ -async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise - -/** - * Build a PostgreSQL connection URL from a database resource - * @param path - Path to the database resource - * @returns PostgreSQL connection URL string - */ -async databaseUrlFromResource(path: string): Promise - -async polarsConnectionSettings(s3_resource_path: string | undefined): Promise - -async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise - -/** - * Get S3 client settings from a resource or workspace default - * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - * @returns S3 client configuration settings - */ -async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContent = await wmill.loadS3FileContent(inputFile) - * // if the file is a raw text file, it can be decoded and printed directly: - * const text = new TextDecoder().decode(fileContentStream) - * console.log(text); - * \`\`\` - * - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - */ -async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContentBlob = await wmill.loadS3FileStream(inputFile) - * // if the content is plain text, the blob can be read directly: - * console.log(await fileContentBlob.text()); - * \`\`\` - * - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * const s3object = await writeS3File(s3Object, "Hello Windmill!") - * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') - * console.log(fileContentAsUtf8Str) - * \`\`\` - * - * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) - */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Permanently delete a file from S3 by key. - * - * \`\`\`typescript - * await wmill.deleteS3File({ s3: "path/to/file.txt" }) - * \`\`\` - * - * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) - * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) - */ -async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise - -/** - * Sign S3 objects to be used by anonymous users in public apps - * @param s3objects s3 objects to sign - * @returns signed s3 objects - */ -async signS3Objects(s3objects: S3Object[]): Promise - -/** - * Sign S3 object to be used by anonymous users in public apps - * @param s3object s3 object to sign - * @returns signed s3 object - */ -async signS3Object(s3object: S3Object): Promise - -/** - * Generate a presigned public URL for an array of S3 objects. - * If an S3 object is not signed yet, it will be signed first. - * @param s3Objects s3 objects to sign - * @returns list of signed public URLs - */ -async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. - * @param s3Object s3 object to sign - * @returns signed public URL - */ -async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Get URLs needed for resuming a flow after this step - * @param approver approver name - * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. - * This allows pre-approvals that can be consumed by any later suspend step in the same flow. - * @returns approval page UI URL, resume and cancel API URLs for resuming the flow - */ -async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * @deprecated use getResumeUrls instead - */ -getResumeEndpoints(approver?: string): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) - * @param audience audience of the token - * @param expiresIn Optional number of seconds until the token expires - * @returns jwt token - */ -async getIdToken(audience: string, expiresIn?: number): Promise - -/** - * Convert a base64-encoded string to Uint8Array - * @param data - Base64-encoded string - * @returns Decoded Uint8Array - */ -base64ToUint8Array(data: string): Uint8Array - -/** - * Convert a Uint8Array to base64-encoded string - * @param arrayBuffer - Uint8Array to encode - * @returns Base64-encoded string - */ -uint8ArrayToBase64(arrayBuffer: Uint8Array): string - -/** - * Get email from workspace username - * This method is particularly useful for apps that require the email address of the viewer. - * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. - * @param username - * @returns email address - */ -async usernameToEmail(username: string): Promise - -/** - * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Slack approval request. - * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. - * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Slack approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * @param {string} [options.resumeButtonText] - Optional text for the resume button. - * @param {string} [options.cancelButtonText] - Optional text for the cancel button. - * - * @returns {Promise} Resolves when the Slack approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getSlackApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveSlackApproval({ - * slackResourcePath: "/u/alex/my_slack_resource", - * channelId: "admins-slack-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * resumeButtonText: "Resume", - * cancelButtonText: "Cancel", - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise - -/** - * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Teams approval request. - * @param {string} options.teamName - The Teams team name where the approval request will be sent. - * @param {string} options.channelName - The Teams channel name where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Teams approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * - * @returns {Promise} Resolves when the Teams approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getTeamsApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveTeamsApproval({ - * teamName: "admins-teams", - * channelName: "admins-teams-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise - -/** - * Parse an S3 object from URI string or record format - * @param s3Object - S3 object as URI string (s3://storage/key) or record - * @returns S3 object record with storage and s3 key - */ -parseS3Object(s3Object: S3Object): S3ObjectRecord - -setWorkflowCtx(ctx: WorkflowCtx | null): void - -async sleep(seconds: number): Promise - -async step(name: string, fn: () => T | Promise): Promise - -/** - * Create a task that dispatches to a separate Windmill script. - * - * @example - * const extract = taskScript("f/data/extract"); - * // inside workflow: await extract({ url: "https://..." }) - */ -taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Create a task that dispatches to a separate Windmill flow. - * - * @example - * const pipeline = taskFlow("f/etl/pipeline"); - * // inside workflow: await pipeline({ input: data }) - */ -taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Mark an async function as a workflow-as-code entry point. - * - * The function must be **deterministic**: given the same inputs it must call - * tasks in the same order on every replay. Branching on task results is fine - * (results are replayed from checkpoint), but branching on external state - * (current time, random values, external API calls) must use \`step()\` to - * checkpoint the value so replays see the same result. - */ -workflow(fn: (...args: any[]) => Promise): void - -/** - * Suspend the workflow and wait for an external approval. - * - * Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage - * URLs before calling this function. - * - * @example - * const urls = await step("urls", () => getResumeUrls()); - * await step("notify", () => sendEmail(urls.approvalPage)); - * const { value, approver } = await waitForApproval({ timeout: 3600 }); - */ -waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> - -/** - * Process items in parallel with optional concurrency control. - * - * Each item is processed by calling \`fn(item)\`, which should be a task(). - * Items are dispatched in batches of \`concurrency\` (default: all at once). - * - * @example - * const process = task(async (item: string) => { ... }); - * const results = await parallel(items, process, { concurrency: 5 }); - */ -async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise - -/** - * Commit Kafka offsets for a trigger with auto_commit disabled. - * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) - * @param topic - Kafka topic name (from event.topic) - * @param partition - Partition number (from event.partition) - * @param offset - Message offset to commit (from event.offset) - */ -async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise - -/** - * Create a SQL template function for PostgreSQL/datatable queries - * @param name - Database/datatable name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.datatable() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age}::int - * \`.fetch() - */ -datatable(name: string = "main"): DatatableSqlTemplateFunction - -/** - * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.ducklake() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age} - * \`.fetch() - */ -ducklake(name: string = "main"): SqlTemplateFunction `, "write-script-php": `--- name: write-script-php @@ -5502,7 +4831,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"type":"object","description":"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, "raw-app": `--- name: raw-app description: MUST use when creating raw apps. @@ -7279,6 +6608,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--include-groups\` - Include syncing groups - \`--include-settings\` - Include syncing workspace settings - \`--include-key\` - Include workspace encryption key + - \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - \`--skip-branch-validation\` - Skip git branch validation and prompts - \`--json-output\` - Output results in JSON format - \`-i --includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string) 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/src/main.ts b/cli/src/main.ts index cc8f9e80b7..da4a46da71 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.714.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; diff --git a/cli/src/types.ts b/cli/src/types.ts index e0d45adb66..5de6cea48b 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -18,7 +18,11 @@ import { pushSchedule } from "./commands/schedule/schedule.ts"; import { pushWorkspaceUser } from "./commands/user/user.ts"; import { pushGroup } from "./commands/user/user.ts"; import { pushWorkspaceDependencies } from "./commands/dependencies/dependencies.ts"; -import { pushWorkspaceSettings, pushWorkspaceKey } from "./core/settings.ts"; +import { + pushWorkspaceSettings, + pushWorkspaceKey, + PushWorkspaceKeyOptions, +} from "./core/settings.ts"; import { pushTrigger, pushNativeTrigger } from "./commands/trigger/trigger.ts"; import { pushRawApp } from "./commands/app/raw_apps.ts"; import type { PermissionedAsContext } from "./core/permissioned_as.ts"; @@ -129,11 +133,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 @@ -144,6 +183,7 @@ export function showConflict(path: string, local: string, remote: string) { * @param alreadySynced - Array to track already synced items * @param message - Optional commit/update message * @param originalLocalPath - The original local file path (used for branch-specific resource file resolution) + * @param keyPushOpts - Options for the encryption_key push: non-interactive flag and explicit re-encryption choice */ export async function pushObj( workspace: string, @@ -156,6 +196,7 @@ export async function pushObj( originalLocalPath?: string, permissionedAsContext?: PermissionedAsContext, wsSpecific?: boolean, + keyPushOpts?: PushWorkspaceKeyOptions, ) { const typeEnding = getTypeStrFromPath(p); @@ -221,7 +262,7 @@ export async function pushObj( } else if (typeEnding === "settings") { await pushWorkspaceSettings(workspace, p, befObj, newObj); } else if (typeEnding === "encryption_key") { - await pushWorkspaceKey(workspace, p, befObj, newObj); + await pushWorkspaceKey(workspace, p, befObj, newObj, keyPushOpts); } else { throw new Error( `The item ${p} has an unrecognized type ending ${typeEnding}` diff --git a/cli/src/utils/git.ts b/cli/src/utils/git.ts index 6e0ba55ec9..0d2e8cb8a8 100644 --- a/cli/src/utils/git.ts +++ b/cli/src/utils/git.ts @@ -252,21 +252,44 @@ export function gitSyncIncludePattern( } } -export interface GitSyncDeployIncludes { - extraIncludes: string[]; +// `forcedIncludes` carries ONLY the include-* flags that must be force-set to +// true (overriding the repo's wmill.yaml). Kinds not present are intentionally +// omitted (never set to false) so the caller can spread this object and let +// the repo's effective config govern the rest — see deriveGitSyncDeployIncludes. +export type GitSyncForcedIncludes = Partial<{ includeSchedules: boolean; includeGroups: boolean; includeUsers: boolean; includeTriggers: boolean; includeSettings: boolean; includeKey: boolean; +}>; + +export interface GitSyncDeployIncludes { + extraIncludes: string[]; + forcedIncludes: GitSyncForcedIncludes; } // Mirrors the hub script's wmill_sync_pull include-derivation: build the -// --extra-includes set from the deployed items, and (only in workspace-wide -// mode — never with --use-individual-branch) opt object kinds that are -// excluded by default back in. Replaces the script's regexFromPath + +// --extra-includes set from the deployed items, and decide which default- +// excluded object kinds (triggers, schedules, groups, users, settings, key) +// must be force-included in the pull. Replaces the script's regexFromPath + // per-kind --include-* construction so the hub script can drop both. +// +// Branch-mode distinction (this is load-bearing — see the trigger-promotion +// bug it fixes): +// - Workspace-wide mode: the repo is a full mirror of the workspace, so a +// deployed object of a default-excluded kind MUST be re-included, even if +// wmill.yaml would otherwise skip it. We force the flag on. +// - Individual-branch (promotion) mode: the repo is a filtered prod surface +// whose own wmill.yaml filters decide what gets promoted. We force NOTHING +// here and the keys stay absent, so the caller's pull resolves them from +// the target's effective config (a deployed trigger lands iff the target +// includes triggers). Forcing `false` (the original behavior) did NOT +// defer — it CLOBBERED the effective config via Object.assign in pull's +// option merge, silently dropping kinds the target actually wanted (e.g. a +// deployed trigger when the target has includeTriggers: true), and the +// server then omitted the object from the tarball entirely. export function deriveGitSyncDeployIncludes( items: GitSyncDeployItem[], useIndividualBranch: boolean, @@ -283,18 +306,19 @@ export function deriveGitSyncDeployIncludes( } } - const has = (pred: (t: string) => boolean) => - !useIndividualBranch && items.some((i) => pred(i.path_type)); + const forcedIncludes: GitSyncForcedIncludes = {}; + if (!useIndividualBranch) { + const has = (pred: (t: string) => boolean) => + items.some((i) => pred(i.path_type)); + if (has((t) => t === "schedule")) forcedIncludes.includeSchedules = true; + if (has((t) => t === "group")) forcedIncludes.includeGroups = true; + if (has((t) => t === "user")) forcedIncludes.includeUsers = true; + if (has((t) => t.includes("trigger"))) forcedIncludes.includeTriggers = true; + if (has((t) => t === "settings")) forcedIncludes.includeSettings = true; + if (has((t) => t === "key")) forcedIncludes.includeKey = true; + } - return { - extraIncludes, - includeSchedules: has((t) => t === "schedule"), - includeGroups: has((t) => t === "group"), - includeUsers: has((t) => t === "user"), - includeTriggers: has((t) => t.includes("trigger")), - includeSettings: has((t) => t === "settings"), - includeKey: has((t) => t === "key"), - }; + return { extraIncludes, forcedIncludes }; } function git( diff --git a/cli/test/git_unit.test.ts b/cli/test/git_unit.test.ts index f55e8d46b4..ebbc838f97 100644 --- a/cli/test/git_unit.test.ts +++ b/cli/test/git_unit.test.ts @@ -254,7 +254,7 @@ describe("deriveGitSyncDeployIncludes", () => { ]); }); - test("workspace-wide mode opts excluded kinds back in", () => { + test("workspace-wide mode force-includes deployed default-excluded kinds", () => { const r = deriveGitSyncDeployIncludes( [ { path_type: "schedule", path: "f/s" }, @@ -266,15 +266,36 @@ describe("deriveGitSyncDeployIncludes", () => { ], false ); - expect(r.includeSchedules).toBe(true); - expect(r.includeGroups).toBe(true); - expect(r.includeTriggers).toBe(true); - expect(r.includeSettings).toBe(true); - expect(r.includeKey).toBe(true); - expect(r.includeUsers).toBe(true); + // Full-mirror repo: a deployed object of a default-excluded kind must be + // re-included even if wmill.yaml would skip it, so the flag is forced on. + expect(r.forcedIncludes).toEqual({ + includeSchedules: true, + includeGroups: true, + includeTriggers: true, + includeSettings: true, + includeKey: true, + includeUsers: true, + }); }); - test("individual-branch mode NEVER sets include flags (matches hub script)", () => { + test("workspace-wide mode only forces the kinds actually deployed", () => { + const r = deriveGitSyncDeployIncludes( + [{ path_type: "script", path: "f/s" }], + false + ); + // Scripts are included by default — nothing to force. + expect(r.forcedIncludes).toEqual({}); + }); + + test("individual-branch (promotion) mode forces NOTHING — defers to wmill.yaml", () => { + // Regression: these flags used to be force-disabled (set to false) in + // promotion mode, which CLOBBERED the promotion target's effective + // wmill.yaml config (an explicit false wins in pull's Object.assign merge). + // The server then stripped the object from the tarball, the pull wrote + // nothing, and `git add '**'` failed with "pathspec did not match + // any files". Forcing nothing leaves the keys absent so the target's + // effective filters govern; extraIncludes still scopes the pull to the + // changed object. const r = deriveGitSyncDeployIncludes( [ { path_type: "schedule", path: "f/s" }, @@ -282,12 +303,27 @@ describe("deriveGitSyncDeployIncludes", () => { ], true ); - expect(r.includeSchedules).toBe(false); - expect(r.includeTriggers).toBe(false); - // extra-includes are still derived regardless of branch mode + expect(r.forcedIncludes).toEqual({}); expect(r.extraIncludes).toContain("f/s.schedule.*"); expect(r.extraIncludes).toContain("f/t.kafka_trigger.*"); }); + + test("regression: http_trigger promotion deploy does not clobber the target's includeTriggers", () => { + // Brad's scenario: an HTTP trigger is deployed and the promotion repo uses + // individual branches. path_type is "httptrigger" (the no-underscore value + // the backend puts on item.path_type — see git_sync_ee.rs + // insert_path_type_and_return_message). includeTriggers must NOT be forced + // false here, so the target's effective includeTriggers (true in Brad's + // config) is honored and the trigger file is pulled and committed. + const r = deriveGitSyncDeployIncludes( + [{ path_type: "httptrigger", path: "f/platform/on_call_chat_http_route" }], + true + ); + expect(r.forcedIncludes.includeTriggers).toBeUndefined(); + expect(r.extraIncludes).toContain( + "f/platform/on_call_chat_http_route.http_trigger.*" + ); + }); }); // ============================================================================= diff --git a/cli/test/gitsync_promotion.test.ts b/cli/test/gitsync_promotion.test.ts index 5b73e8f8e1..e33968d0fa 100644 --- a/cli/test/gitsync_promotion.test.ts +++ b/cli/test/gitsync_promotion.test.ts @@ -22,6 +22,18 @@ import { withTestBackend } from "./test_backend.ts"; import { shouldSkipOnCI } from "./cargo_backend.ts"; import { addWorkspace } from "../workspace.ts"; +// The HTTP-trigger promotion test creates an http_trigger, whose API routes are +// behind the `http_trigger` cargo feature — NOT in the default EE test feature +// set. The shared test backend reads TEST_FEATURES at construction (first +// `withTestBackend` call), so appending here at module load enables it. Guarded +// on shouldSkipOnCI() so we only widen the build when these EE tests actually +// run (i.e. EE_LICENSE_KEY present); minimal CI builds stay untouched. +if (!shouldSkipOnCI()) { + process.env["TEST_FEATURES"] = [process.env["TEST_FEATURES"], "http_trigger"] + .filter(Boolean) + .join(","); +} + function git(cwd: string, ...args: string[]): string { return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); } @@ -44,6 +56,24 @@ function remoteHead(bareDir: string, branch: string): string { ).trim(); } +// True if `filePath` exists in the tree of `branch` on the bare remote. +function fileExistsOnBranch( + bareDir: string, + branch: string, + filePath: string, +): boolean { + try { + execFileSync( + "git", + ["--git-dir", bareDir, "cat-file", "-e", `refs/heads/${branch}:${filePath}`], + { stdio: "ignore" }, + ); + return true; + } catch { + return false; + } +} + test.skipIf(shouldSkipOnCI())( "git-sync promotion: use_individual_branch pushes to wm_deploy branch, not main", async () => { @@ -200,3 +230,419 @@ test.skipIf(shouldSkipOnCI())( }); }, ); + +/** + * Regression test for the promotion trigger-include bug (fix/gitsync-promotion- + * trigger-export): deploying a trigger (or any excluded-by-default kind: + * schedule, group, user, settings, key) with `use_individual_branch` must still + * land the object file on the `wm_deploy` branch. + * + * Root cause: `deriveGitSyncDeployIncludes` used to force the per-kind include + * flags (`includeTriggers` etc.) to false in individual-branch mode. The + * server-side tarball export STRIPS those object kinds entirely when their + * include flag is false (`if include_triggers { … }` in workspaces_export.rs), + * and `extraIncludes` is only a client-side filter over what the tarball + * already contains — it can't recover a file the server never sent. So the + * pull wrote no trigger file, the wm_deploy branch was created empty of the + * trigger, and production's `git add '**'` failed with "pathspec did not + * match any files". A script (always-included kind) never hit this — hence the + * dedicated trigger case here. + * + * Without the fix this test fails: the branch exists but the + * `*.http_trigger.yaml` file is absent from it. + */ +test.skipIf(shouldSkipOnCI())( + "git-sync promotion: use_individual_branch lands a trigger file on the wm_deploy branch", + async () => { + await withTestBackend(async (backend) => { + const ws = backend.workspace; // "test" + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: ws, + name: ws, + token: backend.token, + } as any, + { force: true, configDir: backend.testConfigDir }, + ); + + // --- 1. Bare "remote" seeded with an initial `main` commit --- + const bareDir = await mkdtemp(join(tmpdir(), "wmill_promo_trig_bare_")); + execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]); + const seedDir = await mkdtemp(join(tmpdir(), "wmill_promo_trig_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"), "# promo trigger 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"); + + // --- 2. Workspace content: a script + an HTTP trigger pointing at it --- + await backend.apiRequest!(`/api/w/${ws}/folders/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "promo", owners: [], extra_perms: {} }), + }); + await backend.apiRequest!(`/api/w/${ws}/scripts/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "f/promo/foo", + summary: "", + description: "", + content: "export async function main() { return 1 }", + language: "bun", + }), + }); + const trigRes = await backend.apiRequest!(`/api/w/${ws}/http_triggers/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "f/promo/hook", + script_path: "f/promo/foo", + route_path: "promo_hook", + is_flow: false, + http_method: "post", + authentication_method: "none", + is_static_website: false, + request_type: "sync", + }), + }); + // Guard against the route silently 404ing (the http_trigger cargo feature + // not being built) — otherwise the pull below would find nothing to sync + // and the real assertion would fail with a confusing message. + expect(trigRes.status).toBe(201); + + // --- 3. git_repository resource + git-sync config (triggers included) --- + await backend.apiRequest!(`/api/w/${ws}/resources/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "u/test/promo_repo", + resource_type: "git_repository", + value: { url: `file://${bareDir}`, branch: "main", token: "" }, + }), + }); + await backend.updateGitSyncConfig!({ + git_sync_settings: { + repositories: [ + { + git_repo_resource_path: "u/test/promo_repo", + script_path: "f/**", + use_individual_branch: true, + group_by_folder: false, + settings: { + include_path: ["f/**"], + include_type: ["script", "trigger"], + }, + }, + ], + }, + }); + + // The backend sets path_type "httptrigger" (no underscore) on the deploy + // item — see DeployedObject::HttpTrigger => "httptrigger" in git_sync_ee.rs. + const deployItems = JSON.stringify([ + { + path_type: "httptrigger", + path: "f/promo/hook", + commit_msg: "deploy hook", + }, + ]); + + const work = await mkdtemp(join(tmpdir(), "wmill_promo_trig_work_")); + git(work, "clone", `file://${bareDir}`, "."); + // Option B semantic: in promotion mode the deploy forces NOTHING — the + // trigger lands only because THIS target's effective wmill.yaml opts + // triggers in. (Reverting the source fix re-introduces the force-`false` + // that clobbers this `includeTriggers: true`, so the file is dropped.) + await writeFile( + join(work, "wmill.yaml"), + "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\nincludeTriggers: true\n", + ); + const res = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/promo_repo", + "--use-individual-branch", + "--git-deploy-items", + deployItems, + ], + work, + ); + expect(res.code).toBe(0); + + // Caller-half (mirrors the hub script): stage what the pull wrote, commit + // on the checked-out wm_deploy branch, push. + git(work, "config", "user.email", "test@windmill.dev"); + git(work, "config", "user.name", "test"); + git(work, "add", "-A"); + try { + git(work, "diff", "--cached", "--quiet"); + } catch { + git(work, "commit", "-m", "deploy hook"); + } + git(work, "push", "--porcelain", "-u", "origin", "HEAD"); + + const expectedBranch = `refs/heads/wm_deploy/${ws}/httptrigger/f__promo__hook`; + expect(remoteBranches(bareDir)).toContain(expectedBranch); + // The regression: the trigger file MUST be present on the branch. Without + // the fix the include flag is false, the server strips the trigger from + // the tarball, the pull writes nothing, and this file is absent. + expect( + fileExistsOnBranch( + bareDir, + `wm_deploy/${ws}/httptrigger/f__promo__hook`, + "f/promo/hook.http_trigger.yaml", + ), + ).toBe(true); + // Base branch untouched (individual-branch never pushes to the base). + 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 }); + }); + }, +); + +/** + * Same regression as the HTTP-trigger case above, for a `schedule` — a + * different excluded-by-default kind that exercises a DISTINCT path: its own + * include flag (`includeSchedules`), its own server-side `if include_schedules` + * tarball-strip branch, and its own `.schedule.yaml` extension. Unlike triggers + * it needs no extra cargo feature, so it guards the fix even where the + * trigger-specific features aren't built. + * + * Without the fix this test fails: the branch exists but the + * `*.schedule.yaml` file is absent from it. + */ +test.skipIf(shouldSkipOnCI())( + "git-sync promotion: use_individual_branch lands a schedule file on the wm_deploy branch", + async () => { + await withTestBackend(async (backend) => { + const ws = backend.workspace; // "test" + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: ws, + name: ws, + token: backend.token, + } as any, + { force: true, configDir: backend.testConfigDir }, + ); + + // --- 1. Bare "remote" seeded with an initial `main` commit --- + const bareDir = await mkdtemp(join(tmpdir(), "wmill_promo_sched_bare_")); + execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]); + const seedDir = await mkdtemp(join(tmpdir(), "wmill_promo_sched_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"), "# promo schedule 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"); + + // --- 2. Workspace content: a script + a (disabled) schedule for it --- + await backend.apiRequest!(`/api/w/${ws}/folders/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "promo", owners: [], extra_perms: {} }), + }); + await backend.apiRequest!(`/api/w/${ws}/scripts/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "f/promo/foo", + summary: "", + description: "", + content: "export async function main() { return 1 }", + language: "bun", + }), + }); + const schedRes = await backend.apiRequest!(`/api/w/${ws}/schedules/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "f/promo/sched", + schedule: "0 0 12 * * *", + timezone: "UTC", + script_path: "f/promo/foo", + is_flow: false, + args: {}, + enabled: false, + }), + }); + expect(schedRes.status).toBe(200); + + // --- 3. git_repository resource + git-sync config (schedules included) --- + await backend.apiRequest!(`/api/w/${ws}/resources/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "u/test/promo_repo", + resource_type: "git_repository", + value: { url: `file://${bareDir}`, branch: "main", token: "" }, + }), + }); + await backend.updateGitSyncConfig!({ + git_sync_settings: { + repositories: [ + { + git_repo_resource_path: "u/test/promo_repo", + script_path: "f/**", + use_individual_branch: true, + group_by_folder: false, + settings: { + include_path: ["f/**"], + include_type: ["script", "schedule"], + }, + }, + ], + }, + }); + + const deployItems = JSON.stringify([ + { + path_type: "schedule", + path: "f/promo/sched", + commit_msg: "deploy sched", + }, + ]); + + const work = await mkdtemp(join(tmpdir(), "wmill_promo_sched_work_")); + git(work, "clone", `file://${bareDir}`, "."); + // Option B semantic: in promotion mode the deploy forces NOTHING — the + // schedule lands only because THIS target's effective wmill.yaml opts + // schedules in. (Reverting the source fix re-introduces the force-`false` + // that clobbers this `includeSchedules: true`, so the file is dropped.) + await writeFile( + join(work, "wmill.yaml"), + "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\nincludeSchedules: true\n", + ); + const res = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/promo_repo", + "--use-individual-branch", + "--git-deploy-items", + deployItems, + ], + work, + ); + expect(res.code).toBe(0); + + // Caller-half (mirrors the hub script): stage what the pull wrote, commit + // on the checked-out wm_deploy branch, push. + git(work, "config", "user.email", "test@windmill.dev"); + git(work, "config", "user.name", "test"); + git(work, "add", "-A"); + try { + git(work, "diff", "--cached", "--quiet"); + } catch { + git(work, "commit", "-m", "deploy sched"); + } + git(work, "push", "--porcelain", "-u", "origin", "HEAD"); + + const expectedBranch = `refs/heads/wm_deploy/${ws}/schedule/f__promo__sched`; + expect(remoteBranches(bareDir)).toContain(expectedBranch); + // The regression: the schedule file MUST be present on the branch. Without + // the fix the include flag is false, the server strips the schedule from + // the tarball, the pull writes nothing, and this file is absent. + expect( + fileExistsOnBranch( + bareDir, + `wm_deploy/${ws}/schedule/f__promo__sched`, + "f/promo/sched.schedule.yaml", + ), + ).toBe(true); + // Base branch untouched (individual-branch never pushes to the base). + 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 }); + }); + }, +); + +/** + * 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 }); + }); + }, +); 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"); diff --git a/cli/test/push_workspace_key_unit.test.ts b/cli/test/push_workspace_key_unit.test.ts new file mode 100644 index 0000000000..6d8950cbec --- /dev/null +++ b/cli/test/push_workspace_key_unit.test.ts @@ -0,0 +1,93 @@ +/** + * Unit tests for pushWorkspaceKey in settings.ts. + * + * Covers WIN-2005: changing the encryption key in encryption_key.yaml and + * pushing it must (by default) re-encrypt the remote secrets with the new key. + * + * Verifies that: + * - an unchanged key is a no-op (no setWorkspaceEncryptionKey call) + * - a changed key in non-interactive mode re-encrypts by default + * (skip_reencrypt = false), so secret plaintext values are preserved + * - the --skip-reencrypt-on-key-change flag keeps the remote ciphertexts + * untouched (skip_reencrypt = true) + * - WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true does the same via env var + */ + +import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test"; + +// Track calls to mocked wmill functions +let remoteKey = ""; +let setEncryptionKeyCalls: { + workspace: string; + requestBody: { new_key: string; skip_reencrypt?: boolean }; +}[] = []; + +// Mock the wmill module before importing settings.ts +mock.module("../gen/services.gen.ts", () => ({ + getWorkspaceEncryptionKey: async (_args: { workspace: string }) => ({ + key: remoteKey, + }), + setWorkspaceEncryptionKey: async (args: { + workspace: string; + requestBody: { new_key: string; skip_reencrypt?: boolean }; + }) => { + setEncryptionKeyCalls.push(args); + }, +})); + +import { pushWorkspaceKey } from "../src/core/settings.ts"; + +describe("pushWorkspaceKey", () => { + const ws = "test-workspace"; + + beforeEach(() => { + remoteKey = ""; + setEncryptionKeyCalls = []; + delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE; + }); + + afterEach(() => { + delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE; + }); + + test("no-op when local key matches the remote key", async () => { + remoteKey = "samekey"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "samekey", { + noninteractive: true, + }); + expect(setEncryptionKeyCalls.length).toBe(0); + }); + + test("changed key re-encrypts by default in non-interactive mode", async () => { + remoteKey = "oldkey"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", { + noninteractive: true, + }); + expect(setEncryptionKeyCalls.length).toBe(1); + expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey"); + // skip_reencrypt false => backend re-encrypts existing secrets with new key + expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(false); + }); + + test("--skip-reencrypt-on-key-change skips re-encryption", async () => { + remoteKey = "oldkey"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", { + noninteractive: true, + skipReencrypt: true, + }); + expect(setEncryptionKeyCalls.length).toBe(1); + expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey"); + expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true); + }); + + test("WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true skips re-encryption non-interactively", async () => { + remoteKey = "oldkey"; + process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE = "true"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", { + noninteractive: true, + }); + expect(setEncryptionKeyCalls.length).toBe(1); + expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey"); + expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true); + }); +}); diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 2bfa883466..3301d73506 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -26,7 +26,7 @@ RUN make FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app -ARG LATEST_STABLE_PY=3.11.10 +ARG LATEST_STABLE_PY=3.12.12 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 libgcrypt20 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index d6616b5b97..48cd3698a1 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -26,7 +26,7 @@ RUN make FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app -ARG LATEST_STABLE_PY=3.11.10 +ARG LATEST_STABLE_PY=3.12.12 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 libgcrypt20 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/docker/RHEL8/Dockerfile b/docker/RHEL8/Dockerfile index cb5f36cef5..57b74b75af 100644 --- a/docker/RHEL8/Dockerfile +++ b/docker/RHEL8/Dockerfile @@ -1,6 +1,5 @@ ARG DEBIAN_IMAGE=debian:bookworm-slim ARG RUST_IMAGE=registry.access.redhat.com/ubi8/ubi:latest -ARG PYTHON_IMAGE=python:3.11.10-slim-bookworm FROM ${RUST_IMAGE} AS rust_base @@ -30,6 +29,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..21816ba8a5 100644 --- a/docker/RHEL9/Dockerfile +++ b/docker/RHEL9/Dockerfile @@ -1,6 +1,5 @@ ARG DEBIAN_IMAGE=debian:bookworm-slim ARG RUST_IMAGE=registry.access.redhat.com/ubi9/ubi:latest -ARG PYTHON_IMAGE=python:3.11.10-slim-bookworm FROM ${RUST_IMAGE} AS rust_base @@ -30,6 +29,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/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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67be947bde..f4780b5871 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.714.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.714.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -66,6 +66,7 @@ "quill": "^1.3.7", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", "svelte-carousel": "^1.0.25", @@ -6352,6 +6353,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", @@ -10888,6 +10904,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c3651f7daf..a96e7e1a3e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.711.0", + "version": "1.714.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", @@ -126,7 +126,6 @@ "lru-cache": "^11.1.0", "lucide-svelte": "^0.540.0", "mdast-util-find-and-replace": "^3.0.2", - "unist-util-visit": "^5.0.0", "minimatch": "^10.0.1", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", @@ -141,12 +140,14 @@ "quill": "^1.3.7", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", "svelte-carousel": "^1.0.25", "svelte-exmarkdown": "^5.0.0", "svelte-infinite-loading": "^1.4.0", "tailwind-merge": "^1.13.2", + "unist-util-visit": "^5.0.0", "vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0", "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 05a9068963..2863ec67ab 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "00c9834", - "sha256": "5757e5b9cbf79c20d507dc4c588640368e84b873cb48f3806ca8c67fd1aa625f" + "version": "fe13d03", + "sha256": "c588569103ce065a26f334d9b625f616cd23d4204deaacd6fadcfbf16f23462c" } 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/GfmMarkdown.svelte b/frontend/src/lib/components/GfmMarkdown.svelte index 83cce90fbe..377951b97b 100644 --- a/frontend/src/lib/components/GfmMarkdown.svelte +++ b/frontend/src/lib/components/GfmMarkdown.svelte @@ -1,19 +1,12 @@
diff --git a/frontend/src/lib/components/GraphqlSchemaViewer.svelte b/frontend/src/lib/components/GraphqlSchemaViewer.svelte index 1ab4c68f00..205d8de416 100644 --- a/frontend/src/lib/components/GraphqlSchemaViewer.svelte +++ b/frontend/src/lib/components/GraphqlSchemaViewer.svelte @@ -2,20 +2,19 @@ import { BROWSER } from 'esm-env' import { editor as meditor, KeyMod, KeyCode } from 'monaco-editor' + import { editorFontSize } from '$lib/editorFontSize.svelte' import { onDestroy, onMount } from 'svelte' let divEl: HTMLDivElement | null = $state(null) let editor: meditor.IStandaloneCodeEditor - interface Props { - code?: string; - class?: string; + code?: string + class?: string } - let { code = '', class: className = '' }: Props = $props(); - + let { code = '', class: className = '' }: Props = $props() async function loadMonaco() { editor = meditor.create(divEl as HTMLDivElement, { @@ -25,6 +24,7 @@ automaticLayout: true, scrollBeyondLastLine: false, lineNumbers: 'off', + fontSize: editorFontSize.regular, minimap: { enabled: false } }) @@ -43,6 +43,13 @@ } }) + $effect(() => { + const fontSize = editorFontSize.regular + if (editor) { + editor.updateOptions({ fontSize }) + } + }) + onDestroy(() => { try { editor && editor.dispose() diff --git a/frontend/src/lib/components/LocalDraftBanner.svelte b/frontend/src/lib/components/LocalDraftBanner.svelte new file mode 100644 index 0000000000..357744c2b2 --- /dev/null +++ b/frontend/src/lib/components/LocalDraftBanner.svelte @@ -0,0 +1,101 @@ + + + + +{#if show} +
+
+ + + You have unsaved changes + +
+
+ + {#if !disabled} + + {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index 9a5c69dbd2..126ab0dc7f 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -14,7 +14,6 @@ import { getUserExt } from '$lib/user' import type { UserExt } from '$lib/stores' import { UserDraft, checkStaleness, type UserDraftHandle } from '$lib/userDraft.svelte' - import { notifyRestoredFromLocal } from '$lib/userDraftToast' import LocalDraftStaleModal from './common/confirmationModal/LocalDraftStaleModal.svelte' interface Props { @@ -26,6 +25,14 @@ defaultValues?: Record | undefined workspace?: string | undefined selected?: string | undefined + /** Notifies the parent drawer whether a local draft for the selected + * workspace diverges from the deployed baseline, so it can show the + * "unsaved changes" banner below its header. */ + onDraftStateChange?: (hasDraft: boolean) => void + /** Notifies the parent drawer of write-access for the selected workspace, + * so it can hide the banner's Discard button in read-only mode (matches + * the trigger editors' `disabled={!can_write}` wiring). */ + onCanWriteChange?: (canWrite: boolean) => void } let { @@ -36,7 +43,9 @@ onChange, defaultValues = undefined, workspace = undefined, - selected: selectedProp = $bindable() + selected: selectedProp = $bindable(), + onDraftStateChange, + onCanWriteChange }: Props = $props() type ResourceState = { @@ -189,6 +198,11 @@ Object.keys(states).filter((ws) => !deepEqual(states[ws].draft, initialStates[ws])) ) const anyDirty = $derived(dirtyWorkspaces.length > 0) + // Banner is scoped to the selected workspace — the diff/discard only + // operate on it, so showing it for an unrelated dirty workspace would be + // misleading. The cross-workspace `otherDirty` alert below still covers + // that case. + const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected)) const otherDirty = $derived( dirtyWorkspaces.length == 1 ? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore) @@ -219,9 +233,7 @@ const s: ResourceState = { path: '', description: '', - args: (defaultValues && Object.keys(defaultValues).length > 0 - ? defaultValues - : {}) as any, + args: (defaultValues && Object.keys(defaultValues).length > 0 ? defaultValues : {}) as any, labels: undefined, wsSpecific: false } @@ -276,11 +288,6 @@ { workspace: ws } ) } - notifyRestoredFromLocal(false, true, { - onResetToDeployed: () => { - UserDraft.discard('resource', initialPath ?? '', s, { workspace: ws }) - } - }) } } ensureHandle(ws, s) @@ -329,6 +336,29 @@ canSave = anyDirty && dirtyValid && dirtyCanWrite }) + // Drive the parent drawer's "unsaved changes" banner. The drawer chrome + // (header + banner slot) lives in ResourceEditorDrawer, above this + // lazily-imported content, so the state is lifted up via these accessors. + $effect(() => { + onDraftStateChange?.(!!initialPath && selectedDirty) + }) + $effect(() => { + onCanWriteChange?.(can_write) + }) + + export function localDraftDeployed(): ResourceState | undefined { + return selected ? initialStates[selected] : undefined + } + export function localDraftCurrent(): ResourceState | undefined { + return current + } + export function discardLocalDraft(): void { + if (!selected) return + UserDraft.discard('resource', initialPath ?? '', initialStates[selected], { + workspace: selected + }) + } + $effect(() => { if (current) // $state.snapshot deep-reads (so the effect re-runs on nested diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index ed80717a32..919f8edeff 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -6,6 +6,7 @@ import { Loader2, Save } from 'lucide-svelte' import WsSpecificVersions from './WsSpecificVersions.svelte' import { workspaceStore } from '$lib/stores' + import LocalDraftBanner from './LocalDraftBanner.svelte' let { workspace = undefined, @@ -17,7 +18,16 @@ let resource_type: string | undefined = $state(undefined) let defaultValues: Record | undefined = $state(undefined) - let resourceEditor: { save: () => void } | undefined = $state(undefined) + let resourceEditor: + | { + save: () => void + localDraftDeployed: () => unknown + localDraftCurrent: () => unknown + discardLocalDraft: () => void + } + | undefined = $state(undefined) + let hasLocalDraft = $state(false) + let canWriteSelected = $state(true) let path: string | undefined = $state(undefined) let selected: string | undefined = $state(undefined) @@ -62,8 +72,19 @@ bind:this={resourceEditor} bind:canSave bind:selected + onDraftStateChange={(v) => (hasLocalDraft = v)} + onCanWriteChange={(v) => (canWriteSelected = v)} /> {/await} + {#snippet banner()} + resourceEditor?.localDraftDeployed()} + getCurrent={() => resourceEditor?.localDraftCurrent()} + onDiscard={() => resourceEditor?.discardLocalDraft()} + disabled={!canWriteSelected} + /> + {/snippet} {#snippet actions()} {#if mode == 'edit' && path && effectiveWorkspace} 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 744450e3d3..9a7b4aaf83 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -177,6 +177,11 @@ * to clicking a run button on the graph. */ onTestStateChange?: (running: boolean) => void + // When true the right-hand test/run pane mounts collapsed. The user + // can still expand it via `toggleTestPanel`. Defaults to false so the + // regular /scripts/edit route keeps its current open-by-default UX; + // the session preview opts in to save vertical real estate. + initialTestPanelCollapsed?: boolean } let { @@ -212,7 +217,8 @@ editorBarRight, enablePreprocessorSnippet = false, previewLayout = 'right', - onTestStateChange + onTestStateChange, + initialTestPanelCollapsed = false }: Props = $props() $effect(() => { @@ -1440,8 +1446,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/SimpleEditor.svelte b/frontend/src/lib/components/SimpleEditor.svelte index 50089db519..36fcffc6e6 100644 --- a/frontend/src/lib/components/SimpleEditor.svelte +++ b/frontend/src/lib/components/SimpleEditor.svelte @@ -14,6 +14,7 @@ import { BROWSER } from 'esm-env' import { editorConfig, updateOptions } from '$lib/editorUtils' + import { editorFontSize } from '$lib/editorFontSize.svelte' import { createHash } from '$lib/editorLangUtils' // import { @@ -298,7 +299,14 @@ } }) - let fontSize = $derived(small ? 12 : 13.5) + let fontSize = $derived(small ? editorFontSize.small : editorFontSize.regular) + + $effect(() => { + const next = fontSize + if (editor) { + editor.updateOptions({ fontSize: next }) + } + }) async function loadMonaco() { setMonacoJsonOptions() diff --git a/frontend/src/lib/components/TemplateEditor.svelte b/frontend/src/lib/components/TemplateEditor.svelte index 85392fcdfa..2fef927c9a 100644 --- a/frontend/src/lib/components/TemplateEditor.svelte +++ b/frontend/src/lib/components/TemplateEditor.svelte @@ -16,6 +16,7 @@ registerWebviewPaste, updateOptions } from '$lib/editorUtils' + import { editorFontSize } from '$lib/editorFontSize.svelte' import { createHash } from '$lib/editorLangUtils' import libStdContent from '$lib/es6.d.ts.txt?raw' @@ -400,11 +401,13 @@ extraLib = '', autoHeight = true, fixedOverflowWidgets = true, - fontSize = 12, + fontSize, loadAsync = false, class: clazz = '' }: Props = $props() + let effectiveFontSize = $derived(fontSize ?? editorFontSize.regular) + let yPadding = MONACO_Y_PADDING if (typeof code != 'string') { @@ -472,7 +475,7 @@ // lineNumbers: 'on', lineDecorationsWidth: 0, lineNumbersMinChars: 2, - fontSize, + fontSize: effectiveFontSize, suggestOnTriggerCharacters: true, renderLineHighlight: 'none', lineNumbers: 'off', @@ -688,6 +691,13 @@ $effect(() => { mounted && extraLib && initialized && untrack(() => loadExtraLib()) }) + + $effect(() => { + const next = effectiveFontSize + if (editor) { + editor.updateOptions({ fontSize: next }) + } + }) @@ -696,7 +706,7 @@ class={twMerge(inputBorderClass({ forceFocus: isFocus }), 'rounded-md overflow-auto pl-2', clazz)} > {#if !editor} - + {/if}
!deepEqual(states[ws].draft, initialStates[ws])) ) const anyDirty = $derived(dirtyWorkspaces.length > 0) + // Banner is scoped to the selected workspace — the diff/discard only + // operate on it, so showing it for an unrelated dirty workspace would be + // misleading. The cross-workspace `otherDirty` alert below still covers + // that case. + const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected)) const otherDirty = $derived( dirtyWorkspaces.length == 1 ? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore) @@ -183,11 +188,6 @@ if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws }) } - notifyRestoredFromLocal(false, true, { - onResetToDeployed: () => { - UserDraft.discard('variable', p, s, { workspace: ws }) - } - }) } } ensureHandle(ws, s) @@ -327,6 +327,20 @@ title={edit ? `Update variable at ${initialPath}` : 'Add a variable'} on:close={drawer?.closeDrawer} > + {#snippet banner()} + (selected ? initialStates[selected] : undefined)} + getCurrent={() => current} + onDiscard={() => { + if (!selected) return + UserDraft.discard('variable', editPath ?? '', initialStates[selected], { + workspace: selected + }) + }} + disabled={!can_write} + /> + {/snippet}
{#if !can_write} diff --git a/frontend/src/lib/components/VariableForm.svelte b/frontend/src/lib/components/VariableForm.svelte index 0ffa4d8ba6..c688759874 100644 --- a/frontend/src/lib/components/VariableForm.svelte +++ b/frontend/src/lib/components/VariableForm.svelte @@ -75,7 +75,7 @@ disabled={edit && $userStore?.operator} /> {#if variable.is_secret} - + Every secret is encrypted at rest and in transit with a key specific to this workspace. In addition, any read of a secret variable generates an audit log whose operation name is: variables.decrypt_secret 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..dda620dad5 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -17,8 +17,10 @@ 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 { generateRandomString } from '$lib/utils' import { dirKey, getCachedItems, @@ -30,6 +32,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 @@ -62,7 +66,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let searchInput: TextInput | undefined = $state() let pickerRoot: HTMLElement | undefined = $state() - const instanceId = crypto.randomUUID() + const instanceId = generateRandomString(8) const listboxId = `pkr-list-${instanceId}` const idFor = (key: string) => `pkr-${instanceId}-${key.replace(/[^a-zA-Z0-9-]/g, '_')}` @@ -72,8 +76,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 +94,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 +118,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 +138,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 +155,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 +194,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 +272,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 +284,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 +320,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 +451,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 +465,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 +596,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/components/display/AppMarkdown.svelte b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte index e8ae0f8335..ff9c4263a8 100644 --- a/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte +++ b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte @@ -5,10 +5,8 @@ import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types' import { initCss } from '../../utils' import RunnableWrapper from '../helpers/RunnableWrapper.svelte' - import { Markdown, type Plugin } from 'svelte-exmarkdown' - import { gfmPlugin } from 'svelte-exmarkdown/gfm' - import rehypeRaw from 'rehype-raw' - import { rehypeGithubAlerts } from 'rehype-github-alerts' + import { Markdown } from 'svelte-exmarkdown' + import { markdownPlugins as plugins } from '$lib/components/markdownPlugins' import { classNames } from '$lib/utils' import { components } from '../../editor/component' import ResolveConfig from '../helpers/ResolveConfig.svelte' @@ -31,11 +29,6 @@ configuration }: Props = $props() - const plugins: Plugin[] = [ - gfmPlugin(), - { rehypePlugin: [rehypeRaw] }, - { rehypePlugin: [rehypeGithubAlerts] } - ] const { app, worldStore, mode } = getContext('AppViewerContext') const resolvedConfig = $state( diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 4e62110eff..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` @@ -165,7 +175,7 @@ groups: $userStore?.groups, username: $userStore?.username, name: $userStore?.name, - query: urlParamsToObject(new URL(window.location.href).searchParams), + query: urlParamsToObject(new URL(window.location.href).searchParams, { stripReserved: true }), hash: window.location.hash.substring(1), workspace: $workspaceStore, mode: 'editor', @@ -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 649e67ef0d..2294554f3e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -35,7 +35,8 @@ newEditedPath = $bindable(), newPath, hideSecretUrl = false, - preserveOnBehalfOf = $bindable(false) + preserveOnBehalfOf = $bindable(false), + rawApp = false }: { policy: any setPublishState: () => void @@ -51,6 +52,11 @@ newPath: string hideSecretUrl?: boolean preserveOnBehalfOf?: boolean + // Raw apps need cross-origin isolation (wm_coep) to be embeddable. Classic + // (low-code) apps must NOT get the flag — it would force COEP on the + // document and break no-CORP cross-origin subresources (external images, + // {@html} embeds, CDN imports). + rawApp?: boolean } = $props() let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) @@ -91,6 +97,17 @@ isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : '' }${customPath}` ) + + // When embedding a raw app in an iframe inside another Windmill app (or any + // cross-origin-isolated page), the embedded document must set COEP. The + // `wm_coep` flag opts the public app into the cross-origin isolation headers. + // Only raw apps get it — for classic (low-code) apps COEP would break + // no-CORP cross-origin subresources, so their snippet stays a plain iframe. + let embedMode = $state(false) + function toEmbedSnippet(url: string): string { + const finalUrl = rawApp ? `${url}${url.includes('?') ? '&' : '?'}wm_coep=on` : url + return `` + } async function getSecretUrl() { secretUrl = await AppService.getPublicSecretOfApp({ workspace: $workspaceStore!, @@ -122,7 +139,7 @@ }) $effect(() => { - appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl()) + appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl()) }) @@ -247,18 +264,40 @@ policy.execution_mode = e.detail ? 'anonymous' : 'publisher' setPublishState() }} - disabled={appPath == ''} + disabled={!savedApp} />
- {#if appPath == ''} + {#if !savedApp} {:else if secretUrlHref} - +
+ (embedMode = e.detail)} + options={{ left: 'URL', right: 'Embed' }} + /> +
+ {:else} {/if}
- Share this url directly or embed it using an iframe (if requiring login, top-level domain of - embedding app must be the same as the one of Windmill) + {#if embedMode} + Paste this iframe snippet into another app. + {#if rawApp} + The wm_coep flag Sets the cross-origin isolation headers (COEP) so the app can be embedded inside + another Windmill app or any cross-origin-isolated page. Without it the browser blocks + the iframe. lets it load inside a cross-origin-isolated page. + {/if} + (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) + {:else} + Share this url directly, or switch to Embed to get an iframe snippet. + {/if}
@@ -305,7 +344,10 @@
Custom public URL
- +
{dirtyCustomPath ? customPathError : ''} diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index 48c3d5578d..fef6ba8fa3 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -145,7 +145,7 @@ name: $userStore?.name, groups: $userStore?.groups, username: $userStore?.username, - query: urlParamsToObject(page.url.searchParams), + query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), hash: page.url.hash.substring(1) }} workspace={effectiveWorkspace} 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/common/drawer/DrawerContent.svelte b/frontend/src/lib/components/common/drawer/DrawerContent.svelte index ad89c2c725..6b76873c49 100644 --- a/frontend/src/lib/components/common/drawer/DrawerContent.svelte +++ b/frontend/src/lib/components/common/drawer/DrawerContent.svelte @@ -22,6 +22,8 @@ id?: string | undefined actions?: import('svelte').Snippet titleExtra?: import('svelte').Snippet + /** Rendered fixed below the header, above the scrollable content. */ + banner?: import('svelte').Snippet children?: import('svelte').Snippet } @@ -40,6 +42,7 @@ id, actions, titleExtra, + banner, children }: Props = $props() @@ -83,6 +86,10 @@ {/if}
+ {#if banner} + {@render banner()} + {/if} +
diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index bb97d61ee5..1252b999d7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -23,7 +23,7 @@ import { fade } from 'svelte/transition' import Popover from '$lib/components/meltComponents/Popover.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' - import { type DisplayMessage } from './shared' + import { isActiveUserQuestion, type DisplayMessage } from './shared' import type { ContextElement } from './context' import ChatQuickActions from './ChatQuickActions.svelte' import ProviderModelSelector from './ProviderModelSelector.svelte' @@ -271,18 +271,15 @@ const last = messages[messages.length - 1] if (!last || last.role !== 'tool') return false if (last.needsConfirmation && last.isLoading) return true - if ( - last.userQuestion && - last.isLoading && - !last.error && - !last.userQuestion.selectedChoice && - !last.userQuestion.canceled - ) { - return true - } + if (isActiveUserQuestion(last)) return true return false }) + // While the AI is waiting on an answer to an askUserQuestion, the only valid + // input is one of the choices (or the custom answer) in the question card — + // so disable the main chat input until the question is answered or canceled. + const hasActiveUserQuestion = $derived(isActiveUserQuestion(messages[messages.length - 1])) + // Get app context for display when in APP mode const appContext = $derived.by((): SelectedContext | undefined => { if (aiChatManager.mode !== AIMode.APP || !aiChatManager.appAiChatHelpers) { @@ -510,7 +507,7 @@ bind:this={aiChatInput} bind:selectedContext {availableContext} - {disabled} + disabled={disabled || hasActiveUserQuestion} isFirstMessage={messages.length === 0} />
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 +513,15 @@ 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 } : {}), + testActiveFlow: async (args?: Record) => + this.flowAiChatHelpers?.testFlow(args) + } satisfies GlobalToolHelpers } else if (mode === AIMode.APP) { const customPrompt = getCombinedCustomPrompt(mode) this.systemMessage = prepareAppSystemMessage(customPrompt) @@ -681,7 +705,11 @@ export class AIChatManager { } else if (this.mode === AIMode.NAVIGATOR) { return prepareNavigatorUserMessage(pendingPrompt) } else if (this.mode === AIMode.GLOBAL) { - return prepareGlobalUserMessage(pendingPrompt, this.contextManager.getSelectedContext()) + return prepareGlobalUserMessage( + pendingPrompt, + this.contextManager.getSelectedContext(), + { workspace: get(workspaceStore) } + ) } return undefined }, @@ -791,6 +819,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 @@ -815,6 +849,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) { @@ -898,7 +950,9 @@ export class AIChatManager { userMessage = prepareApiUserMessage(oldInstructions) break case AIMode.GLOBAL: - userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext) + userMessage = prepareGlobalUserMessage(oldInstructions, oldSelectedContext, { + workspace: get(workspaceStore) + }) break case AIMode.APP: userMessage = prepareAppUserMessage( diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 35ebbc257d..b1c77df84d 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -16,7 +16,13 @@ vi.mock('$lib/gen', () => ({ })) vi.mock('$lib/stores', () => ({ - workspaceStore: { subscribe: () => () => undefined } + workspaceStore: { subscribe: () => () => undefined }, + userStore: { + subscribe: (run: (value: { username: string }) => void) => { + run({ username: 'admin' }) + return () => undefined + } + } })) vi.mock('$lib/toast', () => ({ @@ -55,10 +61,12 @@ vi.mock('esm-env', async (importOriginal) => ({ function createFlowHelpers({ hasPendingChanges, - acceptAllModuleActions + acceptAllModuleActions, + testFlow = vi.fn() }: { hasPendingChanges: () => boolean acceptAllModuleActions: () => void + testFlow?: FlowAIChatHelpers['testFlow'] }): FlowAIChatHelpers { return { getFlowAndSelectedId: vi.fn(), @@ -74,7 +82,7 @@ function createFlowHelpers({ rejectAllModuleActions: vi.fn(), hasPendingChanges, selectStep: vi.fn(), - testFlow: vi.fn(), + testFlow, getLintErrors: vi.fn() } as unknown as FlowAIChatHelpers } @@ -162,6 +170,27 @@ describe('AIChatManager autonomy mode', () => { expect(applied).toBe(true) }) + + it('does not pass the AI session id as a flow test conversation id in global mode', async () => { + const manager = new AIChatManager() + const testFlow = vi.fn(async () => 'job-flow-preview') + + manager.isSessionChat = true + manager.sessionId = 'htc1xouxd96dcyo6ruqo39' + manager.setFlowHelpers( + createFlowHelpers({ + hasPendingChanges: () => false, + acceptAllModuleActions: vi.fn(), + testFlow + }) + ) + + manager.changeMode(AIMode.GLOBAL) + const jobId = await manager.helpers.testActiveFlow({ name: 'Ada' }) + + expect(jobId).toBe('job-flow-preview') + expect(testFlow).toHaveBeenCalledWith({ name: 'Ada' }) + }) }) describe('AIChatManager persisted autonomy default', () => { 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..7a7a7014ea 100644 --- a/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AskUserQuestionDisplay.svelte @@ -1,11 +1,18 @@
- -

+

{userQuestion.question}

@@ -93,37 +154,40 @@ {/each}
(activeIndex = customAnswerIndex) }} /> + />
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 @@ {#if activeUserQuestion} {:else} -
+