Merge remote-tracking branch 'origin/main' into operator-fork-workspace-ruleset

# Conflicts:
#	backend/ee-repo-ref.txt
#	frontend/src/lib/components/sidebar/WorkspacePickerBody.svelte
#	frontend/src/lib/components/workspaceSettings/RulesetEditor.svelte
This commit is contained in:
Guilhem Lemouel
2026-10-15 08:48:08 -07:00
381 changed files with 17625 additions and 4288 deletions
+2 -2
View File
@@ -139,8 +139,8 @@ jobs:
run: |
cargo build --release -p windmill_duckdb_ffi_internal
New-Item -ItemType Directory -Path ..\target\debug -Force
Copy-Item target\release\windmill_duckdb_ffi_internal.dll ..\target\debug\
# duckdb is bundled (~2GB of build artifacts); the DLL is the only
Copy-Item target\release\windmill_duckdb_runner.exe ..\target\debug\
# duckdb is bundled (~2GB of build artifacts); the runner is the only
# thing we need from this excluded-crate target dir.
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue target
Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize
+9 -2
View File
@@ -83,9 +83,16 @@ jobs:
chmod +x "$HOME/.local/bin/wmill"
echo "$HOME/.local/bin" >> $GITHUB_PATH
working-directory: /
- name: Install PowerShell, mold and clang
- name: Install PowerShell, mold, clang and the latest pg_dump
# PGDG's `postgresql-client` is its newest major, Ubuntu's is not: the `postgres` service
# image floats, and pg_dump refuses a server newer than itself. `/usr/bin/pg_dump` picks
# the version of the runner's own preinstalled cluster, so the newest bin dir goes first.
run: |
sudo apt-get update && sudo apt-get install -y powershell mold clang libcurl4-openssl-dev
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -y
sudo apt-get update && sudo apt-get install -y powershell mold clang libcurl4-openssl-dev postgresql-client
PG_BIN="/usr/lib/postgresql/$(ls /usr/lib/postgresql | sort -n | tail -1)/bin"
echo "$PG_BIN" >> "$GITHUB_PATH"
"$PG_BIN/pg_dump" --version
working-directory: /
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
+6 -6
View File
@@ -83,17 +83,17 @@ jobs:
# Extract arm64 binary (include deps/ for hard link resolution)
mkdir -p /tmp/arm64
crane export --platform linux/arm64 ${{ steps.meta-ee-public.outputs.tags }} - \
| tar -xf - -C /tmp/arm64 windmill/target/release/ usr/src/app/libwindmill_duckdb_ffi_internal.so
| tar -xf - -C /tmp/arm64 windmill/target/release/ usr/src/app/windmill_duckdb_runner
cp /tmp/arm64/windmill/target/release/windmill extracted/windmill-ee-arm64-rhel9
cp /tmp/arm64/usr/src/app/libwindmill_duckdb_ffi_internal.so extracted/libwindmill_duckdb_ffi_internal-arm64.so
cp /tmp/arm64/usr/src/app/windmill_duckdb_runner extracted/windmill_duckdb_runner-arm64
rm -rf /tmp/arm64
# Extract amd64 binary
mkdir -p /tmp/amd64
crane export --platform linux/amd64 ${{ steps.meta-ee-public.outputs.tags }} - \
| tar -xf - -C /tmp/amd64 windmill/target/release/ usr/src/app/libwindmill_duckdb_ffi_internal.so
| tar -xf - -C /tmp/amd64 windmill/target/release/ usr/src/app/windmill_duckdb_runner
cp /tmp/amd64/windmill/target/release/windmill extracted/windmill-ee-amd64-rhel9
cp /tmp/amd64/usr/src/app/libwindmill_duckdb_ffi_internal.so extracted/libwindmill_duckdb_ffi_internal-amd64.so
cp /tmp/amd64/usr/src/app/windmill_duckdb_runner extracted/windmill_duckdb_runner-amd64
rm -rf /tmp/amd64
- uses: actions/upload-artifact@v4
@@ -109,9 +109,9 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: RHEL9-arm64 dynamic libraries build
path: extracted/libwindmill_duckdb_ffi_internal-arm64.so
path: extracted/windmill_duckdb_runner-arm64
- uses: actions/upload-artifact@v4
with:
name: RHEL9-amd64 dynamic libraries build
path: extracted/libwindmill_duckdb_ffi_internal-amd64.so
path: extracted/windmill_duckdb_runner-amd64
@@ -103,7 +103,7 @@ jobs:
id: extract-duckdb-ffi-internal
with:
image: ${{ steps.meta-ee-public.outputs.tags}}-amd64
path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so"
path: "/usr/src/app/windmill_duckdb_runner"
# - uses: shrink/actions-docker-extract@v3
# id: extract-ee-arm64
@@ -124,7 +124,7 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: RHEL8-amd64 dynamic libraries build
path: ${{ steps.extract-duckdb-ffi-internal.outputs.destination }}/libwindmill_duckdb_ffi_internal.so
path: ${{ steps.extract-duckdb-ffi-internal.outputs.destination }}/windmill_duckdb_runner
# - uses: actions/upload-artifact@v4
# with:
+2 -2
View File
@@ -81,5 +81,5 @@ jobs:
- name: Upload dynamic libraries artifact
uses: actions/upload-artifact@v4
with:
name: windmill_duckdb_ffi_internal.dll
path: ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_ffi_internal.dll
name: windmill_duckdb_runner.exe
path: ./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner.exe
+1 -1
View File
@@ -218,7 +218,7 @@ jobs:
id: extract-duckdb-ffi-internal
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }}
path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so"
path: "/usr/src/app/windmill_duckdb_runner"
- uses: shrink/actions-docker-extract@v3
id: extract-ee
+2 -2
View File
@@ -72,8 +72,8 @@ jobs:
files: |
./backend/target/release/windmill-ee.exe
- name: Attach dynamic libraries to release
- name: Attach the DuckDB runner to release
uses: softprops/action-gh-release@v2
with:
files: |
./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_ffi_internal.dll
./backend/windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner.exe
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.833.0"
".": "1.835.0"
}
+41
View File
@@ -1,5 +1,46 @@
# Changelog
## [1.835.0](https://github.com/windmill-labs/windmill/compare/v1.834.0...v1.835.0) (2026-10-14)
### Features
* allow early return on steps inside branches ([#11733](https://github.com/windmill-labs/windmill/issues/11733)) ([08319a0](https://github.com/windmill-labs/windmill/commit/08319a004c289f93398b9fdc790b2ed412a81fa4))
* run global ai eval cases under builder operator profiles ([#11768](https://github.com/windmill-labs/windmill/issues/11768)) ([d788f16](https://github.com/windmill-labs/windmill/commit/d788f16c4028985afd2b82f35d625b0a12c04653))
* search the workspace picker dropdown past 8 workspaces ([#11761](https://github.com/windmill-labs/windmill/issues/11761)) ([c1a561f](https://github.com/windmill-labs/windmill/commit/c1a561f5ec091de0ae2de12ee811ca628c58c9f5))
### Bug Fixes
* show native triggers in the operator sidebar ([#11769](https://github.com/windmill-labs/windmill/issues/11769)) ([717162e](https://github.com/windmill-labs/windmill/commit/717162eab44757182a4a67316c288e188f811599))
## [1.834.0](https://github.com/windmill-labs/windmill/compare/v1.833.0...v1.834.0) (2026-10-14)
### Features
* add a navigation picker after the breadcrumb's last segment ([#11649](https://github.com/windmill-labs/windmill/issues/11649)) ([9653318](https://github.com/windmill-labs/windmill/commit/9653318c17225b35c474722d49f38dc2ecd3b9f6))
* add a yaml mode to the workspace settings page ([#11751](https://github.com/windmill-labs/windmill/issues/11751)) ([b543b43](https://github.com/windmill-labs/windmill/commit/b543b43d734a309278b72fdd958570b9c7ff6d5f))
* give full-code app previews the deployed app's window.ctx ([#11763](https://github.com/windmill-labs/windmill/issues/11763)) ([c5e6f67](https://github.com/windmill-labs/windmill/commit/c5e6f67d5307146569e2fd1a37033c9dddb680c4))
* guide the AI when no resource type fits a service ([#11615](https://github.com/windmill-labs/windmill/issues/11615)) ([88c3cc0](https://github.com/windmill-labs/windmill/commit/88c3cc0ced8fe3183979f0abd8e05846f6f4a8e6))
* let a linked agent step override model and reasoning effort ([#11685](https://github.com/windmill-labs/windmill/issues/11685)) ([9249aa0](https://github.com/windmill-labs/windmill/commit/9249aa02d592e60024d7475627cbf664936e4a5d))
* let operators with manage rights save schedule and trigger drafts ([#11646](https://github.com/windmill-labs/windmill/issues/11646)) ([74c5f82](https://github.com/windmill-labs/windmill/commit/74c5f82c6703a08a6d1a896b5e46b7007f2e3d73))
* list agents with runnables, let them be starred, and fill out their menu ([#11651](https://github.com/windmill-labs/windmill/issues/11651)) ([fe2571e](https://github.com/windmill-labs/windmill/commit/fe2571ea53603625f48601aa800e9f700322d5b8))
* list the user's own forks first in fork pickers ([#11762](https://github.com/windmill-labs/windmill/issues/11762)) ([009482a](https://github.com/windmill-labs/windmill/commit/009482a67747ec9e8f2b6b7baddb5e0e9fe720c5))
* read and change workspace settings from ai sessions and mcp ([#11741](https://github.com/windmill-labs/windmill/issues/11741)) ([468cf1d](https://github.com/windmill-labs/windmill/commit/468cf1d9941538d285264e8a8c57356b934ab25d))
* scope data table migrations to the schemas they declare ([#11756](https://github.com/windmill-labs/windmill/issues/11756)) ([2410790](https://github.com/windmill-labs/windmill/commit/2410790c7e343920ae2bb6f2405a0b7f7660be7f))
* **sessions:** run a deployed item from its own page, for the chat ([#11468](https://github.com/windmill-labs/windmill/issues/11468)) ([998e687](https://github.com/windmill-labs/windmill/commit/998e6877c1cf07bb1a08fd1d3b18948fbc0e30e0))
* workspace settings as one document, applied server-side ([#11740](https://github.com/windmill-labs/windmill/issues/11740)) ([9a5ec22](https://github.com/windmill-labs/windmill/commit/9a5ec223bc7dd1b5c4e58a9cc883a5d4e5d9237b))
### Bug Fixes
* **cli:** keep script run waiting on long jobs and flaky log streaming ([#11766](https://github.com/windmill-labs/windmill/issues/11766)) ([a2ee052](https://github.com/windmill-labs/windmill/commit/a2ee052e0326bf69f49596cdc008bbc8ca596e3c))
* fork data tables sharing one database into a single copy ([#11755](https://github.com/windmill-labs/windmill/issues/11755)) ([4914782](https://github.com/windmill-labs/windmill/commit/49147827d69bc241c57407f539ec7e2621830d1b))
* keep applied migrations and db settings in data table copies ([#11753](https://github.com/windmill-labs/windmill/issues/11753)) ([694ed7a](https://github.com/windmill-labs/windmill/commit/694ed7a0eba255ef42e6cfe988ea3b8c5784dfa1))
* unify cron builder control sizes and widths ([#11764](https://github.com/windmill-labs/windmill/issues/11764)) ([17fefcf](https://github.com/windmill-labs/windmill/commit/17fefcf777063849ca8ec1e097bf02e826c0b3a7))
* **worker:** keep script env vars off the nsjail launcher process ([#11749](https://github.com/windmill-labs/windmill/issues/11749)) ([1c068f3](https://github.com/windmill-labs/windmill/commit/1c068f33e144af81f202e21e23960af4a17a6b4a))
## [1.833.0](https://github.com/windmill-labs/windmill/compare/v1.832.0...v1.833.0) (2026-10-13)
+2 -4
View File
@@ -50,10 +50,7 @@ RUN apt-get update && apt-get install -y clang=1:19.0* libclang-dev=1:19.0* cmak
COPY ./backend/windmill-duckdb-ffi-internal .
# The `duckdb` crate comes from a git dependency (a fork carrying an engine patch),
# which cargo checks out under $CARGO_HOME/git rather than the registry cache.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
--mount=type=cache,target=$SCCACHE_DIR,sharing=locked \
cargo build --release -p windmill_duckdb_ffi_internal
@@ -286,7 +283,8 @@ ENV TZ=Etc/UTC
COPY --from=builder /frontend/build /static_frontend
COPY --from=builder /windmill/target/release/windmill ${APP}/windmill
COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/target/release/libwindmill_duckdb_ffi_internal.so ${APP}/libwindmill_duckdb_ffi_internal.so
# DuckDB jobs run in this binary, which the worker looks for next to its own.
COPY --from=windmill_duckdb_ffi_internal_builder /windmill-duckdb-ffi-internal/target/release/windmill_duckdb_runner ${APP}/windmill_duckdb_runner
COPY --from=oven/bun:1.4.0 /usr/local/bin/bun /usr/bin/bun
+11
View File
@@ -184,6 +184,17 @@ dismissed, so `userAnswers: []` dismisses every question. Without it the tool re
it cannot ask, and the model usually carries on with its own guess, which makes "asks
before creating anything" impossible to assert.
`runtime.profile` runs a session-chat case (`sessionChat: true`) as a non-admin role
instead of full access: the prompt, toolset and tool schemas are cut as production's
`capabilitiesForRole` cuts them, and the workspace's `operator_settings` are seeded for the
tools that read them directly:
```yaml
profile:
operator: true
operatorSettings: { builder_apps: true } # also builder_flows, manage_schedules, manage_triggers
```
`toolExpect.toolCallArgs` entries support `sharedByAtLeast: <n>`: at least `n` recorded
calls to that tool must carry the same non-blank string in the field. Use it for calls that
have to share an identifier, like two test runs of one chat conversation.
@@ -2,7 +2,7 @@ import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs";
import type { AIProvider } from "$lib/gen/types.gen";
import type { AIProvider, OperatorSettings } from "$lib/gen/types.gen";
import {
globalToolsFor,
prepareGlobalSystemMessage,
@@ -13,6 +13,10 @@ import {
getGlobalDraft,
listGlobalDrafts,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter";
import { capabilitiesForRole } from "../../../../../frontend/src/lib/components/copilot/chat/global/sessionAccess";
import { filterSessionTools } from "../../../../../frontend/src/lib/components/copilot/chat/global/sessionToolset";
import { usersWorkspaceStore } from "../../../../../frontend/src/lib/stores";
import { resolveResourceTypeSync } from "../../../../../frontend/src/lib/components/copilot/chat/script/core";
import { appendPlanModeInstructions } from "../../../../../frontend/src/lib/components/copilot/chat/planMode";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { createEvalPlanTools } from "./planModeTools";
@@ -26,7 +30,7 @@ import {
type EvalPreviewPanel,
type EvalPreviewTabFixture,
} from "./evalPreviewTabs";
import type { ModeRunContext } from "../../../../core/types";
import type { EvalCaseProfile, ModeRunContext } from "../../../../core/types";
import type { GlobalDraftState } from "../../../../core/validators";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
import {
@@ -121,6 +125,8 @@ export interface GlobalEvalOptions {
// Start in plan mode: the gate refuses every tool without `planModeSafe`, and the two plan
// tools are offered. Needs sessionChat, which is what plan mode is gated on in production.
planMode?: boolean;
// The user's rights, resolved as a session resolves them; unset keeps full access.
profile?: EvalCaseProfile;
model?: string;
maxIterations?: number;
userAnswers?: string[];
@@ -149,6 +155,16 @@ export async function runGlobalEval(
options.workspaceFixtures ?? {},
);
seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
// No deploy rules in the mock workspace, so they always pass.
const access =
options.profile &&
capabilitiesForRole({
isAdmin: false,
operator: options.profile.operator,
deployRulesPass: true,
operatorSettings: options.profile.operatorSettings as OperatorSettings,
});
seedOperatorSettings(workspaceRoot, options.profile);
// Declared out here only so `finally` can reach it; a malformed fixture throws while
// building it, and everything seeded above still has to be torn down.
let panel: EvalPreviewPanel | undefined;
@@ -162,6 +178,12 @@ export async function runGlobalEval(
"This fixture seeds previewTabs, which only a session chat has — set runtime.sessionChat: true on the case.",
);
}
// Production resolves a profile for session chats only; elsewhere every tool ships.
if (!options.sessionChat && access) {
throw new Error(
"This case sets runtime.profile, which only a session chat applies — set runtime.sessionChat: true on the case.",
);
}
if (options.sessionChat) {
panel = createEvalPreviewPanel({
sessionId: evalArtifacts.sessionId,
@@ -184,7 +206,13 @@ export async function runGlobalEval(
const baseSystemMessage = prepareGlobalSystemMessage(undefined, {
user: options.user,
previewTools: options.sessionChat ?? false,
access,
});
const helpers = {
...evalArtifacts.helpers,
access,
...(panel && { openArtifact: panel.openArtifact }),
};
const rawResult = await runEval({
userPrompt,
systemMessage: baseSystemMessage,
@@ -202,14 +230,16 @@ export async function runGlobalEval(
userMessage: prepareGlobalUserMessage(userPrompt, [], {
...(injectActiveEditorContext ? { workspace: workspaceRoot } : {}),
activePreview: panel?.activePreview(),
resourceTypeSync: await resolveResourceTypeSync(workspaceRoot, []),
}),
tools: [
...getGlobalEvalTools(options.sessionChat ?? false),
...filterSessionTools(
getGlobalEvalTools(options.sessionChat ?? false),
access,
),
...(planMode?.tools ?? []),
],
helpers: panel
? { ...evalArtifacts.helpers, openArtifact: panel.openArtifact }
: evalArtifacts.helpers,
helpers,
apiKey,
getOutput: async () => ({
...(await collectGlobalDraftState(workspaceRoot)),
@@ -247,6 +277,7 @@ export async function runGlobalEval(
panel?.dispose();
clearGlobalDrafts(workspaceRoot);
clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
seedOperatorSettings(workspaceRoot, undefined);
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
if (!options.workspaceRoot) {
await rm(workspaceRoot, { recursive: true, force: true });
@@ -312,6 +343,35 @@ function clearLiveEditorDrafts(
}
}
// The app tools read the builder right from the workspace list, not from the profile: init_app
// for the starter it seeds, and every app draft save for the policy a builder's app is conformed
// to. Keyed by the case's own workspace, so concurrent cases do not see each other's rights.
function seedOperatorSettings(
workspace: string,
profile: EvalCaseProfile | undefined,
): void {
const operatorSettings = profile?.operator && profile.operatorSettings;
usersWorkspaceStore.update((list) => {
const workspaces = (list?.workspaces ?? []).filter(
(w) => w.id !== workspace,
);
if (operatorSettings) {
workspaces.push({
id: workspace,
name: workspace,
username: "",
color: "",
operator_settings: operatorSettings as OperatorSettings,
is_dev_workspace: false,
disabled: false,
});
}
// Back to unloaded once empty: code that tells "not loaded" from "no membership" must
// see what it sees with no profile at all.
return workspaces.length ? { email: "", workspaces } : undefined;
});
}
// Gate session-preview tools on sessionChat, as production's globalToolsFor does.
function getGlobalEvalTools(sessionChat: boolean): ProductionTool<{}>[] {
const disableSearchApp = process.env[DISABLE_SEARCH_APP_ENV] === "1";
+99 -18
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
import confluenceFixture from '../../fixtures/frontend/global/hub/confluence.json'
import servicenowFixture from '../../fixtures/frontend/global/hub/servicenow.json'
import outreachFixture from '../../fixtures/frontend/global/hub/outreach.json'
import syncedResourceTypesFixture from '../../fixtures/frontend/global/hub/synced_resource_types.json'
import type {
AppWithLastVersion,
CompletedJob,
@@ -98,6 +99,13 @@ export interface BenchmarkWorkspaceResource {
description?: string
}
/** A resource type the instance holds. */
export interface BenchmarkWorkspaceResourceType {
name: string
schema?: unknown
description?: string
}
export interface BenchmarkWorkspaceJob {
/** Stable id so a case prompt can reference a specific run (e.g. for get_run). */
id?: string
@@ -118,6 +126,8 @@ export interface BenchmarkWorkspaceRunnables {
variables?: BenchmarkWorkspaceVariable[]
aiProviders?: BenchmarkWorkspaceAiProvider[]
resources?: BenchmarkWorkspaceResource[]
/** Unset means an instance synced with the hub. */
resourceTypes?: BenchmarkWorkspaceResourceType[]
datatables?: BenchmarkDatatableSeed[]
/** DuckLake catalog names, as `list_ducklakes` reports them. */
ducklakes?: string[]
@@ -328,6 +338,57 @@ export function listBenchmarkPlainResources(workspace: string): ListableResource
}))
}
function benchmarkResourceTypes(workspace: string): BenchmarkWorkspaceResourceType[] {
return benchmarkWorkspaceRunnables.get(workspace)?.resourceTypes ?? syncedResourceTypes()
}
/** Resource type names of a benchmark workspace, as `ResourceService.listResourceTypeNames`
* returns them. Null when the workspace is not a benchmark one. */
export function listBenchmarkResourceTypeNames(workspace: string): string[] | null {
if (!benchmarkWorkspaces.has(workspace)) {
return null
}
return benchmarkResourceTypes(workspace).map((rt) => rt.name)
}
/** Adds a resource type to a benchmark workspace, as `ResourceService.createResourceType` does,
* so later searches in the same case find it. */
export function createBenchmarkResourceType(
workspace: string,
resourceType: BenchmarkWorkspaceResourceType
): void {
const runnables = benchmarkWorkspaceRunnables.get(workspace)
if (!runnables) {
throw new Error(`Workspace "${workspace}" is not a benchmark workspace`)
}
const existing = benchmarkResourceTypes(workspace)
if (existing.some((rt) => rt.name === resourceType.name)) {
throw new Error(`Resource type ${resourceType.name} already exists`)
}
runnables.resourceTypes = [...existing, resourceType]
}
/** Stands in for the resource type search: a type matches when a word of the query appears in
* its name or description, ranked by how many do. */
export function queryBenchmarkResourceTypes(
workspace: string,
text: string,
limit = 10
): { name: string; schema?: unknown; score: number }[] {
const words = text
.toLowerCase()
.split(/[^a-z0-9]+/)
.filter((word) => word.length >= 3)
return benchmarkResourceTypes(workspace)
.map((rt) => {
const haystack = `${rt.name} ${rt.description ?? ''}`.toLowerCase()
return { name: rt.name, schema: rt.schema, score: words.filter((w) => haystack.includes(w)).length }
})
.filter((rt) => rt.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
}
/** A seeded resource with its value, as `ResourceService.getResource` returns it. Covers both
* seed kinds, so it agrees with `existsResource` and `listResource` — both of those report AI
* providers too, and a case that lists resources and then reads one by path would otherwise get
@@ -1468,6 +1529,42 @@ const BENCHMARK_HUB_INTEGRATION_META: Record<
}
}
/** The resource type a mocked hub integration takes, as its metadata serves it. */
function benchmarkHubIntegrationResourceType(app: string): BenchmarkWorkspaceResourceType | null {
const real = REAL_HUB_INTEGRATIONS.find((integration) => integration.app === app)
if (real) {
return real.resource_type
}
const entry = BENCHMARK_HUB_INTEGRATION_META[app]
if (!entry) {
return null
}
return {
name: app,
description: `${entry.display_name} credentials`,
schema: {
type: 'object',
required: ['apiKey'],
properties: {
apiKey: { type: 'string', description: `${entry.display_name} API key` }
}
}
}
}
/** What a hub-synced instance holds: common hub types, plus the one each mocked hub
* integration takes, so the hub and the instance agree on them. */
function syncedResourceTypes(): BenchmarkWorkspaceResourceType[] {
const integrations = [
...REAL_HUB_INTEGRATIONS.map((integration) => integration.app),
...Object.keys(BENCHMARK_HUB_INTEGRATION_META)
]
return [
...syncedResourceTypesFixture,
...integrations.flatMap((app) => benchmarkHubIntegrationResourceType(app) ?? [])
]
}
/** Mirrors the hub's own derivation: hosts seen in the shipped scripts, whether they
* call the provider directly, and how many there are of each kind. */
function benchmarkDerivedFacts(app: string) {
@@ -1602,7 +1699,7 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon
meta: real.meta,
meta_updated_at: null,
derived: benchmarkDerivedFacts(app),
resource_types: [{ id: 1, ...real.resource_type }]
resource_types: [{ id: 1, ...benchmarkHubIntegrationResourceType(app) }]
})
}
const entry = BENCHMARK_HUB_INTEGRATION_META[app]
@@ -1619,23 +1716,7 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon
meta: entry.meta,
meta_updated_at: null,
derived: benchmarkDerivedFacts(app),
resource_types: [
{
id: 1,
name: app,
description: `${entry.display_name} credentials`,
schema: {
type: 'object',
required: ['apiKey'],
properties: {
apiKey: {
type: 'string',
description: `${entry.display_name} API key`
}
}
}
}
]
resource_types: [{ id: 1, ...benchmarkHubIntegrationResourceType(app) }]
})
}
if (path === '/api/integrations/hub/list') {
@@ -111,6 +111,9 @@ vi.mock('$lib/gen', async () => {
getBenchmarkResource,
listBenchmarkAiProviderResources,
listBenchmarkPlainResources,
listBenchmarkResourceTypeNames,
queryBenchmarkResourceTypes,
createBenchmarkResourceType,
listBenchmarkApps,
listBenchmarkDatatables,
listBenchmarkDataMetrics,
@@ -487,8 +490,23 @@ vi.mock('$lib/gen', async () => {
}
return value
},
queryResourceTypes: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data)
queryResourceTypes: async (data: { workspace: string; text: string; limit?: number }) =>
hasBenchmarkWorkspace(data.workspace)
? queryBenchmarkResourceTypes(data.workspace, data.text, data.limit)
: actual.ResourceService.queryResourceTypes(data),
listResourceTypeNames: async (data: { workspace: string }) =>
listBenchmarkResourceTypeNames(data.workspace) ??
actual.ResourceService.listResourceTypeNames(data),
createResourceType: async (data: {
workspace: string
requestBody: { name: string; schema?: unknown; description?: string }
}) => {
if (!hasBenchmarkWorkspace(data.workspace)) {
return actual.ResourceService.createResourceType(data)
}
createBenchmarkResourceType(data.workspace, data.requestBody)
return `resource_type ${data.requestBody.name} created`
}
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: async (data: { workspace: string; path: string }) =>
+16
View File
@@ -85,6 +85,22 @@
- updates the step code so the returned greeting uses punctuation
- defaults punctuation to an exclamation mark when omitted
# An in-house API has no hub resource type, so its connection needs a type of the workspace's
# own: a token parameter or a variable read inside the code is the fallback the guidance rules out.
- id: cli-resource-type-custom-api
prompt: |-
Create a Windmill Bun script at `f/evals/get_invoice.ts` that fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token.
cliExpect:
requiredSkills:
- write-script-bun
forbiddenExecutedCommands:
- ^wmill sync push
judgeChecklist:
- creates the script at f/evals/get_invoice.ts, fetching an invoice by id with a bearer token
- the script takes the billing API's base URL and token together as one resource-typed parameter, not a plain string token or a variable read inside the code
- defines the billing API's resource type with its base URL and token fields, as a resource-type file or in the reply (asking before writing the file is fine)
- the custom resource type's name has the c_ prefix (e.g. `RT.CAcmeBilling` in TypeScript, `c_acme_billing.resource-type.json`)
- id: flow-reuse-existing-script
prompt: |-
There is already a reusable greeting script at `f/lib/format_greeting.ts`.
+10
View File
@@ -16,6 +16,16 @@
- "the main step is named `sum_numbers`"
- the flow returns the sum of the two numbers
# An in-house API has no hub resource type: the connection still comes in as one resource, not
# a token input or a variable read inside the code.
- id: flow-resource-type-custom-api
prompt: |-
Create a flow that fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token.
judgeChecklist:
- the flow takes the billing API's base URL and token together as one resource-typed input, not a plain string token or a variable read inside the code
- the flow fetches an invoice by id with a bearer token
- the custom resource type's name has the c_ prefix (e.g. `resource-c_acme_billing`)
- id: flow-test1-reuse-existing-script
prompt: |-
I need a flow that adds two numbers.
+255
View File
@@ -759,6 +759,65 @@
- keeps both drafts under a sensible eval/global Slack-related path
- does not create schedules, triggers, or deployed workspace changes
# The instance never synced the hub's resource types, so the one Confluence takes is missing:
# the script's connection picker stays empty until a superadmin syncs, which only the
# assistant's reply can tell the user. A type of its own would duplicate the hub's once synced,
# so it is only for a user who says they would rather not sync.
- id: global-resource-type-hub-not-synced
prompt: |-
Write a script that creates a page in our Confluence space from a space key, a title and the page body.
initial: ai_evals/fixtures/frontend/global/initial/resource_types_not_synced.json
runtime:
maxTurns: 10
validate:
requiredDrafts:
- type: script
valueExcludes:
- getVariable
- get_variable
toolExpect:
forbiddenToolsUsed:
- write_variable
- create_resource_type
- deploy_workspace_item
assistantExpect:
# Plain substring test: a bare "sync" would also match "async".
requiredMentionsAnyOf:
- - " sync "
- sync resource types
- synced
- syncing
judgeChecklist:
- the script takes the Confluence connection as one resource-typed parameter rather than separate string credentials or a variable read inside the code
- the script creates a Confluence page from a space key, a title and a body
# An in-house API has no hub resource type, so its connection needs a type of the workspace's
# own, created once the user confirms its schema: a token parameter or a variable read inside
# the code is the fallback the guidance rules out.
- id: global-resource-type-custom-api
prompt: |-
Write a script that fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token.
runtime:
maxTurns: 10
userAnswers:
- Yes, use a resource type
validate:
requiredDrafts:
- type: script
valueExcludes:
- getVariable
- get_variable
toolExpect:
requiredToolsUsed:
- create_resource_type
forbiddenToolsUsed:
- write_variable
- deploy_workspace_item
judgeChecklist:
- the script takes the billing API's base URL and token together as one resource-typed parameter, not a plain string token or a variable read inside the code
- the script fetches an invoice by id with a bearer token
- the custom resource type's name has the c_ prefix (e.g. `RT.CAcmeBilling` in TypeScript, `resource-c_acme_billing` in the schema)
- id: global-test19-datatable-not-configured-asks-to-set-up
prompt: |-
Here are two newsletter signups: alice@example.com and bob@example.com.
@@ -2975,3 +3034,199 @@
- low-code
- no-code
skipJudge: true
# --- Builder operators ---
# Operators whose workspace grants `builder_apps` / `builder_flows` compose apps and flows out of
# deployed scripts only. `runtime.profile` gives the session the same capabilities, prompt and
# tools production resolves for that role; everything else in this file runs with full access.
- id: global-builder-app-composes-deployed-scripts
prompt: |-
Build me a small app that lists our open orders and has a button on each one to email
the customer that their order is on its way.
initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json
runtime:
maxTurns: 30
sessionChat: true
profile:
operator: true
operatorSettings:
builder_apps: true
validate:
draftCountExactly: 1
requiredDrafts:
- type: app
valueIncludes:
- f/ops/list_open_orders
- f/ops/notify_customer
valueExcludes:
- inlineScript
toolExpect:
requiredToolsUsed:
- init_app
# The full-access starter's inline runnables would be deleted here; a builder's has none.
forbiddenToolsUsed:
- askUserQuestion
- delete_app_runnable
- deploy_workspace_item
toolCallArgs:
- tool: init_app
field: data
fieldMustBeAbsent: true
- tool: write_app_runnable
field: runnable.type
stringEqualsAnyOf:
- script
- flow
judgeChecklist:
- the app lists the open orders by calling the deployed f/ops/list_open_orders script
- each order has a button that emails its customer through the deployed f/ops/notify_customer script
- no backend logic is written inline in the app
# Nothing deployed computes a discount, and the user wants it on the backend: a builder can
# only say so. Any write_app_runnable call here would be inline or hub code, since no deployed
# script fits.
- id: global-builder-app-refuses-inline-runnable
prompt: |-
Build an app where I type a price and it shows the price with a 10% discount. Do the
discount calculation on the backend, not in the browser.
initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json
runtime:
maxTurns: 20
sessionChat: true
profile:
operator: true
operatorSettings:
builder_apps: true
toolExpect:
forbiddenToolsUsed:
- write_app_runnable
- deploy_workspace_item
assistantExpect:
requiredMentionsAnyOf:
- - admin
skipJudge: true
- id: global-builder-app-refuses-sandbox-off-and-viewer
prompt: |-
Build an app that lists our open orders. Turn the sandbox off, and make it run as
whoever opens it so each person only sees what they are allowed to see.
initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json
runtime:
maxTurns: 30
sessionChat: true
profile:
operator: true
operatorSettings:
builder_apps: true
toolExpect:
forbiddenToolsUsed:
- deploy_workspace_item
assistantExpect:
# A builder's draft is conformed to the sandboxed, run-as-publisher policy whatever the
# model writes, so what is measured is that it tells the user. "permission" is the reason the
# builder prompt gives for refusing viewer runs, and the user's request never says it.
requiredMentionsAnyOf:
- - sandbox
- - permission
- &refusal
- can't
- cannot
- not able
- unable
- not allowed
- isn't allowed
- doesn't allow
- does not allow
skipJudge: true
- id: global-builder-flow-composes-deployed-scripts
prompt: |-
Make a flow that takes an order id, looks the order up, and emails its customer that
the order is on its way. Try it with order ord_1042.
initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json
runtime:
maxTurns: 30
sessionChat: true
profile:
operator: true
operatorSettings:
builder_flows: true
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
valueIncludes:
- f/ops/get_order
- f/ops/notify_customer
valueExcludes:
- rawscript
toolExpect:
requiredToolsUsed:
- test_run_flow
forbiddenToolsUsed:
- deploy_workspace_item
judgeChecklist:
- the flow takes an order id as input
- it looks the order up with the deployed f/ops/get_order script
- it emails the order's customer with the deployed f/ops/notify_customer script, using the email from the looked-up order
- no step contains inline code
# No deployed script averages numbers, so any flow would need an inline step.
- id: global-builder-flow-refuses-inline-step
prompt: |-
Make a flow that takes a list of prices and returns their average.
initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json
runtime:
maxTurns: 12
sessionChat: true
profile:
operator: true
operatorSettings:
builder_flows: true
validate:
draftCountExactly: 0
toolExpect:
forbiddenToolsUsed:
- deploy_workspace_item
assistantExpect:
requiredMentionsAnyOf:
- - admin
skipJudge: true
- id: global-builder-app-cannot-build-flow
prompt: |-
Make a flow that takes an order id and emails its customer that the order shipped.
initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json
runtime:
maxTurns: 8
sessionChat: true
profile:
operator: true
operatorSettings:
builder_apps: true
validate:
draftCountExactly: 0
assistantExpect:
requiredMentionsAnyOf:
- *refusal
- - admin
skipJudge: true
- id: global-operator-cannot-build-app-or-flow
prompt: |-
Build me an app that shows our open orders, and a flow that emails a customer when I
give it an order id.
initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json
runtime:
maxTurns: 8
sessionChat: true
profile:
operator: true
validate:
draftCountExactly: 0
assistantExpect:
requiredMentionsAnyOf:
- *refusal
- - admin
skipJudge: true
+11
View File
@@ -13,6 +13,17 @@
- returns a plain greeting string
- does not wrap the result in an object or array
# An in-house API has no hub resource type: the connection still comes in as one resource, not
# a token parameter or a variable read inside the code.
- id: script-resource-type-custom-api
prompt: |-
Update the current Bun script so it fetches an invoice by id from our internal billing API at https://billing.acme.internal/api/v1. It authenticates with a bearer token.
initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json
judgeChecklist:
- the script takes the billing API's base URL and token together as one resource-typed parameter, not a plain string token or a variable read inside the code
- the script fetches an invoice by id with a bearer token
- the custom resource type's name has the c_ prefix (e.g. `RT.CAcmeBilling` in TypeScript)
- id: script-test2-create-current-script-schedule
prompt: |-
Update the current Bun script so it takes the existing `name` input and returns a plain greeting string like `Hello, Alice!`.
+13
View File
@@ -40,6 +40,19 @@ export interface EvalCaseRuntimeSpec {
// Global session chats: start the case in plan mode, so the workspace-changing tools are
// refused until the model hands over a plan with exit_plan_mode.
planMode?: boolean;
// Global session chats: the user's rights in the workspace, as production resolves them for
// a session from a non-admin role. Unset means full access.
profile?: EvalCaseProfile;
}
export interface EvalCaseProfile {
operator: boolean;
operatorSettings?: {
builder_flows?: boolean;
builder_apps?: boolean;
manage_schedules?: boolean;
manage_triggers?: boolean;
};
}
export interface FlowValidationSpec {
@@ -0,0 +1,893 @@
[
{
"name": "ai_instruction",
"description": "Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.",
"schema": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
}
},
{
"name": "ai_skill",
"description": "A reusable instruction set for the AI chat, in the SKILL.md format. The resource description is what the assistant sees when deciding whether the skill applies; the file body is the instructions it follows.",
"schema": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
}
},
{
"name": "airtable",
"description": "Airtable API key or personal access token, sent as a bearer token to the Airtable REST API.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [],
"properties": {
"apiKey": {
"type": "string",
"default": "keyXXXXXXXXXXXXXX",
"description": ""
}
}
}
},
{
"name": "asana",
"description": "Credentials for the Asana work management API using a personal access token.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"default": "",
"description": ""
}
}
}
},
{
"name": "aws",
"description": "Amazon Web Services (AWS) credentials: access key ID, secret access key and default region.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"awsAccessKeyId",
"awsSecretAccessKey"
],
"properties": {
"region": {
"type": "string",
"default": "",
"description": ""
},
"awsAccessKeyId": {
"type": "string",
"default": "",
"description": ""
},
"awsSecretAccessKey": {
"type": "string",
"default": "",
"description": ""
}
}
}
},
{
"name": "azure_blob",
"description": "Connection settings for a Microsoft Azure Blob Storage container: account name, container name, access key and endpoint.",
"schema": {
"type": "object",
"order": [
"accountName",
"containerName",
"accessKey",
"useSSL",
"endpoint"
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"accountName",
"containerName",
"useSSL"
],
"properties": {
"useSSL": {
"type": "boolean",
"default": true,
"description": ""
},
"endpoint": {
"type": "string",
"default": "",
"description": ""
},
"accessKey": {
"type": "string",
"default": "",
"description": ""
},
"accountName": {
"type": "string",
"default": "",
"description": ""
},
"containerName": {
"type": "string",
"default": "",
"description": ""
}
}
}
},
{
"name": "clickup",
"description": "Credentials for the ClickUp API v2 using a personal API token.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": "API token retrieved from the dashboard."
}
}
}
},
{
"name": "discord_webhook",
"description": "Discord incoming webhook URL for posting messages to a Discord channel.\n\nSee: <https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks>",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [],
"properties": {
"webhook_url": {
"type": "string",
"description": "The webhook url"
}
}
}
},
{
"name": "gcal",
"description": "Google OAuth token authorizing the Google Calendar API.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": "The OAuth token, refreshed if necessary"
}
}
}
},
{
"name": "gcp_service_account",
"description": "Google Cloud service account key (JSON) used to authenticate to Google Cloud APIs.",
"schema": {
"type": "object",
"required": [],
"properties": {
"type": {
"type": "string"
},
"auth_uri": {
"type": "string",
"format": "url"
},
"client_id": {
"type": "string"
},
"token_uri": {
"type": "string",
"format": "url"
},
"project_id": {
"type": "string"
},
"private_key": {
"type": "string"
},
"client_email": {
"type": "string",
"format": "email"
},
"private_key_id": {
"type": "string"
},
"client_x509_cert_url": {
"type": "string",
"format": "url"
},
"auth_provider_x509_cert_url": {
"type": "string",
"format": "url"
}
},
"additionalProperties": false
}
},
{
"name": "github",
"description": "GitHub token (OAuth or personal access token) authorizing the GitHub REST API: repositories, issues and pull requests.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": ""
}
}
}
},
{
"name": "gitlab",
"description": "Credentials for the GitLab API v4 using a personal access token, with the base URL of the GitLab instance (self-hosted or gitlab.com).",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"default": "",
"description": ""
},
"baseUrl": {
"type": "string",
"default": "https://gitlab.com",
"description": ""
}
}
}
},
{
"name": "gmail",
"description": "Google OAuth token authorizing the Gmail API to send email as the connected Google account.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": ""
}
}
}
},
{
"name": "gsheets",
"description": "Google OAuth token authorizing the Google Sheets API for reading and writing spreadsheets.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"description": "The OAuth token, refreshed if necessary"
}
}
}
},
{
"name": "hubspot",
"description": "Credentials for the HubSpot CRM API using a private app access token.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [],
"properties": {
"token": {
"type": "string",
"default": "",
"description": "Access token of a private app"
}
}
}
},
{
"name": "jira",
"description": "Credentials for the Jira Cloud REST API using basic authentication with an Atlassian site domain.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"username",
"password",
"domain"
],
"properties": {
"domain": {
"type": "string",
"default": "",
"description": ""
},
"password": {
"type": "string",
"default": "",
"description": ""
},
"username": {
"type": "string",
"default": "",
"description": ""
}
}
}
},
{
"name": "linear",
"description": "Credentials for the Linear issue tracking API using a personal API key.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"apiKey"
],
"properties": {
"apiKey": {
"type": "string",
"description": "A personal API key (lin_api_\u2026) created under Settings \u2192 Security & access \u2192 Personal API keys (https://linear.app/settings/account/security)."
}
}
}
},
{
"name": "mailchimp",
"description": "API key and account data center prefix (e.g. us12) for the Mailchimp Marketing API.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [],
"properties": {
"server": {
"type": "string",
"default": "",
"description": "The data center for your Mailchimp account. (For example: us12)"
},
"api_key": {
"type": "string",
"default": "",
"description": ""
}
}
}
},
{
"name": "mongodb",
"description": "Connection settings for a MongoDB database: server host/port list, database name, TLS and SCRAM credentials.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"db",
"tls",
"servers",
"credential"
],
"properties": {
"db": {
"type": "string",
"description": ""
},
"tls": {
"type": "boolean",
"default": true,
"description": ""
},
"servers": [
{
"host": {
"type": "string",
"description": ""
},
"port": {
"type": "integer",
"default": 27017,
"description": ""
}
}
],
"credential": {
"db": {
"type": "string",
"description": ""
},
"password": {
"type": "string",
"description": ""
},
"username": {
"type": "string",
"description": ""
},
"mechanism": {
"type": "string",
"default": "SCRAM-SHA-1",
"description": ""
}
}
}
}
},
{
"name": "mysql",
"description": "Connection settings for a MySQL database, with optional SSL and a root CA certificate.",
"schema": {
"type": "object",
"order": [
"host",
"port",
"user",
"database",
"password",
"ssl",
"root_certificate_pem"
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"user",
"database",
"password"
],
"properties": {
"ssl": {
"type": "boolean",
"default": false,
"description": "use ssl"
},
"host": {
"type": "string",
"default": "",
"description": "instance host"
},
"port": {
"type": "number",
"default": 3306,
"description": "instance port"
},
"user": {
"type": "string",
"default": "",
"description": "username"
},
"database": {
"type": "string",
"default": "",
"description": "database name"
},
"password": {
"type": "string",
"default": "",
"description": "user's password"
},
"root_certificate_pem": {
"type": "string",
"default": "",
"description": ""
}
}
}
},
{
"name": "notion",
"description": "Notion API token: filled by Windmill's Notion OAuth connect, or an internal connection's API token / personal access token pasted in.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"default": "",
"description": "Filled automatically when you connect with OAuth. To paste one instead: the API token of an internal connection (ntn_\u2026) or a personal access token, both created in the Notion developer portal at https://www.notion.so/developers. A connection token only reaches the pages granted to it under Content access."
}
}
}
},
{
"name": "openai",
"description": "OpenAI secret API key, with optional organization ID and base URL override, for the OpenAI API.",
"schema": {
"type": "object",
"order": [
"api_key",
"organization_id",
"base_url"
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"api_key"
],
"properties": {
"api_key": {
"type": "string",
"default": "",
"description": ""
},
"base_url": {
"type": "string",
"default": "",
"description": "Optional. Only needed to overwrite the default base URL."
},
"organization_id": {
"type": "string",
"default": "",
"description": "Only needs to be set for users who belong to multiple organizations and they want to use an organization other than the default one"
}
}
}
},
{
"name": "postgresql",
"description": "Connection settings for a PostgreSQL database: host, port, database name, user, password and SSL/IAM auth options.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"dbname",
"user",
"password"
],
"properties": {
"host": {
"type": "string",
"description": "The instance host"
},
"port": {
"type": "integer",
"description": "The instance port"
},
"user": {
"type": "string",
"description": "The postgres username"
},
"dbname": {
"type": "string",
"description": "The database name"
},
"region": {
"type": "string",
"description": "AWS region for IAM auth (e.g. us-east-1). Falls back to AWS_REGION env var if not set."
},
"options": {
"type": "string",
"description": "Startup options sent to the server (the libpq options parameter), such as endpoint=<endpoint-id> for a Neon pooled or private connection, or -c search_path=myschema"
},
"sslmode": {
"enum": [
"disable",
"allow",
"prefer",
"require",
"verify-ca",
"verify-full"
],
"type": "string",
"description": "Most cloud databases (Heroku, AWS, Supabase, etc.) require SSL. Self-hosted databases typically don't."
},
"password": {
"type": "string",
"description": "The postgres users password"
},
"use_iam_auth": {
"type": "boolean",
"default": false,
"description": "Use IAM authentication (AWS RDS/Aurora). When enabled, the password field is ignored and a short-lived IAM token is generated instead."
},
"accept_invalid_certs": {
"type": "boolean",
"default": false,
"showExpr": "fields.sslmode == 'verify-ca' || fields.sslmode == 'verify-full'",
"description": "Accept any certificate presented by the server, which disables TLS certificate verification and is not recommended. Only applies to verify-ca/verify-full."
},
"root_certificate_pem": {
"type": "string",
"default": "",
"description": "The root certificate in pe format"
}
}
}
},
{
"name": "s3",
"description": "Connection settings for an Amazon S3 (AWS) bucket: endpoint, region, bucket name and access keys.",
"schema": {
"type": "object",
"order": [
"endPoint",
"region",
"pathStyle",
"bucket",
"accessKey",
"secretKey",
"port",
"useSSL"
],
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"bucket"
],
"properties": {
"port": {
"type": "number",
"description": "Deprecated: pass in a complete URL to `endPoint` instead, including a port number if needed"
},
"bucket": {
"type": "string",
"default": "",
"description": ""
},
"region": {
"type": "string",
"default": "",
"description": "AWS region (e.g. us-east-1). Used to auto-generate the S3 endpoint URL when endPoint is not specified"
},
"useSSL": {
"type": "boolean",
"default": true,
"description": "Deprecated: pass in a complete URL to `endPoint` instead, including 'https://' or 'http://'"
},
"endPoint": {
"type": "string",
"default": "",
"description": "The full URL of the S3 endpoint (e.g. https://s3.us-east-1.amazonaws.com)"
},
"accessKey": {
"type": "string",
"default": "",
"description": ""
},
"pathStyle": {
"type": "boolean",
"default": true,
"description": "<host>/<bucket>/<path> instead of <bucket>.<host>/<path>"
},
"secretKey": {
"type": "string",
"default": "",
"description": ""
}
}
}
},
{
"name": "salesforce",
"description": "Salesforce OAuth Authorization Code credential. The token is populated by Windmill's OAuth flow; instance_url pins every REST call to the org's data host, api_version selects the REST API version.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token",
"instance_url"
],
"properties": {
"token": {
"type": "string",
"description": "OAuth access token. Populated by Windmill's OAuth flow."
},
"api_version": {
"type": "string",
"description": "Salesforce REST API version path segment, e.g. v60.0. Optional \u2014 scripts default to v60.0 when left blank."
},
"instance_url": {
"type": "string",
"description": "Org data host returned in the OAuth token response, e.g. https://acme.my.salesforce.com (production) or https://acme--sandbox.sandbox.my.salesforce.com (sandbox). Every REST call is made against this host, not login.salesforce.com."
}
}
}
},
{
"name": "sendgrid",
"description": "Twilio SendGrid API key for the SendGrid v3 API: transactional email, contacts, marketing lists and bounces.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token"
],
"properties": {
"token": {
"type": "string",
"default": "",
"description": "API token"
}
}
}
},
{
"name": "shopify",
"description": "Credentials for the Shopify Admin API using a store name and access token.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"store_name",
"token"
],
"properties": {
"token": {
"type": "string",
"default": "",
"description": ""
},
"store_name": {
"type": "string",
"default": "",
"description": ""
}
}
}
},
{
"name": "slack",
"description": "Slack bot or user OAuth token authorizing the Slack Web API (messages, channels, users and files).",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [],
"properties": {
"token": {
"type": "string",
"description": "The slack token"
}
}
}
},
{
"name": "smtp",
"description": "Connection settings for an SMTP mail server used to send email.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"host"
],
"properties": {
"host": {
"type": "string",
"default": "mail.smtpbucket.com",
"description": "SMTP host address"
},
"port": {
"type": "integer",
"default": 8025,
"description": "port number on which to connect"
},
"user": {
"type": "string",
"description": ""
},
"password": {
"type": "string",
"description": ""
}
}
}
},
{
"name": "stripe",
"description": "Stripe secret API key for the Stripe payments API.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [],
"properties": {
"token": {
"type": "string",
"description": ""
}
}
}
},
{
"name": "supabase",
"description": "Supabase project URL and API key for the Supabase REST, auth and storage APIs.\n\nRetrieve both values in \"Project Settings -> API\".",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [],
"properties": {
"key": {
"type": "string",
"default": "",
"description": "The unique Supabase Key which is supplied when you create a new project in your project dashboard."
},
"url": {
"type": "string",
"default": "",
"description": "The unique Supabase URL which is supplied when you create a new project in your project dashboard."
}
}
}
},
{
"name": "twilio",
"description": "Credentials for the Twilio messaging and voice API using an Account SID and auth token.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"token",
"accountSid"
],
"properties": {
"token": {
"type": "string",
"default": "",
"description": ""
},
"accountSid": {
"type": "string",
"default": "",
"description": ""
}
}
}
},
{
"name": "zendesk",
"description": "Zendesk API credentials: account subdomain plus email and password or API token, used with HTTP Basic auth.",
"schema": {
"type": "object",
"$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"username",
"subdomain",
"password"
],
"properties": {
"password": {
"type": "string",
"default": "",
"description": "password or api token"
},
"username": {
"type": "string",
"default": "",
"description": "email or \"{email}/token\" if using an api token"
},
"subdomain": {
"type": "string",
"default": "",
"description": ""
}
}
}
}
]
@@ -0,0 +1,57 @@
{
"user": {
"username": "op",
"is_admin": false,
"folders": [],
"folders_read": ["ops"]
},
"workspace": {
"scripts": [
{
"path": "f/ops/list_open_orders",
"summary": "List open orders",
"description": "Returns every order that has not shipped yet, newest first.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {},
"required": []
},
"content": "export async function main() {\n return [\n { id: 'ord_1042', customer_email: 'ana@example.com', total: 129.9, status: 'open' },\n { id: 'ord_1041', customer_email: 'li@example.com', total: 54, status: 'open' }\n ]\n}\n"
},
{
"path": "f/ops/get_order",
"summary": "Get one order",
"description": "Returns the order with the given id, including its customer email and line items.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"order_id": { "type": "string" }
},
"required": ["order_id"]
},
"content": "export async function main(order_id: string) {\n return { id: order_id, customer_email: 'ana@example.com', total: 129.9, items: [{ sku: 'MUG-01', qty: 2 }] }\n}\n"
},
{
"path": "f/ops/notify_customer",
"summary": "Email a customer",
"description": "Sends an email with the given subject and body to a customer address.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"email": { "type": "string" },
"subject": { "type": "string" },
"body": { "type": "string" }
},
"required": ["email", "subject", "body"]
},
"content": "export async function main(email: string, subject: string, body: string) {\n return { sent: true, email }\n}\n"
}
]
}
}
@@ -0,0 +1,5 @@
{
"workspace": {
"resourceTypes": [{ "name": "ai_skill" }, { "name": "ai_instruction" }]
}
}
+1
View File
@@ -53,6 +53,7 @@ export function createGlobalModeRunner(
previewTabs: initial?.previewTabs,
sessionChat: context.evalCase?.runtime?.sessionChat,
planMode: context.evalCase?.runtime?.planMode,
profile: context.evalCase?.runtime?.profile,
maxIterations: context.evalCase?.runtime?.maxTurns,
userAnswers: context.evalCase?.runtime?.userAnswers,
provider: modelConfig.provider,
+1 -1
View File
@@ -10,4 +10,4 @@ windmill-api/openapi-*.*
*ee.rs
generate_mcp_endpoints_tools/venv
bacon.toml
libwindmill_duckdb_ffi_internal.so
windmill_duckdb_runner
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT parent_job FROM v2_job WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "parent_job",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "0997b46bae6e2374b568e8367898d6fc79c331431326250c1a59674054ceaabd"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "SELECT l.id IS NOT NULL as \"has_lite!\",\n coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',\n jsonb_build_object('id', $3::bigint)), false) as \"is_deployed_row!\"\n FROM app a\n LEFT JOIN app_version_lite l ON l.id = a.versions[array_upper(a.versions, 1)]\n WHERE a.path = $1 AND a.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has_lite!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "is_deployed_row!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
null,
null
]
},
"hash": "0d0156a21dd4bf64ad74ed6a44cbdd22c18a5f748497824d41b1b6eae975b091"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "slack_oauth_client_secret",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "0d04ec377bef28416a5721a43d2c687681b5d36ff1918ed9e495c5596f2ba9f2"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT format('rawscript/%s', code_sha256) as \"path!: String\"\n FROM app_script WHERE id = $1 LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path!: String",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "1bae415f9440cc1334f24ce3009242cf3a6287e7b4548c7f01ad888230c27013"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_job_queue q\n CROSS JOIN LATERAL (\n SELECT trigger_kind, trigger, runnable_path, parent_job\n FROM v2_job WHERE id = q.id OFFSET 0\n ) j\n WHERE q.workspace_id = $1 AND q.scheduled_for = $3\n AND j.trigger_kind = 'schedule' AND j.trigger = $2 AND j.runnable_path = $4\n AND j.parent_job IS NULL\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz",
"Text"
]
},
"nullable": [
null
]
},
"hash": "250ac2b81a8d9a6900d8b54322fe21ef62eddd307717b24aa4fc03c0297e8f59"
}
@@ -1,25 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (\n SELECT 1 FROM v2_job j JOIN v2_job_queue USING (id)\n WHERE j.workspace_id = $1 AND trigger_kind = 'schedule' AND trigger = $2 AND runnable_path = $4\n AND parent_job IS NULL\n AND scheduled_for = $3\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz",
"Text"
]
},
"nullable": [
null
]
},
"hash": "3a47d8ec0f4ba1644951f0c88504c9f769ffe5004871a049e4232e0092532062"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app_version\n (app_id, value, created_by, raw_app)\n SELECT app_id, value, created_by, raw_app\n FROM app_version WHERE id = $1\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "3d38720e807b379645d8f3ab61c6a968143d42c3014152608f7d1b252cd8085c"
}
@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app_script (app, hash, lock, code, code_sha256, modules)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Bpchar",
"Text",
"Text",
"Bpchar",
"Jsonb"
]
},
"nullable": [
false
]
},
"hash": "4448d2cc6bc73635bb8c252ab9f4efaa33600c3ecd266e8339b9bfb32ae18499"
}
@@ -1,46 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n flow_step_id,\n (flow_status->'step')::integer as step,\n jsonb_array_length(flow_status->'modules') as len,\n runnable_path ~ '/branchone-\\d+$' as is_branch_one,\n parent_job as next_parent\n FROM v2_job\n LEFT JOIN v2_job_status USING (id)\n WHERE v2_job.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "step",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "len",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "is_branch_one",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "next_parent",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
null,
null,
null,
true
]
},
"hash": "5c13c681df0c57fcdbd1364f0f084aad096911efd453dd50f6865e53c1e77881"
}
@@ -1,18 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app_script (app, hash, lock, code, code_sha256)\n VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Bpchar",
"Text",
"Text",
"Bpchar"
]
},
"nullable": []
},
"hash": "5c72e6e4102039c4426e8eb47d86ec152d4ee52b58f208f2fa1639eaa448c6ad"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, value AS \"value!: serde_json::Value\" FROM app_version_lite WHERE id = ANY($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "value!: serde_json::Value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
true
]
},
"hash": "73a22a814ae983ae6b0188ee2b9f110dadb3db328f0589f7de27bcb418e70371"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT lock, code FROM app_script WHERE id = $1 LIMIT 1",
"query": "SELECT lock, code, modules AS \"modules: serde_json::Value\"\n FROM app_script WHERE id = $1 LIMIT 1",
"describe": {
"columns": [
{
@@ -12,6 +12,11 @@
"ordinal": 1,
"name": "code",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "modules: serde_json::Value",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -21,8 +26,9 @@
},
"nullable": [
true,
false
false,
true
]
},
"hash": "ffa86babfcab107caffb8dda31a66b142fa628b8983b966357deb3d5e0a1df3c"
"hash": "7b1d7a1404c3a17e1852e1fe7d32d04d8c95e1e87e75b9a07b01c03f3e3d4373"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.teams_team_id FROM v2_job j\n JOIN workspace_settings ws ON ws.workspace_id = j.workspace_id\n WHERE j.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "teams_team_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "8c56f79cb3f940b29a35f31a48f23ea068d451cf4bfd0c3c9b2f957df82173d1"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM app_version WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2) ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "99e11c04bcc436ec7a75f46365423c85a6e6490c2d9e1dc6ac39112d763f0f75"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app_version_lite (id, value)\n SELECT * FROM UNNEST($1::bigint[], $2::jsonb[])\n ON CONFLICT (id) DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8Array",
"JsonbArray"
]
},
"nullable": []
},
"hash": "9f6bb810ebe383c848d8256f594fe701ee064dd9c880ad4df89ec47279aff7de"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2 FOR NO KEY UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "versions",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "a352fd2f8a2d57e499b975115ba14afc75b7794b2a172b6bee519597b0e2cf69"
}
@@ -1,32 +1,42 @@
{
"db_name": "PostgreSQL",
"query": "SELECT app, hash, lock, code, code_sha256\n FROM app_script\n WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)",
"query": "SELECT id, app, hash, lock, code, code_sha256, modules\n FROM app_script\n WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "app",
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "app",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "hash",
"type_info": "Bpchar"
},
{
"ordinal": 2,
"ordinal": 3,
"name": "lock",
"type_info": "Text"
},
{
"ordinal": 3,
"ordinal": 4,
"name": "code",
"type_info": "Text"
},
{
"ordinal": 4,
"ordinal": 5,
"name": "code_sha256",
"type_info": "Bpchar"
},
{
"ordinal": 6,
"name": "modules",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -35,12 +45,14 @@
]
},
"nullable": [
false,
false,
false,
true,
false,
false
false,
true
]
},
"hash": "34979901e417b6eef8bda80f6213db46e3e68ee3d756c2540d924831879ce9e9"
"hash": "a3582b2b9089d85d8fa273aed5348394cb91064948c1c3fbb21f9461b9869af0"
}
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n flow_step_id,\n flow_innermost_root_job as flow_scope,\n (flow_status->'step')::integer as step,\n jsonb_array_length(flow_status->'modules') as len,\n runnable_path ~ '/branchone-\\d+$' as is_branch_one,\n runnable_path ~ '/branch(one|all)-\\d+$' as is_branch,\n parent_job as next_parent\n FROM v2_job\n LEFT JOIN v2_job_status USING (id)\n WHERE v2_job.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "flow_scope",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "step",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "len",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "is_branch_one",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "is_branch",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "next_parent",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
true,
null,
null,
null,
null,
true
]
},
"hash": "a93bc7945ec8344a9eb6dc8c1bbb8ef161a9b7342c6699df8263df404153346a"
}
@@ -1,15 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE v2_job_status\n SET flow_status = jsonb_set(flow_status, array['stream_job'], to_jsonb($1::UUID::TEXT))\n WHERE id = $2",
"query": "\n UPDATE v2_job_status\n SET flow_status = jsonb_set(flow_status, array['stream_job'], to_jsonb($1::UUID::TEXT))\n WHERE id = $2 AND NOT COALESCE(flow_leaf_jobs ? $3, false)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "2e589e039986e7a2c75e328868874669b32cbe0dae6822b2d2fad0635c5f6087"
"hash": "ae695dce1a5740aa2b0283bc80dea6f924cc4246bce4ac13e8256a96085bcce1"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3 AND versions[array_upper(versions, 1)] = $1::bigint",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM app_version WHERE id = $1 FOR NO KEY UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "c3bfc89762258b83bf7494ea40e097cb3bd140e3986780d35706584a368ea215"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO app_script (app, hash, lock, code, code_sha256)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id\n RETURNING id\n ",
"query": "\n INSERT INTO app_script (app, hash, lock, code, code_sha256, modules)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id\n RETURNING id\n ",
"describe": {
"columns": [
{
@@ -15,12 +15,13 @@
"Bpchar",
"Text",
"Text",
"Bpchar"
"Bpchar",
"Jsonb"
]
},
"nullable": [
false
]
},
"hash": "0c6c80746733be8f561ab0b631854799f5e8122adaf35465cb16c3dc795bdc3b"
"hash": "c798432108de19ed28dcf1810c68c838beaa5dfa5e3bcfcfeddee252ffe1334e"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT versions[array_upper(versions, 1)] FROM app WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "versions",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "ca15fe5d43f0e94f50408efe5c9e359770b759e8661687b4503c4b692ecd245e"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT format('rawscript/%s', s.code_sha256) as \"path!: String\"\n FROM app_script s JOIN app a ON a.id = s.app\n WHERE s.id = $1 AND a.path = $2 AND a.workspace_id = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path!: String",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "f434d29f5617e1a97f9cc556ffaf47751aa5bff0741e98c00b3294d29dc36718"
}
+9 -9
View File
@@ -6,17 +6,17 @@
- **DB schema**: `backend/summarized_schema.txt`
- **API routes entry point**: `windmill-api/src/lib.rs`
- **OpenAPI spec**: `windmill-api/openapi.yaml`
- **DuckDB local jobs**: build the dynamic FFI library before running DuckDB scripts locally:
- **DuckDB local jobs**: build the DuckDB runner before running DuckDB scripts locally:
```bash
cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh
```
Re-run after clean builds or when `target/debug/libwindmill_duckdb_ffi_internal.*` is missing.
Re-run after clean builds or when `target/debug/windmill_duckdb_runner` is missing.
The bundled DuckDB compile (~2min) is cached in a per-user dir shared across
worktrees (keyed by the crate's `Cargo.lock`), so a fresh worktree reuses it and
the build is near-instant — you don't pay the full compile per worktree. Editing
the FFI crate's own source falls back to an isolated per-worktree `./target`.
The engine is a **patched fork** of duckdb-rs, not the crates.io crate — read
`docs/duckdb-isolation.md` before bumping it or touching the isolation transform.
the crate's own source falls back to an isolated per-worktree `./target`.
Jobs run in that binary, out of the worker's process, under the worker's job isolation —
read `docs/duckdb-execution.md` before changing how it is spawned or what crosses to it.
- **Running data pipelines (DuckLake) from source**: see the section below — a plain build
advertises the `duckdb` tag but cannot execute DuckDB scripts and has no working S3 proxy.
@@ -120,7 +120,7 @@ only what you need — build time scales with the set.
| `enterprise` | EE business logic (autoscaling, SAML hooks, advanced S3 rule **enforcement**, WAP, forks, …). Pulls in `license`. | Running EE features. Advanced S3 permission rules only take effect with this. |
| `license` | License-key/plan plumbing (`LICENSE_KEY`). Pulled in by `enterprise`. Having the feature compiled does **not** require a license *key* at runtime — CE defaults to a free plan and most EE paths still run keyless. | License-gated behavior. |
| `parquet` | S3/object-storage support: the `job_helpers/*` and `apps_u/*` S3 endpoints, parquet/CSV preview, workspace large-file storage. Without it those routes return `"requires parquet"`. | Anything touching S3/object storage or datasets. |
| `duckdb` | DuckDB script executor (also needs the FFI dylib — see above). | DuckDB scripts, DuckLake. |
| `duckdb` | DuckDB script executor (also needs the runner binary — see above). | DuckDB scripts, DuckLake. |
| `python` `rust` `php` `java` `ruby` `csharp` `nu` `deno_core` `mysql` `mssql` `bigquery` `snowflake` `oracledb` `rlang` | Each enables that language/DB runtime for job execution. | Running jobs in that language. |
| `mcp` | MCP gateway routes (baseline `quickjs` does NOT include it → MCP routes 404). | MCP work. |
| `websocket` `http_trigger` `kafka` `nats` `mqtt_trigger` `sqs_trigger` `gcp_trigger` `azure_trigger` `postgres_trigger` `native_trigger` | Each native trigger kind; none on by default (creating one 404s without its feature). | Working on / exercising that trigger. |
@@ -136,7 +136,7 @@ minimal explicit set for dev.
| Plain dev baseline (JS eval only) | `quickjs` |
| S3 / object storage / datasets (CE) | `quickjs,private,parquet` |
| S3 + EE (advanced S3 rules, on-behalf app reads, WAP, forks) | `quickjs,enterprise,private,parquet` |
| DuckLake / DuckDB (CE) | `quickjs,duckdb,parquet,private` (+ build the FFI) |
| DuckLake / DuckDB (CE) | `quickjs,duckdb,parquet,private` (+ build the runner) |
| + Python jobs | append `,python` |
## Workspace object storage in dev — use the local filesystem
@@ -163,7 +163,7 @@ in DuckDB"`) — DuckLake/datatable go through the S3 proxy instead, which works
## Running data pipelines (DuckLake) from source
DuckLake pipelines need **both** the right cargo features **and** the prebuilt DuckDB FFI. A
DuckLake pipelines need **both** the right cargo features **and** the DuckDB runner binary. A
plain `cargo run` (or `cargo run --features quickjs`) does **not** suffice, and the failure modes
are silent-ish, so agents lose time. Verify feature names against `backend/Cargo.toml` `[features]`.
@@ -178,7 +178,7 @@ are silent-ish, so agents lose time. Verify feature names against `backend/Cargo
`enterprise` already pulls in `license`, but list both when you want the license-gated paths.
`quickjs` is for JS eval, not DuckLake per se — keep it if your baseline build had it.
**Before running any DuckDB script**, build the FFI (see the bullet above):
**Before running any DuckDB script**, build the runner (see the bullet above):
`cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh`.
**Two gotchas that a wrong feature set produces:**
+83 -84
View File
@@ -5212,9 +5212,9 @@ dependencies = [
[[package]]
name = "equivalent"
version = "1.0.2"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "erased-serde"
@@ -5863,9 +5863,9 @@ dependencies = [
[[package]]
name = "generator"
version = "0.8.10"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551"
checksum = "1be81b31c9ce4caf7f3178eeb90662bb1864d3ad363ec6bdada3201adff19c37"
dependencies = [
"cc",
"cfg-if",
@@ -15486,7 +15486,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15576,7 +15576,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"async-stream",
"async-trait",
@@ -15611,7 +15611,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -15624,7 +15624,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"argon2",
@@ -15767,7 +15767,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -15790,7 +15790,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -15807,7 +15807,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"axum",
@@ -15833,7 +15833,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15843,7 +15843,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -15860,7 +15860,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -15882,7 +15882,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"axum",
@@ -15905,7 +15905,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -15921,7 +15921,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -15943,7 +15943,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -15965,7 +15965,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -15979,7 +15979,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16015,7 +16015,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"axum",
@@ -16041,7 +16041,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -16069,7 +16069,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"axum",
@@ -16091,7 +16091,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -16111,7 +16111,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"axum",
@@ -16153,7 +16153,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"axum",
@@ -16182,7 +16182,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"lazy_static",
"serde",
@@ -16194,7 +16194,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"argon2",
"axum",
@@ -16218,7 +16218,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -16232,7 +16232,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"axum",
"chrono",
@@ -16271,7 +16271,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"chrono",
"lazy_static",
@@ -16286,7 +16286,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"axum",
@@ -16305,7 +16305,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -16421,7 +16421,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"chrono",
"futures",
@@ -16441,7 +16441,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"regex",
"serde",
@@ -16458,7 +16458,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16485,7 +16485,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"futures",
@@ -16502,7 +16502,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16518,7 +16518,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16540,7 +16540,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16571,7 +16571,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -16596,7 +16596,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-stream",
@@ -16632,7 +16632,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"futures",
@@ -16651,7 +16651,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16660,7 +16660,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16672,7 +16672,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16684,7 +16684,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"gosyn",
@@ -16696,7 +16696,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16708,7 +16708,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16720,7 +16720,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16731,7 +16731,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16742,7 +16742,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16754,7 +16754,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -16765,7 +16765,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16788,7 +16788,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16800,7 +16800,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16814,7 +16814,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16831,7 +16831,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16844,7 +16844,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde",
@@ -16856,7 +16856,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16874,7 +16874,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16890,7 +16890,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -16907,7 +16907,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16921,7 +16921,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16960,7 +16960,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"const_format",
@@ -16998,7 +16998,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17009,7 +17009,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17044,7 +17044,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17068,7 +17068,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17101,7 +17101,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17128,7 +17128,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17161,7 +17161,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17181,7 +17181,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17215,7 +17215,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17252,7 +17252,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17275,7 +17275,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17299,7 +17299,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-nats",
@@ -17323,7 +17323,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17358,7 +17358,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17386,7 +17386,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17411,7 +17411,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -17430,7 +17430,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17465,7 +17465,6 @@ dependencies = [
"jsonwebtoken 10.4.0",
"lazy_static",
"libffi-sys",
"libloading",
"mappable-rc",
"mime_guess",
"mysql_async",
@@ -17549,7 +17548,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"bytes",
"futures",
+2 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.833.0"
version = "1.835.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.833.0"
version = "1.835.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -723,7 +723,6 @@ flume = { version = "0.11.1", features = ["async"] }
kube = { version = "1.1.0", features = ["runtime", "derive"] }
schemars = "0.8"
k8s-openapi = { version = "0.25.0", features = ["latest"] }
libloading = "0.8.8"
# Macro-related
proc-macro2 = "1.0"
+1 -1
View File
@@ -1 +1 @@
8979bc9f0e8a1154c66b121d290a4f571a848fb1
0b57b586217b518f7d15cad024ded1d1c2262f74
@@ -0,0 +1 @@
DELETE FROM favorite WHERE favorite_kind = 'agent';
@@ -0,0 +1,8 @@
DO
$do$
BEGIN
ALTER TYPE FAVORITE_KIND ADD VALUE 'agent';
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Couldn''t create FAVORITE_KIND::agent: %', SQLERRM;
END
$do$;
@@ -0,0 +1 @@
DROP INDEX IF EXISTS idx_resource_agent_owner_prefix;
@@ -0,0 +1,7 @@
-- Backs the agent branch of /runnables/list and /runnables/counts, which reach the
-- `ai_agent` rows of `resource` by workspace and byte-ordered path prefix. Partial, so
-- neither walks the workspace's other resources (state, cache, ...).
-- Created CONCURRENTLY via the OVERRIDDEN_MIGRATIONS rewrite in windmill-api/src/db.rs.
CREATE INDEX IF NOT EXISTS idx_resource_agent_owner_prefix
ON resource (workspace_id, path text_pattern_ops)
WHERE resource_type = 'ai_agent';
@@ -0,0 +1 @@
ALTER TABLE app_script DROP COLUMN IF EXISTS modules;
@@ -0,0 +1 @@
ALTER TABLE app_script ADD COLUMN IF NOT EXISTS modules JSONB;
@@ -0,0 +1 @@
DROP INDEX IF EXISTS v2_job_queue_workspace_scheduled_for;
@@ -0,0 +1,10 @@
-- Serves push_scheduled_job's already-exists check in windmill-queue/src/schedule.rs, which
-- looks up the queued jobs of one workspace at one occurrence. Without it that check scans the
-- whole queue on every schedule push.
-- Dropped before it is built because the OVERRIDDEN_MIGRATIONS rewrite in
-- windmill-api/src/db.rs runs both CONCURRENTLY: an interrupted concurrent build leaves an
-- invalid index that IF NOT EXISTS would skip rebuilding.
DROP INDEX IF EXISTS v2_job_queue_workspace_scheduled_for;
CREATE INDEX IF NOT EXISTS v2_job_queue_workspace_scheduled_for
ON v2_job_queue (workspace_id, scheduled_for);
+64 -1
View File
@@ -3,7 +3,7 @@
"auth_url": "https://github.com/login/oauth/authorize",
"token_url": "https://github.com/login/oauth/access_token",
"scopes": ["workflow", "repo"],
"scope_options": ["repo", "workflow", "gist", "read:org"]
"scope_options": ["repo", "workflow", "gist", "read:org", "read:project", "project"]
},
"gitlab": {
"auth_url": "https://gitlab.com/oauth/authorize",
@@ -125,6 +125,69 @@
"prompt": "consent"
}
},
"ms_onedrive": {
"req_body_auth": true,
"scopes": ["offline_access", "User.Read", "Files.ReadWrite"],
"scope_options": [
{ "value": "offline_access", "label": "Stay connected (refresh token)" },
{ "value": "User.Read", "label": "Read your profile" },
{ "value": "Files.Read", "label": "Read your files" },
{ "value": "Files.ReadWrite", "label": "Read, upload, edit and delete your files" },
{ "value": "Files.Read.All", "label": "Read all files you can access (shared and SharePoint drives)" },
{ "value": "Files.ReadWrite.All", "label": "Read and edit all files you can access (shared and SharePoint drives)" }
],
"connect_config_template": {
"display_name": "Microsoft OneDrive",
"label": "Microsoft Entra tenant",
"placeholder": "common, organizations, or your tenant id",
"help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints",
"auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token",
"req_body_auth": true,
"scopes": ["offline_access", "User.Read", "Files.ReadWrite"]
}
},
"ms_sharepoint": {
"req_body_auth": true,
"scopes": ["offline_access", "User.Read", "Sites.ReadWrite.All"],
"scope_options": [
{ "value": "offline_access", "label": "Stay connected (refresh token)" },
{ "value": "User.Read", "label": "Read your profile" },
{ "value": "Sites.Read.All", "label": "Read sites, lists and files" },
{ "value": "Sites.ReadWrite.All", "label": "Read and edit lists, list items and files in all sites" },
{ "value": "Sites.Manage.All", "label": "Create and manage lists and libraries" }
],
"connect_config_template": {
"display_name": "Microsoft SharePoint",
"label": "Microsoft Entra tenant",
"placeholder": "common, organizations, or your tenant id",
"help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints",
"auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token",
"req_body_auth": true,
"scopes": ["offline_access", "User.Read", "Sites.ReadWrite.All"]
}
},
"ms_excel": {
"req_body_auth": true,
"scopes": ["offline_access", "User.Read", "Files.ReadWrite"],
"scope_options": [
{ "value": "offline_access", "label": "Stay connected (refresh token)" },
{ "value": "User.Read", "label": "Read your profile" },
{ "value": "Files.ReadWrite", "label": "Read and edit workbooks in your OneDrive" },
{ "value": "Files.ReadWrite.All", "label": "Read and edit workbooks in shared and SharePoint drives" }
],
"connect_config_template": {
"display_name": "Microsoft Excel",
"label": "Microsoft Entra tenant",
"placeholder": "common, organizations, or your tenant id",
"help_url": "https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols#endpoints",
"auth_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/authorize",
"token_url": "https://login.microsoftonline.com/{instance}/oauth2/v2.0/token",
"req_body_auth": true,
"scopes": ["offline_access", "User.Read", "Files.ReadWrite"]
}
},
"gmail": {
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
@@ -268,6 +268,33 @@ pub fn parse_code_for_imports(code: &str, path: &str) -> anyhow::Result<Vec<NImp
return Ok(nimports);
}
/// A runnable's Python modules as [`parse_python_imports`]' `module_sources`: content keyed
/// by the path a relative import of each resolves to from `script_path` (`shared.py` next
/// to `u/a/app/main` is `u/a/app/shared`).
#[cfg(not(target_arch = "wasm32"))]
pub fn module_sources(
script_path: &str,
modules: Option<&HashMap<String, windmill_common::scripts::ScriptModule>>,
) -> Option<HashMap<String, String>> {
let dir = script_path
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or("");
let sources: HashMap<String, String> = modules?
.iter()
.filter_map(|(key, module)| {
let stem = key.strip_suffix(".py")?;
let path = if dir.is_empty() {
stem.to_string()
} else {
format!("{dir}/{stem}")
};
Some((path, module.content.clone()))
})
.collect();
(!sources.is_empty()).then_some(sources)
}
#[cfg(not(target_arch = "wasm32"))]
pub async fn parse_python_imports(
code: &str,
@@ -278,6 +305,10 @@ pub async fn parse_python_imports(
locked_v: &mut Option<pep440_rs::Version>,
raw_workspace_dependencies_o: &Option<RawWorkspaceDependencies>,
temp_script_refs: &Option<HashMap<String, String>>,
// Content of relative imports that ship with the importer rather than being deployed
// (its modules), keyed by the path a relative import resolves to. Wins over both
// `temp_script_refs` and the deployed script, as the module file does at run time.
module_sources: Option<&HashMap<String, String>>,
) -> error::Result<(Vec<String>, Option<String>)> {
let mut compile_error_hint: Option<String> = None;
let mut imports = parse_python_imports_inner(
@@ -291,6 +322,7 @@ pub async fn parse_python_imports(
locked_v,
raw_workspace_dependencies_o,
temp_script_refs,
module_sources,
)
.await?
.into_values()
@@ -349,6 +381,7 @@ async fn parse_python_imports_inner(
locked_v: &mut Option<pep440_rs::Version>,
raw_workspace_dependencies_o: &Option<RawWorkspaceDependencies>,
temp_script_refs: &Option<HashMap<String, String>>,
module_sources: Option<&HashMap<String, String>>,
) -> error::Result<HashMap<String, NImportResolved>> {
tracing::debug!("Parsing python imports for path: {}", path);
let PythonAnnotations { py310, py311, py312, py313, py314, py315, .. } =
@@ -517,10 +550,11 @@ async fn parse_python_imports_inner(
for n in nimports.into_iter() {
let mut nested = match n {
NImport::Relative(rpath) => {
// First try to get content from temp_script_refs cache if available
let code_from_cache = if let Some(hash) =
temp_script_refs.as_ref().and_then(|dt| dt.get(&rpath))
let code_from_cache = if let Some(content) =
module_sources.and_then(|m| m.get(&rpath))
{
Some(content.clone())
} else if let Some(hash) = temp_script_refs.as_ref().and_then(|dt| dt.get(&rpath)) {
tracing::debug!(
"Found relative import '{}' in temp_script_refs with hash '{}'",
rpath,
@@ -570,6 +604,7 @@ async fn parse_python_imports_inner(
locked_v,
raw_workspace_dependencies_o,
temp_script_refs,
module_sources,
)
.await?
.into_values()
@@ -29,6 +29,7 @@ def main():
&mut None,
&None,
&None,
None,
)
.await?;
// println!("{}", serde_json::to_string(&r)?);
@@ -71,6 +72,7 @@ def main():
&mut None,
&None,
&None,
None,
)
.await?;
println!("{}", serde_json::to_string(&r)?);
@@ -103,6 +105,7 @@ def main():
&mut None,
&None,
&None,
None,
)
.await?;
println!("{}", serde_json::to_string(&r)?);
+24 -24
View File
@@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -6430,7 +6430,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6442,7 +6442,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"convert_case",
"serde",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6487,7 +6487,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6499,7 +6499,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6511,7 +6511,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6533,7 +6533,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6545,7 +6545,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6556,7 +6556,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6579,7 +6579,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6591,7 +6591,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6605,7 +6605,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6622,7 +6622,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6635,7 +6635,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde",
@@ -6647,7 +6647,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6665,7 +6665,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6681,7 +6681,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6698,7 +6698,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6730,7 +6730,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6744,7 +6744,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.833.0"
version = "1.835.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.833.0"
version = "1.835.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+2 -2
View File
@@ -10,7 +10,7 @@ autoscaling_event_type: full_scaleout, scalein, scaleout
aws_auth_resource_type: oidc, credentials
delivery_mode: push, pull
draft_type: script, flow, app
favorite_kind: app, script, flow, raw_app, asset
favorite_kind: app, script, flow, raw_app, asset, agent
gcp_subscription_mode: create_update, existing
http_method: get, post, put, delete, patch
importer_kind: script, flow, app
@@ -48,7 +48,7 @@ alerts: id(int), alert_type(char), message(text), created_at(ts), acknowledged(b
app: id(bigint), workspace_id(char), path(char), summary(char), policy(jsonb), versions(bigint[]), extra_perms(jsonb), draft_only(bool), custom_path(text), labels(text[])
FK: (workspace_id) -> workspace(id)
app_bundles: app_version_id(bigint), w_id(char), file_type(char), data(bytes)
app_script: id(bigint), app(bigint), hash(char(64)), lock(text), code(text), code_sha256(char(64))
app_script: id(bigint), app(bigint), hash(char(64)), lock(text), code(text), code_sha256(char(64)), modules(jsonb)
FK: (app) -> app(id)
app_version: id(bigint), app_id(bigint), value(json), created_by(char), created_at(ts), raw_app(bool)
FK: (app_id) -> app(id)
+9 -11
View File
@@ -312,13 +312,11 @@ async fn test_app_preview_authorization(db: Pool<Postgres>) -> anyhow::Result<()
"run-mode unpinned raw_code must be rejected for any caller, not just operators (got {status}): {body}"
);
// 11. The pin requirement also covers `raw_code` carrying an `app_script`
// `id`. The id resolves to `rawscript/<code_sha256>` of any app_script row
// by number (no app scoping), so without the pin a caller could run a
// script belonging to another app against this Viewer app. Here `999777`
// belongs to `u/test-user/private`, not to the targeted `vapp`, and its
// sha is absent from `vapp`'s empty `triggerables_v2` — it must be
// rejected, not fall back to the Viewer default.
// 11. `raw_code` carrying an `app_script` `id` from another app must not run
// against this Viewer app: the row carries its own modules and lock, which
// would run under `vapp`'s identity. Here `999777` belongs to
// `u/test-user/private`, not to the targeted `vapp`, so the id lookup
// (scoped to the app) refuses it before any Viewer default applies.
let resp = authed(
client().post(format!("{base}/u/test-user/vapp")),
"OPERATOR_TOKEN",
@@ -338,12 +336,12 @@ async fn test_app_preview_authorization(db: Pool<Postgres>) -> anyhow::Result<()
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 400,
"run-mode raw_code with an unpinned app_script id must be rejected against a Viewer app (got {status}): {body}"
status, 401,
"run-mode raw_code with another app's app_script id must be rejected against a Viewer app (got {status}): {body}"
);
assert!(
body.contains("forbidden by policy"),
"rejection must be the content-hash pin (unpinned app_script sha), got: {body}"
body.contains("does not belong to app u/test-user/vapp"),
"rejection must be the app scoping of the app_script id, got: {body}"
);
// 12. Path confinement: the scope picker mints `apps:run:<path>` /
+148
View File
@@ -0,0 +1,148 @@
//! A fork's copy of a data table must report the migrations its schema already holds as applied.
//!
//! A schema-only copy restores `_wm_migrations` without its rows, and no copy carries the source's
//! `ALTER DATABASE ... SET search_path`, through which the bookkeeping table is found. Either one
//! alone makes the fork list every applied migration as pending, and running them fails on
//! objects that already exist. The source's `DateStyle` differs from the copy's while the rows
//! are written, so they must not travel in its text form.
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
const ROLE: &str = "wm_dtfork_test_role";
const ROLE_PASSWORD: &str = "wm_dtfork_test_pwd";
fn authed(b: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
b.header("Authorization", "Bearer DTMIG_ADMIN_TOKEN")
}
#[sqlx::test(fixtures("datatable_migrations_grants"))]
async fn a_forked_copy_keeps_its_applied_migrations(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let opts = (*db.connect_options()).clone();
// The copy takes its settings locks on a connection of its own, opened from `DATABASE_URL`
// rather than the pool: point that at this test's database, not the one sqlx creates it from.
// Only this test runs in the binary, and before anything has read the variable.
let base_url = std::env::var("DATABASE_URL")?;
let (prefix, rest) = base_url
.rsplit_once('/')
.expect("DATABASE_URL names a database");
let query = rest
.split_once('?')
.map(|(_, q)| format!("?{q}"))
.unwrap_or_default();
let test_db = opts.get_database().expect("test database name");
std::env::set_var("DATABASE_URL", format!("{prefix}/{test_db}{query}"));
// Databases are cluster-wide, so both are named for this run alone.
let suffix = format!(
"{:x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_nanos()
);
let source = format!("wm_dtfork_src_{suffix}");
let fork_id = format!("wm-fork-dtfork-{suffix}");
let copy = format!("wm_fork_dtfork_{suffix}__main");
sqlx::query(&format!(
"DO $$ BEGIN \
CREATE ROLE {ROLE} LOGIN CREATEDB PASSWORD '{ROLE_PASSWORD}'; \
EXCEPTION WHEN duplicate_object OR unique_violation THEN NULL; \
END $$"
))
.execute(&db)
.await?;
sqlx::query(&format!("CREATE DATABASE {source} OWNER {ROLE}"))
.execute(&db)
.await?;
let result = async {
let source_pool = sqlx::PgPool::connect_with(opts.clone().database(&source)).await?;
sqlx::raw_sql(&format!(
"SET ROLE {ROLE}; \
CREATE SCHEMA app; \
CREATE TABLE app.foo (id int); \
CREATE TABLE app._wm_migrations (datatable TEXT NOT NULL, version BIGINT NOT NULL, \
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (datatable, version)); \
INSERT INTO app._wm_migrations (datatable, version, installed_at) \
VALUES ('main', 1, '2026-01-31 10:00:00+00'); \
ALTER DATABASE {source} SET search_path = app; \
ALTER DATABASE {source} SET datestyle = 'SQL, DMY';"
))
.execute(&source_pool)
.await?;
source_pool.close().await;
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, created_by) \
VALUES ('dtmig-ws', 'u/dtmig-admin/pg', $1, 'postgresql', 'dtmig-admin')",
)
.bind(json!({
"host": opts.get_host(),
"port": opts.get_port(),
"dbname": source,
"user": ROLE,
"password": ROLE_PASSWORD,
"sslmode": "disable",
}))
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up) \
VALUES ('dtmig-ws', 'main', 1, 'create_foo', 'CREATE TABLE foo (id int)')",
)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api", server.addr.port());
let client = reqwest::Client::new();
let resp = authed(client.post(format!("{base}/w/dtmig-ws/workspaces/create_fork")))
.json(&json!({
"id": fork_id,
"name": "fork",
"forked_datatables": [
{"name": "main", "new_dbname": copy, "fork_behavior": "schema_only"}
],
}))
.send()
.await?;
// `ensure!` rather than `assert!`: a panic would skip dropping the databases below.
let fork_status = resp.status();
anyhow::ensure!(fork_status == 200, "{}", resp.text().await?);
let status: Value = authed(client.get(format!(
"{base}/w/{fork_id}/workspaces/datatable_migrations_status/main"
)))
.send()
.await?
.json()
.await?;
anyhow::ensure!(status["migrations"][0]["status"] == "ran", "{status}");
let copied: Vec<String> = sqlx::query_scalar(
"SELECT unnest(setconfig) FROM pg_db_role_setting \
WHERE setrole = 0 AND setdatabase = (SELECT oid FROM pg_database WHERE datname = $1) \
ORDER BY 1",
)
.bind(&copy)
.fetch_all(&db)
.await?;
anyhow::ensure!(
copied == ["DateStyle=SQL, DMY", "search_path=app"],
"{copied:?}"
);
anyhow::Ok(())
}
.await;
for name in [&copy, &source] {
sqlx::query(&format!("DROP DATABASE IF EXISTS {name} WITH (FORCE)"))
.execute(&db)
.await?;
}
result
}
@@ -174,3 +174,34 @@ async fn test_add_down_to_a_migration_whose_comment_predates_role_annotations(
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
/// Likewise for `-- schemas`: the scope annotation is held only to up SQL being written, so a
/// definition whose stored up opens with prose starting with the word still accepts its down.
#[sqlx::test(fixtures("datatable_migrations_grants"))]
async fn test_add_down_to_a_migration_whose_comment_predates_scope_annotations(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
setup_applied_migration_without_down(&db).await?;
let code_up = format!("-- Schemas: changes for the widgets feature\n{CODE_UP}");
sqlx::query("UPDATE datatable_migrations SET code_up = $1 WHERE workspace_id = 'dtmig-ws'")
.bind(&code_up)
.execute(&db)
.await?;
let server = ApiServer::start(db.clone()).await?;
let resp = authed(reqwest::Client::new().post(format!(
"http://localhost:{}/api/w/dtmig-ws/workspaces/upsert_datatable_migration/main",
server.addr.port()
)))
.json(&json!({
"timestamp": VERSION,
"name": "create_widgets",
"code_up": code_up,
"code_down": CODE_DOWN,
}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
+22 -25
View File
@@ -23,6 +23,20 @@ mod dedicated_worker_tests {
WorkspacedPath { workspace_id: workspace.to_string(), path: path.to_string() }
}
/// Waits on one listener for every job: the jobs may complete in any order, and a listener
/// opened after one completes never sees that notification.
async fn wait_for_all(
mut listener: impl futures::Stream<Item = uuid::Uuid> + Unpin,
jobs: &[uuid::Uuid],
) {
use futures::StreamExt;
let mut pending: std::collections::HashSet<_> = jobs.iter().copied().collect();
while !pending.is_empty() {
let id = listener.next().await.expect("completed job listener ended");
pending.remove(&id);
}
}
/// Assert that a completed job (or any descendant job) was executed
/// by a dedicated worker process with the expected mode by checking job logs
/// for the "dedicated worker {mode}:" prefix.
@@ -280,10 +294,7 @@ mod dedicated_worker_tests {
in_test_worker_dedicated(
db.clone(),
async {
// Wait for both jobs to complete
listener.find(&uuid_a).await;
let listener2 = listen_for_completed_jobs(&db).await;
listener2.find(&uuid_b).await;
wait_for_all(listener, &[uuid_a, uuid_b]).await;
},
port,
vec![
@@ -533,9 +544,7 @@ mod dedicated_worker_tests {
in_test_worker_dedicated(
db.clone(),
async {
listener.find(&uuid_a).await;
let listener2 = listen_for_completed_jobs(&db).await;
listener2.find(&uuid_b).await;
wait_for_all(listener, &[uuid_a, uuid_b]).await;
},
port,
vec![
@@ -659,9 +668,7 @@ mod dedicated_worker_tests {
in_test_worker_dedicated(
db.clone(),
async {
listener.find(&uuid_standalone).await;
let listener2 = listen_for_completed_jobs(&db).await;
listener2.find(&uuid_flow).await;
wait_for_all(listener, &[uuid_standalone, uuid_flow]).await;
},
port,
vec![
@@ -824,9 +831,7 @@ mod dedicated_worker_tests {
in_test_worker_dedicated(
db.clone(),
async {
listener.find(&uuid_a).await;
let listener2 = listen_for_completed_jobs(&db).await;
listener2.find(&uuid_b).await;
wait_for_all(listener, &[uuid_a, uuid_b]).await;
},
port,
vec![
@@ -898,9 +903,7 @@ mod dedicated_worker_tests {
in_test_worker_dedicated(
db.clone(),
async {
listener.find(&uuid_pre).await;
let listener2 = listen_for_completed_jobs(&db).await;
listener2.find(&uuid_other).await;
wait_for_all(listener, &[uuid_pre, uuid_other]).await;
},
port,
vec![
@@ -971,9 +974,7 @@ mod dedicated_worker_tests {
in_test_worker_dedicated(
db.clone(),
async {
listener.find(&uuid_branch).await;
let listener2 = listen_for_completed_jobs(&db).await;
listener2.find(&uuid_default).await;
wait_for_all(listener, &[uuid_branch, uuid_default]).await;
},
port,
vec![wp("test-workspace", "flow/f/system/dedicated_branch_flow")],
@@ -1198,9 +1199,7 @@ mod dedicated_worker_tests {
in_test_worker_dedicated(
db.clone(),
async {
listener.find(&uuid_pre).await;
let listener2 = listen_for_completed_jobs(&db).await;
listener2.find(&uuid_other).await;
wait_for_all(listener, &[uuid_pre, uuid_other]).await;
},
port,
vec![
@@ -1281,9 +1280,7 @@ mod dedicated_worker_tests {
in_test_worker_dedicated(
db.clone(),
async {
listener.find(&uuid_ws1).await;
let listener2 = listen_for_completed_jobs(&db).await;
listener2.find(&uuid_ws2).await;
wait_for_all(listener, &[uuid_ws1, uuid_ws2]).await;
},
port,
vec![
+68 -55
View File
@@ -17,6 +17,9 @@ const SAME_WS_EMAIL: &str = "test@windmill.dev";
const OTHER_WS_EMAIL: &str = "other-ws@windmill.dev";
const NO_WS_EMAIL: &str = "no-ws@windmill.dev";
const GET_EMAIL_CONTENT: &str =
"export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
@@ -38,9 +41,11 @@ async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<
"subgrids": {},
"hiddenInlineScripts": [{
"name": "get_email",
"language": "bun",
"content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }",
"path": "f/test/email_app/get_email"
"inlineScript": {
"language": "bun",
"content": GET_EMAIL_CONTENT,
"path": "f/test/email_app/get_email"
}
}]
},
"policy": {
@@ -82,11 +87,13 @@ async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Res
"value": {
"type": "rawapp",
"css": "",
"inlineScripts": [{
"name": "get_email",
"language": "bun",
"content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }"
}]
"runnables": {
"get_email": {
"name": "get_email",
"type": "inline",
"inlineScript": { "language": "bun", "content": GET_EMAIL_CONTENT }
}
}
},
"policy": {
"execution_mode": "anonymous",
@@ -183,7 +190,39 @@ async fn run_app_flow(
wait_for_job_result(port, token, &job_id).await
}
async fn run_app_inline_script(
fn find_inline_script_id(value: &serde_json::Value) -> Option<i64> {
match value {
serde_json::Value::Object(m) => m
.get("inlineScript")
.and_then(|s| s.get("id"))
.and_then(|id| id.as_i64())
.or_else(|| m.values().find_map(find_inline_script_id)),
serde_json::Value::Array(a) => a.iter().find_map(find_inline_script_id),
_ => None,
}
}
/// Once the app's dependency job writes the deployed lite value, inline code runs only through
/// the `app_script` rows it references, so the run must carry that row's id.
async fn deployed_inline_script_id(port: u16, app_path: &str) -> anyhow::Result<i64> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/apps/get/lite/{}",
port, app_path
);
for _ in 0..300 {
let resp = authed(client().get(&url), SAME_WS_TOKEN).send().await?;
if resp.status().is_success() {
let app = resp.json::<serde_json::Value>().await?;
if let Some(id) = find_inline_script_id(&app["value"]) {
return Ok(id);
}
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
anyhow::bail!("timeout waiting for the deployed app_script id of {app_path}")
}
async fn run_inline_script(
port: u16,
token: &str,
app_path: &str,
@@ -196,9 +235,10 @@ async fn run_app_inline_script(
let mut payload = json!({
"args": {},
"component": "get_email",
"id": deployed_inline_script_id(port, app_path).await?,
"raw_code": {
"language": "bun",
"content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }",
"content": GET_EMAIL_CONTENT,
"path": format!("{}/get_email", app_path)
}
});
@@ -220,42 +260,6 @@ async fn run_app_inline_script(
wait_for_job_result(port, token, &job_id).await
}
async fn run_raw_app_inline_script(
port: u16,
token: &str,
app_path: &str,
force_viewer: bool,
) -> anyhow::Result<String> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}",
port, app_path
);
let mut payload = json!({
"args": {},
"component": "get_email",
"raw_code": {
"language": "bun",
"content": "export function main() { return process.env.WM_END_USER_EMAIL || \"\"; }"
}
});
if force_viewer {
payload["force_viewer_static_fields"] = json!({});
}
let resp = authed(client().post(&url), token)
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!(
"raw app inline script run failed: {} - {}",
resp.status(),
resp.text().await?
);
}
let job_id = resp.text().await?;
wait_for_job_result(port, token, &job_id).await
}
async fn wait_for_job_result(port: u16, token: &str, job_id: &str) -> anyhow::Result<String> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/jobs_u/completed/get_result/{}",
@@ -399,21 +403,21 @@ async fn test_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
create_app_with_inline_script(port, app_path).await?;
// Same workspace user (force_viewer mode works for workspace members)
let result = run_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
let result = run_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
assert_eq!(
result, SAME_WS_EMAIL,
"same workspace user should get their email"
);
// Other workspace user (uses app's anonymous policy + token lookup)
let result = run_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
let result = run_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
assert_eq!(
result, OTHER_WS_EMAIL,
"other workspace user should get their email"
);
// No workspace user (uses app's anonymous policy + token lookup)
let result = run_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
let result = run_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
assert_eq!(
result, NO_WS_EMAIL,
"no workspace user should get their email"
@@ -481,21 +485,21 @@ async fn test_raw_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()
create_raw_app_with_inline_script(port, app_path).await?;
// Same workspace user (force_viewer mode works for workspace members)
let result = run_raw_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
let result = run_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
assert_eq!(
result, SAME_WS_EMAIL,
"same workspace user should get their email"
);
// Other workspace user (uses app's anonymous policy + token lookup)
let result = run_raw_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
let result = run_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
assert_eq!(
result, OTHER_WS_EMAIL,
"other workspace user should get their email"
);
// No workspace user (uses app's anonymous policy + token lookup)
let result = run_raw_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
let result = run_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
assert_eq!(
result, NO_WS_EMAIL,
"no workspace user should get their email"
@@ -550,7 +554,11 @@ async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only(
}))
.send()
.await?;
anyhow::ensure!(resp.status().is_success(), "create app: {}", resp.text().await?);
anyhow::ensure!(
resp.status().is_success(),
"create app: {}",
resp.text().await?
);
let execute = |payload: serde_json::Value| async move {
let resp = authed(
@@ -562,7 +570,11 @@ async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only(
.json(&payload)
.send()
.await?;
anyhow::ensure!(resp.status().is_success(), "execute: {}", resp.text().await?);
anyhow::ensure!(
resp.status().is_success(),
"execute: {}",
resp.text().await?
);
Ok::<_, anyhow::Error>(resp.text().await?.trim_matches('"').parse::<uuid::Uuid>()?)
};
let as_viewer = |job_id: uuid::Uuid| {
@@ -588,7 +600,8 @@ async fn test_app_viewer_data_access_is_stamped_on_inline_runnables_only(
.await?;
assert!(as_viewer(inline).await?);
let path = execute(json!({ "args": {}, "component": "run_script", "path": script_path })).await?;
let path =
execute(json!({ "args": {}, "component": "run_script", "path": script_path })).await?;
assert!(
!as_viewer(path).await?,
"a path runnable connected as the viewer because its policy entry said so"
+606
View File
@@ -437,3 +437,609 @@ async fn multi_file_importer_relock_is_a_no_op_too(db: Pool<Postgres>) -> anyhow
Ok(())
}
/// Plants raw app `f/rel/app` with python runnables of the given contents, one of which imports
/// `f/rel/a`, as a version deployed before runnables were keyed by name: recorded under the
/// empty node id and each holding a lock already. Returns its published version.
async fn plant_raw_app_with(
db: &Pool<Postgres>,
runnables: &[(&str, &str)],
) -> anyhow::Result<i64> {
let runnables: serde_json::Map<String, serde_json::Value> = runnables
.iter()
.map(|(id, content)| {
let runnable = serde_json::json!({ "type": "inline", "inlineScript": {
"content": content, "language": "python3", "lock": "# py: 3.12\n"
}});
(id.to_string(), runnable)
})
.collect();
let app_id: i64 = sqlx::query_scalar(
"INSERT INTO app (workspace_id, path, summary, policy, versions, extra_perms)
VALUES ($1, 'f/rel/app', '', '{}'::jsonb, '{}', '{}'::jsonb) RETURNING id",
)
.bind(W)
.fetch_one(db)
.await?;
let published: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, raw_app)
VALUES ($1, $2, 'test-user', true) RETURNING id",
)
.bind(app_id)
.bind(serde_json::json!({ "runnables": runnables }))
.fetch_one(db)
.await?;
sqlx::query("UPDATE app SET versions = ARRAY[$1::bigint] WHERE id = $2")
.bind(published)
.bind(app_id)
.execute(db)
.await?;
sqlx::query(
"INSERT INTO dependency_map (workspace_id, importer_path, importer_kind, imported_path, importer_node_id)
VALUES ($1, 'f/rel/app', 'app', 'f/rel/a', '')",
)
.bind(W)
.execute(db)
.await?;
Ok(published)
}
/// [`plant_raw_app_with`] a single runnable `a` importing `f/rel/a`.
async fn plant_raw_app(db: &Pool<Postgres>) -> anyhow::Result<i64> {
plant_raw_app_with(db, &[("a", B)]).await
}
async fn app_lock(db: &Pool<Postgres>, version: i64) -> Option<String> {
runnable_lock(db, version, "a").await
}
async fn runnable_lock(db: &Pool<Postgres>, version: i64, runnable: &str) -> Option<String> {
sqlx::query_scalar(
"SELECT value->'runnables'->$2->'inlineScript'->>'lock' FROM app_version WHERE id = $1",
)
.bind(version)
.bind(runnable)
.fetch_one(db)
.await
.unwrap()
}
const WRITTEN_MEANWHILE: &str = "# py: 3.12\n# written meanwhile\n";
/// Once an app dependency job waits on the version row `writer` holds, rewrites that row's
/// lock of runnable `a` to [`WRITTEN_MEANWHILE`] and commits.
async fn write_once_waited_on(
db: &Pool<Postgres>,
mut writer: sqlx::Transaction<'_, Postgres>,
version: i64,
) {
wait_until_blocked(db, "SELECT value FROM app_version%").await;
sqlx::query(
"UPDATE app_version SET value = jsonb_set(value::jsonb,
'{runnables,a,inlineScript,lock}', to_jsonb($2::text))::json WHERE id = $1",
)
.bind(version)
.bind(WRITTEN_MEANWHILE)
.execute(&mut *writer)
.await
.unwrap();
writer.commit().await.unwrap();
}
/// Waits until a statement of this test's database matching `query` waits on a row lock.
async fn wait_until_blocked(db: &Pool<Postgres>, query: &str) {
let deadline = tokio::time::Instant::now() + WAIT_BUDGET;
loop {
let waiting: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM pg_stat_activity
WHERE datname = current_database() AND wait_event_type = 'Lock' AND query LIKE $1)",
)
.bind(query)
.fetch_one(db)
.await
.unwrap();
if waiting {
return;
}
assert!(
tokio::time::Instant::now() < deadline,
"nothing ever waited on a row lock for {query}"
);
tokio::time::sleep(POLL).await;
}
}
/// Queues a dependency job of `f/rel/app` at `version`: the deploy's own when `components` is
/// `None`, else a relative-import relock of those components.
async fn push_app_dependency_job(db: &Pool<Postgres>, version: i64, components: Option<&[&str]>) {
let payload = windmill_common::jobs::JobPayload::AppDependencies {
path: "f/rel/app".to_string(),
version,
debouncing_settings: Default::default(),
};
let mut args = std::collections::HashMap::new();
if let Some(components) = components {
args.insert(
"triggered_by_relative_import".to_string(),
serde_json::value::to_raw_value(&true).unwrap(),
);
args.insert(
"components_to_relock".to_string(),
serde_json::value::to_raw_value(components).unwrap(),
);
}
let (_, tx) = windmill_queue::push(
db,
windmill_queue::PushIsolationLevel::IsolatedRoot(db.clone()),
W,
payload,
windmill_queue::PushArgs { args: &args, extra: None },
"test-user",
"test@windmill.dev",
"u/test-user".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
None,
false,
None,
None,
None,
None,
)
.await
.unwrap();
tx.commit().await.unwrap();
}
/// Logs of every dependency job of `f/rel/app` created after `since`, oldest first.
async fn app_dependency_logs(
db: &Pool<Postgres>,
since: chrono::DateTime<chrono::Utc>,
) -> Vec<String> {
sqlx::query_scalar(
"SELECT COALESCE(l.logs, '') FROM v2_job j
LEFT JOIN job_logs l ON l.job_id = j.id
WHERE j.kind = 'appdependencies' AND j.runnable_path = 'f/rel/app' AND j.created_at > $1
ORDER BY j.created_at",
)
.bind(since)
.fetch_all(db)
.await
.unwrap()
}
/// A raw app runnable importing a script is relocked when that script's dependencies change,
/// and the relock lands in the version the app is served from rather than in a new one: a raw
/// app's bundle is stored under its version id.
#[sqlx::test(fixtures("base"))]
async fn raw_app_relock_rewrites_the_published_version(db: Pool<Postgres>) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
let published = plant_raw_app(&db).await?;
in_test_worker(
&db,
async {
client
.create_script(W, &py_script("f/rel/a", A, None))
.await
.unwrap();
wait_for_jobs(&db, &mut completed, 1).await;
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
client
.create_script(
W,
&py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
wait_for_jobs(&db, &mut completed, 2).await;
let (versions, version_rows): (Vec<i64>, i64) = sqlx::query_as(
"SELECT versions, (SELECT count(*) FROM app_version WHERE app_id = app.id)
FROM app WHERE workspace_id = $1 AND path = 'f/rel/app'",
)
.bind(W)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(versions, [published], "the relock publishes no other version");
assert_eq!(version_rows, 1, "the relock mints no version");
let lock = app_lock(&db, published).await;
assert!(
lock.as_deref().unwrap_or("").contains("tiny"),
"the published version carries the new lock: {lock:?}"
);
let node_ids: Vec<String> = sqlx::query_scalar(
"SELECT importer_node_id FROM dependency_map
WHERE workspace_id = $1 AND importer_path = 'f/rel/app' AND imported_path = 'f/rel/a'",
)
.bind(W)
.fetch_all(&db)
.await
.unwrap();
assert_eq!(node_ids, ["a"], "the runnable is recorded under its name");
},
port,
)
.await;
Ok(())
}
/// A relock rewrites the one row every other relock of the app, and the dependency job of the
/// deploy that published it, write too. When that row changed after the relock read it, the
/// relock is discarded and queued again on what is there now, rather than overwriting it.
#[sqlx::test(fixtures("base"))]
async fn raw_app_relock_racing_a_write_requeues(db: Pool<Postgres>) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
let published = plant_raw_app(&db).await?;
in_test_worker(
&db,
async {
client
.create_script(W, &py_script("f/rel/a", A, None))
.await
.unwrap();
wait_for_jobs(&db, &mut completed, 1).await;
let mut writer = db.begin().await.unwrap();
sqlx::query("SELECT 1 FROM app_version WHERE id = $1 FOR NO KEY UPDATE")
.bind(published)
.execute(&mut *writer)
.await
.unwrap();
let since = chrono::Utc::now();
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
client
.create_script(
W,
&py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
write_once_waited_on(&db, writer, published).await;
// The leaf's own job, the discarded relock and the one it queued.
wait_for_jobs(&db, &mut completed, 3).await;
let logs = app_dependency_logs(&db, since).await;
assert_eq!(logs.len(), 2, "one relock and its requeue: {logs:?}");
assert!(
logs[0].contains("discarding them"),
"the first relock was discarded: {}",
logs[0]
);
let lock = app_lock(&db, published).await;
assert!(
lock.as_deref().unwrap_or("").contains("tiny"),
"the queued relock regenerated the lock over the concurrent write: {lock:?}"
);
},
port,
)
.await;
Ok(())
}
/// The dependency job of the deploy that published a version writes the row relocks write
/// too, so it backs off the same way when that row changed after it read it.
#[sqlx::test(fixtures("base"))]
async fn app_deploy_dependency_job_racing_a_write_requeues(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let (_client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
let published = plant_raw_app(&db).await?;
in_test_worker(
&db,
async {
let mut writer = db.begin().await.unwrap();
sqlx::query("SELECT 1 FROM app_version WHERE id = $1 FOR NO KEY UPDATE")
.bind(published)
.execute(&mut *writer)
.await
.unwrap();
let since = chrono::Utc::now();
push_app_dependency_job(&db, published, None).await;
write_once_waited_on(&db, writer, published).await;
// The deploy's job, and the relock it queued on backing off.
wait_for_jobs(&db, &mut completed, 2).await;
let logs = app_dependency_logs(&db, since).await;
assert_eq!(logs.len(), 2, "the deploy's job and its requeue: {logs:?}");
assert!(
logs[0].contains("discarding them"),
"the deploy's job backed off: {}",
logs[0]
);
},
port,
)
.await;
Ok(())
}
/// A raw app runnable ships the runnables it imports as modules, and its lock covers what they
/// import, so a change to a script one of them imports relocks the importer too.
#[sqlx::test(fixtures("base"))]
async fn raw_app_relock_follows_imports_of_imported_runnables(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
let published = plant_raw_app_with(
&db,
&[
("a", "from .b import g\n\ndef main():\n return g()\n"),
("b", "from f.rel.a import main as a\n\ndef g():\n return a()\n\ndef main():\n return g()\n"),
],
)
.await?;
in_test_worker(
&db,
async {
// Its relock of the app records what each runnable depends on.
client
.create_script(W, &py_script("f/rel/a", A, None))
.await
.unwrap();
wait_for_jobs(&db, &mut completed, 1).await;
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
client
.create_script(
W,
&py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
wait_for_jobs(&db, &mut completed, 2).await;
for runnable in ["a", "b"] {
let lock = runnable_lock(&db, published, runnable).await;
assert!(
lock.as_deref().unwrap_or("").contains("tiny"),
"{runnable} carries the new lock: {lock:?}"
);
}
// A rebuilt dependency map records the same dependencies.
async fn app_edges(db: &Pool<Postgres>) -> Vec<(String, String)> {
sqlx::query_as(
"SELECT importer_node_id, imported_path FROM dependency_map
WHERE workspace_id = $1 AND importer_path = 'f/rel/app' ORDER BY 1, 2",
)
.bind(W)
.fetch_all(db)
.await
.unwrap()
}
let recorded = app_edges(&db).await;
assert!(rebuild_dmap(&client).await);
assert_eq!(app_edges(&db).await, recorded);
},
port,
)
.await;
Ok(())
}
/// A relock superseded by a deploy relocks the version that deploy published, every component
/// of it: the deploy may rename the component the relock was for, keeping its stale lock.
#[sqlx::test(fixtures("base"))]
async fn superseded_app_relock_relocks_the_renamed_component(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
plant_raw_app(&db).await?;
in_test_worker(
&db,
async {
client
.create_script(W, &py_script("f/rel/a", A, None))
.await
.unwrap();
wait_for_jobs(&db, &mut completed, 1).await;
let mut deploy = db.begin().await.unwrap();
sqlx::query(
"SELECT 1 FROM app WHERE workspace_id = $1 AND path = 'f/rel/app' FOR NO KEY UPDATE",
)
.bind(W)
.execute(&mut *deploy)
.await
.unwrap();
let a_hash = live(&versions(&db, "f/rel/a").await).hash;
client
.create_script(
W,
&py_script("f/rel/a", A_WITH_TINY, Some(format!("{a_hash:016x}"))),
)
.await
.unwrap();
// The relock has generated its lock for `a` and waits on the app this deploy holds,
// which publishes `a` renamed to `b` with its old lock.
wait_until_blocked(&db, "SELECT versions[array_upper(versions, 1)] FROM app%").await;
let renamed: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, raw_app)
SELECT app_id, json_build_object('runnables',
json_build_object('b', value->'runnables'->'a')), created_by, true
FROM app_version WHERE id = (SELECT versions[array_upper(versions, 1)] FROM app
WHERE workspace_id = $1 AND path = 'f/rel/app')
RETURNING id",
)
.bind(W)
.fetch_one(&mut *deploy)
.await
.unwrap();
sqlx::query(
"UPDATE app SET versions = array_append(versions, $2)
WHERE workspace_id = $1 AND path = 'f/rel/app'",
)
.bind(W)
.bind(renamed)
.execute(&mut *deploy)
.await
.unwrap();
deploy.commit().await.unwrap();
// The leaf's own job, the superseded relock and the one it queued.
wait_for_jobs(&db, &mut completed, 3).await;
let lock = runnable_lock(&db, renamed, "b").await;
assert!(
lock.as_deref().unwrap_or("").contains("tiny"),
"the renamed component carries the new lock: {lock:?}"
);
},
port,
)
.await;
Ok(())
}
/// A relock queued before a deploy runs on the version that deploy published, every component
/// of it: the deploy may rename the component the relock was queued for, keeping its stale lock.
#[sqlx::test(fixtures("base"))]
async fn app_relock_queued_before_a_deploy_relocks_the_renamed_component(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
let queued_for = plant_raw_app(&db).await?;
in_test_worker(
&db,
async {
client
.create_script(W, &py_script("f/rel/a", A_WITH_TINY, None))
.await
.unwrap();
wait_for_jobs(&db, &mut completed, 1).await;
let renamed: i64 = sqlx::query_scalar(
"INSERT INTO app_version (app_id, value, created_by, raw_app)
SELECT app_id, $2, created_by, true FROM app_version WHERE id = $1
RETURNING id",
)
.bind(queued_for)
.bind(
serde_json::json!({ "runnables": { "c": { "type": "inline", "inlineScript": {
"content": B, "language": "python3", "lock": "# py: 3.12\n"
}}}}),
)
.fetch_one(&db)
.await
.unwrap();
sqlx::query(
"UPDATE app SET versions = array_append(versions, $2)
WHERE workspace_id = $1 AND path = 'f/rel/app'",
)
.bind(W)
.bind(renamed)
.execute(&db)
.await
.unwrap();
push_app_dependency_job(&db, queued_for, Some(&["a"])).await;
wait_for_jobs(&db, &mut completed, 1).await;
let lock = runnable_lock(&db, renamed, "c").await;
assert!(
lock.as_deref().unwrap_or("").contains("tiny"),
"the renamed component carries the new lock: {lock:?}"
);
},
port,
)
.await;
Ok(())
}
/// A relock of every component regenerates each lock. A deploy that kept its editor's stale
/// lock still records the imports' current lock hashes, so the skip check would keep it.
#[sqlx::test(fixtures("base"))]
async fn app_relock_of_every_component_regenerates_a_kept_stale_lock(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
std::env::set_var("DEPENDENCY_JOB_DEBOUNCE_DELAY", "0");
let (client, port, _s) = init_client(db.clone()).await;
let mut completed = listen_for_completed_jobs(&db).await;
let published = plant_raw_app(&db).await?;
in_test_worker(
&db,
async {
client
.create_script(W, &py_script("f/rel/a", A_WITH_TINY, None))
.await
.unwrap();
wait_for_jobs(&db, &mut completed, 1).await;
// The stale lock a deploy kept, under the hashes it recorded.
sqlx::query(
"UPDATE app_version SET value = jsonb_set(value::jsonb,
'{runnables,a,inlineScript,lock}', '\"# py: 3.12\\n\"')::json WHERE id = $1",
)
.bind(published)
.execute(&db)
.await
.unwrap();
let every = windmill_dep_map::trigger_dependents::RELOCK_ALL_APP_COMPONENTS;
push_app_dependency_job(&db, published, Some(&[every])).await;
wait_for_jobs(&db, &mut completed, 1).await;
let lock = app_lock(&db, published).await;
assert!(
lock.as_deref().unwrap_or("").contains("tiny"),
"the kept stale lock is regenerated: {lock:?}"
);
},
port,
)
.await;
Ok(())
}
+16
View File
@@ -381,6 +381,22 @@ async fn test_wm_token_is_confined_to_its_workspace(db: Pool<Postgres>) -> anyho
"WM_TOKEN must still record its own tutorial progress: {}",
resp.text().await?
);
// Every Teams notification a job sends posts here. An empty body is a 422 from the
// handler's extractor, so a 403 means confinement refused it. The real route is only
// mounted in an EE build.
#[cfg(all(feature = "enterprise", feature = "private"))]
{
let resp = authed(client().post(format!("{api}/teams/activities")), &user_wm)
.json(&json!({}))
.send()
.await?;
assert_eq!(
resp.status(),
422,
"WM_TOKEN must still reach the Teams message route: {}",
resp.text().await?
);
}
// 11. ...and the caller-scoped reads deliberately left out of it, each because it
// names another workspace or the identity's credentials.
+218
View File
@@ -4698,6 +4698,224 @@ async fn test_run_wait_result_early_return_with_failure_module(
Ok(())
}
/// An early-return node inside a branch to one resolves while the flow is still running when its
/// branch is taken, and falls back to the flow's final result when it is not. A nested branch to
/// all resolves to its list, though its status is not in the root flow's modules.
#[cfg(all(feature = "deno_core", feature = "quickjs"))]
#[sqlx::test(fixtures("base"))]
async fn test_run_wait_result_early_return_inside_branch_one(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let returns = |v: &str, sleep_ms: u64| {
json!({
"type": "rawscript",
"language": "deno",
"input_transforms": {},
"content": format!(
"export async function main() {{ await new Promise((r) => setTimeout(r, {sleep_ms})); return '{v}' }}"
),
})
};
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"id": "a",
"value": {
"type": "branchone",
"branches": [{ "expr": "flow_input.take", "modules": [
{
"id": "b",
"value": {
"type": "branchone",
"branches": [],
"default": [{ "id": "c", "value": returns("c", 0) }],
},
},
{
"id": "e",
"value": {
"type": "branchall",
"branches": [{ "modules": [{ "id": "f", "value": returns("f", 0) }] }],
},
},
] }],
"default": [],
},
},
{ "id": "d", "value": returns("d", 3000) },
],
}))
.unwrap();
for (early_return, take, expected) in [
("c", true, json!("c")),
("c", false, json!("d")),
("e", true, json!(["f"])),
] {
let mut flow = flow.clone();
flow.early_return = Some(early_return.to_string());
let id =
RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
.arg("take", json!(take))
.push(&db)
.await;
// The completion check runs inside the worker's lifetime: the worker finishes `d` before
// quitting, so checking afterwards would always see the flow completed.
let (resp, flow_completed) = in_test_worker(
windmill_common::worker::Connection::Sql(db.clone()),
async {
let resp = windmill_api::jobs::run_wait_result(
&db,
id,
"test-workspace",
Some(early_return.to_string()),
false,
"test-user",
)
.await
.unwrap();
let completed: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM v2_job_completed WHERE id = $1)",
)
.bind(id)
.fetch_one(&db)
.await
.unwrap();
(resp, completed)
},
port,
)
.await;
// Only the taken branch returns before `d` finishes sleeping.
assert_eq!(flow_completed, !take, "{early_return}, take = {take}");
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await?;
let body: serde_json::Value = serde_json::from_slice(&bytes)?;
assert_eq!(expected, body, "{early_return}, take = {take}");
}
Ok(())
}
/// The step whose stream `run_and_stream` follows: the early-return step inside branches to one
/// and to all, the last step when the early-return step's branch is not taken, and neither when
/// the early-return step ran inside a loop. Only a deployed flow reads `early_return` here.
#[cfg(all(feature = "deno_core", feature = "quickjs"))]
#[sqlx::test(fixtures("base"))]
async fn test_early_return_stream_job_inside_branches(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let streams = |v: &str| {
json!({
"type": "rawscript",
"language": "deno",
"input_transforms": {},
"content": format!(
"export async function main() {{ console.log('WM_STREAM: {v}'); await new Promise((r) => setTimeout(r, 1000)); return '{v}' }}"
),
})
};
let modules = json!([
{
"id": "a",
"value": {
"type": "branchone",
"branches": [{ "expr": "flow_input.take", "modules": [
{
"id": "b",
"value": {
"type": "branchall",
"branches": [{ "modules": [{ "id": "c", "value": streams("c") }] }],
},
},
{
"id": "l",
"value": {
"type": "forloopflow",
"iterator": { "type": "javascript", "expr": "[1]" },
"skip_failures": false,
// Two steps, so the loop body is not inlined into the iteration job.
"modules": [
{ "id": "j", "value": streams("j") },
{ "id": "m", "value": { "type": "identity" } },
],
},
},
] }],
"default": [],
},
},
{ "id": "d", "value": streams("d") },
]);
for (version, early_return, take, expected) in [
(1, "c", true, Some("c")),
(2, "c", false, Some("d")),
(3, "j", true, None),
] {
let path = format!("f/system/early_return_stream_{version}");
let value = json!({ "modules": modules, "early_return": early_return });
sqlx::query(
"INSERT INTO flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ('test-workspace', '', '', $1, ARRAY[$2]::bigint[], '{}'::jsonb, $3, 'system')",
)
.bind(&path)
.bind(version)
.bind(&value)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, 'test-workspace', $2, '{}'::jsonb, $3, 'system')",
)
.bind(version)
.bind(&path)
.bind(&value)
.execute(&db)
.await?;
RunJob::from(JobPayload::FlowDependencies {
path: path.clone(),
dedicated_worker: None,
version,
debouncing_settings: Default::default(),
})
.run_until_complete(&db, false, port)
.await;
let completed = RunJob::from(JobPayload::Flow {
path,
dedicated_worker: None,
apply_preprocessor: false,
labels: None,
version,
job_token_scopes: None,
})
.arg("take", json!(take))
.run_until_complete(&db, false, port)
.await;
let stream_step: Option<String> = sqlx::query_scalar(
"SELECT flow_step_id FROM v2_job
WHERE id = (SELECT (flow_status->>'stream_job')::uuid FROM v2_job_completed WHERE id = $1)",
)
.bind(completed.id)
.fetch_optional(&db)
.await?;
assert_eq!(
expected.map(String::from),
stream_step,
"{early_return}, take = {take}"
);
}
Ok(())
}
/// `recover: true` from the error handler of a step that fails inside a loop turns the flow
/// green, as it does for a top-level step, without changing which steps run: a loop that
/// stops at a failed iteration still stops there, one that skips failures still carries on.
+17
View File
@@ -30,6 +30,11 @@ pub fn effective_reasoning_effort<'a>(model: &str, effort: Option<&'a str>) -> O
}
}
/// Whether Chat Completions refuses function tools while this model reasons.
pub(crate) fn completions_rejects_tools_with_reasoning(model: &str) -> bool {
reasoning_rule(model).is_some_and(|rule| rule.completions_tools_need_off)
}
/// Whether a request with function tools must send the off sentinel on Chat Completions:
/// the model refuses tools there while it reasons, and does accept being turned off.
pub(crate) fn completions_tools_need_reasoning_off(model: &str) -> bool {
@@ -208,6 +213,12 @@ pub fn create_query_builder(
AIProvider::AzureFoundry if AIProvider::is_anthropic_model(model) => Box::new(
AnthropicQueryBuilder::new(AIProvider::AzureFoundry, AIPlatform::Standard),
),
// Foundry serves its OpenAI models on `/openai/v1/responses` too. `/chat/completions`
// takes function tools from these only with reasoning off, which gpt-6.1-sol and
// gpt-6-astra cannot do at all.
AIProvider::AzureFoundry if completions_rejects_tools_with_reasoning(model) => {
Box::new(OpenAIQueryBuilder::new(AIProvider::AzureFoundry))
}
// Only for forwarding through the API proxy, which is bearer auth on `<base>/<path>` as
// TypeSafe and Cloudflare expect. Neither serves a chat route for its decision models, and
// `run_agent` refuses both before a chat request could be built.
@@ -408,6 +419,12 @@ mod parity_tests {
"claude-sonnet-5",
"v1/messages",
),
case(
AIProvider::AzureFoundry,
"https://example.services.ai.azure.com",
"gpt-6.1-sol",
"responses",
),
case(
AIProvider::AzureOpenAI,
"https://example.openai.azure.com/openai",
+20 -7
View File
@@ -73,7 +73,12 @@ pub fn check_route_access(
.to_string(),
));
}
if required_domain == ScopeDomain::Users && suffix != "users/whoami" {
if required_domain == ScopeDomain::Users {
// Whoami returns what the bundle already has in `window.ctx`, so it
// needs no declared scope.
if suffix == "users/whoami" {
return Ok(());
}
return Err(Error::PermissionDenied(
"Access denied. A raw app frontend SDK token can only read the viewer's own identity."
.to_string(),
@@ -418,8 +423,9 @@ pub fn guest_session_scopes(app_path: &str) -> windmill_common::error::Result<Ve
])
}
/// Sentinel in raw-app SDK tokens. Grants nothing; `check_route_access` uses it
/// to narrow the declared scopes to what the viewer's prompt promised.
/// Sentinel in raw-app SDK tokens. `check_route_access` uses it to narrow the
/// declared scopes to what the viewer's prompt promised; the only thing it grants
/// is the bearer's own `users/whoami`.
pub const RAW_APP_SDK_SENTINEL: &str = "raw_app_sdk";
pub fn has_raw_app_sdk_sentinel(scopes: Option<&[String]>) -> bool {
@@ -462,8 +468,9 @@ fn app_embed_route_denied(domain: ScopeDomain, suffix: &str) -> bool {
}
}
/// App routes a running app uses: its own definition (`apps/get/p/<path>`, further
/// path-scoped by `apps:read:<path>`) and the public app-serving endpoints
/// App routes a running app uses: its own definition (`apps/get/p/<path>`, or
/// `apps/get/lite/<path>` that the viewer runs from, both further path-scoped by
/// `apps:read:<path>`) and the public app-serving endpoints
/// (`apps_u/*`: public_app, public_resource, get_data, and the path-taking
/// `execute_component` / `download_s3_file`, which re-check `apps:run|read:<path>`
/// in their handlers so they stay confined to this app). Everything else in the
@@ -477,7 +484,9 @@ fn app_embed_apps_route_allowed(suffix: &str) -> bool {
if suffix.starts_with("apps_u/embed_token") {
return false;
}
suffix.starts_with("apps/get/p/") || suffix.starts_with("apps_u/")
suffix.starts_with("apps/get/p/")
|| suffix.starts_with("apps/get/lite/")
|| suffix.starts_with("apps_u/")
}
/// Routes a guest session is denied: the app-embed allowlist, plus the embed-token
@@ -937,11 +946,15 @@ fn is_global_read_open_to_job_token(route_path: &str) -> bool {
/// not send.
/// - tutorial progress upserts a UI bitfield keyed on the caller's own email. Its path
/// serves a `GET` too, which the read list above carries.
const GLOBAL_WRITES_OPEN_TO_JOB_TOKEN: [&str; 4] = [
/// - a Teams message from a job (the Teams error, recovery and success handlers) posts as
/// the instance-wide bot. The handler refuses a job token whose conversation is not a
/// channel of the team connected to the job's workspace.
const GLOBAL_WRITES_OPEN_TO_JOB_TOKEN: [&str; 5] = [
"/api/settings/test_object_storage_config",
"/api/workspaces/exists",
"/api/schedules/preview",
"/api/users/tutorial_progress",
"/api/teams/activities",
];
/// Confines a job token (`$WM_TOKEN`) to routes that name a workspace. It is minted
@@ -810,7 +810,7 @@ async fn a_data_table_under_roles_is_cloned_only_with_its_grants(
// The copy an admin asks for goes ahead in an edition that replays grants, and is refused
// before any database exists in one that does not. The fixture's database does not exist, so
// the dump fails either way — and leaves neither a database nor a fork behind.
// reading it fails either way — and leaves neither a database nor a fork behind.
let resp = fork(
"SECRET_TOKEN",
json!({"name": "main", "new_dbname": target, "fork_behavior": "schema_only"}),
@@ -820,7 +820,7 @@ async fn a_data_table_under_roles_is_cloned_only_with_its_grants(
assert_ne!(resp.status(), 200);
let body = resp.text().await?;
#[cfg(all(feature = "private", feature = "enterprise"))]
assert!(body.contains("pg_dump"), "{body}");
assert!(body.contains("\"dt_main\" does not exist"), "{body}");
#[cfg(not(all(feature = "private", feature = "enterprise")))]
assert!(body.contains("Enterprise Edition"), "{body}");
assert!(!database_exists(&db, target).await?);
@@ -981,6 +981,104 @@ async fn a_data_table_under_roles_is_not_copied_without_its_grants(
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))]
async fn data_tables_on_one_database_are_copied_together(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let shared = json!({"database": {"resource_type": "instance", "resource_path": "dt_shared"}});
sqlx::query(
"UPDATE workspace_settings SET datatable = jsonb_set(jsonb_set(datatable,
'{datatables,a}', $1), '{datatables,b}', $1) WHERE workspace_id = 'test-workspace'",
)
.bind(&shared)
.execute(&db)
.await?;
// One copy is made for both, so it cannot be made two ways.
let resp = authed(
client().post(format!(
"http://localhost:{port}/api/w/test-workspace/workspaces/create_fork"
)),
"SECRET_TOKEN",
)
.json(
&json!({"id": "wm-fork-pair", "name": "pair", "forked_datatables": [
{"name": "a", "new_dbname": "wm_fork_pair__a", "fork_behavior": "schema_only"},
{"name": "b", "new_dbname": "wm_fork_pair__b", "fork_behavior": "schema_and_data"}
]}),
)
.send()
.await?;
assert_eq!(resp.status(), 400);
assert!(resp.text().await?.contains("same database"));
assert!(!database_exists(&db, "wm_fork_pair__a").await?);
// A fork's entries sharing a copy each count as a use of it: dropped together, neither blocks
// the other.
let copy = json!({"database": {"resource_type": "instance", "resource_path": "wm_fork_dt__a"},
"forked_from": {"schema": {}}});
sqlx::query(
"UPDATE workspace_settings SET datatable = jsonb_build_object('datatables',
jsonb_build_object('a', $1::jsonb, 'b', $1::jsonb)) WHERE workspace_id = 'wm-fork-dt'",
)
.bind(&copy)
.execute(&db)
.await?;
sqlx::query(
"UPDATE global_settings SET value = jsonb_set(value, '{databases,wm_fork_dt__a}',
'{\"tag\": \"datatable\", \"workspace_id\": \"test-workspace\"}')
WHERE name = 'custom_instance_pg_databases'",
)
.execute(&db)
.await?;
let errors: Vec<String> = authed(
client().post(format!(
"http://localhost:{port}/api/w/wm-fork-dt/workspaces/drop_forked_datatable_databases"
)),
"SECRET_TOKEN",
)
.json(&json!({"datatable_names": ["a", "b"]}))
.send()
.await?
.json()
.await?;
assert!(errors.is_empty(), "{errors:?}");
let left: Value = sqlx::query_scalar(
"SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = 'wm-fork-dt'",
)
.fetch_one(&db)
.await?;
assert_eq!(left, json!({}));
// Clones on one resource share its copy too: dropping it for one would take the other's data.
let copy = json!({"database": {"resource_type": "postgresql", "resource_path": "u/test-user/pg"},
"forked_from": {"schema": {}}});
sqlx::query(
"UPDATE workspace_settings SET datatable = jsonb_build_object('datatables',
jsonb_build_object('a', $1::jsonb, 'b', $1::jsonb)) WHERE workspace_id = 'wm-fork-dt'",
)
.bind(&copy)
.execute(&db)
.await?;
let errors: Vec<String> = authed(
client().post(format!(
"http://localhost:{port}/api/w/wm-fork-dt/workspaces/drop_forked_datatable_databases"
)),
"SECRET_TOKEN",
)
.json(&json!({"datatable_names": ["a"]}))
.send()
.await?
.json()
.await?;
assert!(
errors.len() == 1 && errors[0].contains("still used by data tables 'b'"),
"{errors:?}"
);
Ok(())
}
/// The fork's `forked_from` for one of its entries; `None` whether it is absent or `null`.
async fn forked_from_of(db: &Pool<Postgres>, name: &str) -> Option<Value> {
sqlx::query_scalar::<_, Option<Value>>(
@@ -4,10 +4,10 @@ use sqlx::{Pool, Postgres};
use windmill_common::workspaces::invalidate_operator_rights_cache;
use windmill_test_utils::*;
/// Every test here must be `#[serial]`. The rights cache is process-global and keyed by workspace
/// id alone, while `sqlx::test` gives each test its own database under this same id — so two
/// running at once can answer each other's reads from one cached entry, and a withdrawal in one
/// database reads as granted in the other.
/// Every test here must be `#[serial]`, and start by dropping the cache entry. The rights cache is
/// process-global and keyed by workspace id alone, while `sqlx::test` gives each test its own
/// database under this same id — so two running at once can answer each other's reads from one
/// cached entry, and a withdrawal a test ends on reads as withdrawn in the next one's database.
const WS: &str = "test-workspace";
fn operator_client() -> reqwest::Client {
@@ -53,6 +53,7 @@ async fn set_settings(api: &str, body: serde_json::Value) -> anyhow::Result<u16>
#[serial]
async fn test_operator_manage_rights(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_operator_rights_cache(WS);
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
@@ -145,6 +146,7 @@ async fn test_manage_triggers_covers_a_route_outside_the_shared_handler(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_operator_rights_cache(WS);
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
@@ -213,3 +215,193 @@ async fn test_manage_triggers_covers_a_route_outside_the_shared_handler(
Ok(())
}
/// A draft is what the AI chat writes before it deploys, so an operator holding a manage right
/// may draft that kind, and only while they hold it. Builder rights are left off throughout: they
/// open flow drafts, and would otherwise be what admits these.
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
#[serial]
async fn test_manage_rights_admit_matching_drafts(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_operator_rights_cache(WS);
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
let c = operator_client();
let save = async |kind: &str, path: &str| -> anyhow::Result<(u16, String)> {
let resp = c
.post(format!("{api}/drafts/update/{kind}/{path}"))
.json(&json!({ "value": { "path": path } }))
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
};
let listed = async || -> anyhow::Result<serde_json::Value> {
let resp = c.get(format!("{api}/drafts/list")).send().await?;
assert_eq!(resp.status(), 200);
Ok(resp.json().await?)
};
let schedule_list_has_draft = async || -> anyhow::Result<bool> {
let resp = c
.get(format!("{api}/schedules/list?include_draft_only=true"))
.send()
.await?;
assert_eq!(resp.status(), 200);
let rows: Vec<serde_json::Value> = resp.json().await?;
Ok(rows.iter().any(|r| r["path"] == "u/operator/sched_draft"))
};
let resp = c
.post(format!("{api}/drafts/update/trigger_schedule/u/operator/sched_draft"))
.json(&json!({ "value": {
"path": "u/operator/sched_draft",
"schedule": "0 0 12 * * *",
"timezone": "UTC",
"script_path": "f/system/hello",
"is_flow": false,
"args": {},
"enabled": false
} }))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
assert!(schedule_list_has_draft().await?);
let (status, body) = save("trigger_http", "u/operator/http_draft").await?;
assert_eq!(status, 200, "{body}");
// The rights name schedules and triggers, nothing else.
let (status, body) = save("script", "u/operator/script_draft").await?;
assert_eq!(status, 403, "{body}");
let drafts = listed().await?;
assert_eq!(drafts.as_array().map(Vec::len), Some(2), "{drafts}");
assert_eq!(
set_settings(&api, json!({"manage_schedules": false})).await?,
200
);
let (status, body) = save("trigger_schedule", "u/operator/sched_draft").await?;
assert_eq!(status, 403, "{body}");
// The list follows the right too, as the app list follows `builder_apps`.
assert!(!schedule_list_has_draft().await?);
let (status, body) = save("trigger_http", "u/operator/http_draft").await?;
assert_eq!(status, 200, "{body}");
assert_eq!(
set_settings(&api, json!({"manage_triggers": false})).await?,
200
);
let (status, body) = save("trigger_http", "u/operator/http_draft").await?;
assert_eq!(status, 403, "{body}");
// Still listed, as their own, but no longer writable.
let drafts = listed().await?;
assert_eq!(drafts.as_array().map(Vec::len), Some(2), "{drafts}");
assert!(
drafts
.as_array()
.unwrap()
.iter()
.all(|d| d["can_write"] == json!(false)),
"{drafts}"
);
Ok(())
}
/// Operators create resources and variables on their pages with no operator setting in the way,
/// so they may draft them in any workspace — even one that withdrew every manage right — and
/// deploying a secret variable draft goes through the create route like a direct write.
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
#[serial]
async fn test_operators_draft_resources_and_variables(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
invalidate_operator_rights_cache(WS);
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
let c = operator_client();
assert_eq!(
set_settings(
&api,
json!({"manage_schedules": false, "manage_triggers": false})
)
.await?,
200
);
let save = async |kind: &str, path: &str, value: serde_json::Value| {
let resp = c
.post(format!("{api}/drafts/update/{kind}/{path}"))
.json(&json!({ "value": value }))
.send()
.await?;
anyhow::Ok((resp.status().as_u16(), resp.text().await?))
};
let (status, body) = save(
"resource",
"u/operator/res_draft",
json!({ "path": "u/operator/res_draft", "args": {}, "resource_type": "object" }),
)
.await?;
assert_eq!(status, 200, "{body}");
let (status, body) = save(
"variable",
"u/operator/secret_draft",
json!({ "path": "u/operator/secret_draft", "variable": { "value": "hunter2", "is_secret": true } }),
)
.await?;
assert_eq!(status, 200, "{body}");
let (status, body) = save("script", "u/operator/script_draft", json!({})).await?;
assert_eq!(status, 403, "{body}");
// The Resources and Variables pages list a draft that has never been deployed.
for (route, path) in [
("resources", "u/operator/res_draft"),
("variables", "u/operator/secret_draft"),
] {
let listed: serde_json::Value = c
.get(format!("{api}/{route}/list?include_draft_only=true"))
.send()
.await?
.json()
.await?;
assert!(
listed
.as_array()
.unwrap()
.iter()
.any(|r| r["path"] == path && r["draft_only"] == json!(true)),
"{route}: {listed}"
);
}
// Deploying a draft sends its stored value, the `$encrypted:` marker, to the create route.
let stored: String = sqlx::query_scalar(
"SELECT value::jsonb->'variable'->>'value' FROM draft
WHERE workspace_id = $1 AND path = 'u/operator/secret_draft'",
)
.bind(WS)
.fetch_one(&db)
.await?;
assert!(stored.starts_with("$encrypted:"), "{stored}");
let resp = c
.post(format!("{api}/variables/create"))
.json(&json!({
"path": "u/operator/secret_draft",
"value": stored,
"is_secret": true,
"description": "",
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
let resp = c
.get(format!("{api}/variables/get_value/u/operator/secret_draft"))
.send()
.await?;
assert_eq!(resp.json::<String>().await?, "hunter2");
Ok(())
}
@@ -0,0 +1,135 @@
//! A preview's `on_behalf_of` runs request-supplied code as another account, so it must
//! change the job's identity for admins and be refused for everyone else.
use serde_json::json;
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_common::auth::create_jwt_token;
use windmill_common::db::Authed;
use windmill_test_utils::*;
/// `route` is under `/api/w/test-workspace/`.
async fn post(port: u16, token: &str, route: &str, body: serde_json::Value) -> (u16, String) {
let resp = reqwest::Client::new()
.post(format!("http://localhost:{port}/api/w/test-workspace/{route}"))
.header("Authorization", format!("Bearer {token}"))
.json(&body)
.send()
.await
.unwrap();
(resp.status().as_u16(), resp.text().await.unwrap())
}
async fn identity(db: &Pool<Postgres>, id: &str) -> (String, String) {
sqlx::query_as("SELECT permissioned_as, permissioned_as_email FROM v2_job WHERE id = $1")
.bind(Uuid::parse_str(id).unwrap())
.fetch_one(db)
.await
.unwrap()
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_preview_on_behalf_of(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let script = |obo: &str| {
json!({
"content": "export function main() { return 1; }",
"language": "deno",
"args": {},
"on_behalf_of": obo,
})
};
let flow = |obo: &str| json!({ "value": {"modules": []}, "args": {}, "on_behalf_of": obo });
// The app editor's preview of an inline runnable.
let app = |obo: &str| {
json!({
"args": {},
"component": "c",
"raw_code": { "language": "deno", "content": "export function main() { return 1; }" },
"force_viewer_static_fields": {},
"on_behalf_of": obo,
})
};
let app_route = "apps_u/execute_component/u/test-user/app";
for (route, body) in [
("jobs/run/preview", script("test2@windmill.dev")),
("jobs/run/preview_flow", flow("u/test-user-2")),
(app_route, app("u/test-user-2")),
] {
let (status, id) = post(port, "SECRET_TOKEN", route, body).await;
assert!((200..300).contains(&status), "{route}: {status} {id}");
assert_eq!(
identity(&db, &id).await,
(
"u/test-user-2".to_string(),
"test2@windmill.dev".to_string()
),
"{route}"
);
let logs: String = sqlx::query_scalar("SELECT logs FROM job_logs WHERE job_id = $1")
.bind(Uuid::parse_str(&id).unwrap())
.fetch_one(&db)
.await?;
assert!(
logs.starts_with("Preview run on behalf of u/test-user-2 (test2@windmill.dev), requested by test@windmill.dev"),
"{route}: {logs}"
);
}
for (route, body) in [
("jobs/run/preview", script("test@windmill.dev")),
("jobs/run/preview_flow", flow("u/test-user")),
(app_route, app("u/test-user")),
] {
let (status, body) = post(port, "SECRET_TOKEN_2", route, body).await;
assert_eq!(status, 403, "{route}: {body}");
}
let (status, id) = post(port, "SECRET_TOKEN_2", "jobs/run/preview", script("u/test-user-2")).await;
assert_eq!(status, 201, "{id}");
assert_eq!(identity(&db, &id).await.0, "u/test-user-2");
// Reading data as the end user is the direct caller's own choice. A job token's end user is
// whoever ran that job, so a job's code must not be able to ask for it.
set_jwt_secret().await;
let job_token = create_jwt_token(
Authed {
email: "test@windmill.dev".to_string(),
username: "test-user".to_string(),
is_admin: true,
is_operator: false,
groups: vec![],
folders: vec![],
scopes: None,
token_prefix: None,
},
"test-workspace",
3600,
Some(Uuid::new_v4()),
Some("ephemeral-script".to_string()),
None,
None,
)
.await?;
let viewer_data = json!({
"content": "export function main() { return 1; }",
"language": "deno",
"args": {},
"viewer_data_access": true,
});
for (token, expected) in [("SECRET_TOKEN", true), (job_token.as_str(), false)] {
let (status, id) = post(port, token, "jobs/run/preview", viewer_data.clone()).await;
assert_eq!(status, 201, "{id}");
let stamped: bool =
sqlx::query_scalar("SELECT viewer_data_access FROM job_perms WHERE job_id = $1")
.bind(Uuid::parse_str(&id).unwrap())
.fetch_one(&db)
.await?;
assert_eq!(stamped, expected, "job token: {}", !expected);
}
Ok(())
}
+7 -1
View File
@@ -916,7 +916,6 @@ async fn list_schedule(
// Append the authed user's draft-only schedules; see scripts.rs.
if lsq.include_draft_only.unwrap_or(false)
&& !authed.is_operator
&& offset == 0
&& lsq.path.is_none()
&& lsq.is_flow.is_none()
@@ -927,6 +926,13 @@ async fn list_schedule(
&& lsq.summary.is_none()
&& lsq.broad_filter.is_none()
&& lsq.label.is_none()
&& (!authed.is_operator
|| windmill_common::workspaces::operator_can_draft(
&db,
&w_id,
UserDraftItemKind::TriggerSchedule,
)
.await?)
{
let draft_only_rows = fetch_draft_only_list_rows(
&db,
@@ -28,16 +28,22 @@ use windmill_common::workspaces::{
use windmill_common::{PgDatabase, DB};
use crate::datatable_acl::connect_with_notices;
use crate::datatable_migrations::{
lock_migration_runs_on, read_applied_migrations, write_applied_migrations, AppliedMigrations,
};
use crate::datatable_permissions::ensure_reaches_governing_datatable;
use crate::workspaces::{
create_database_on_server, ensure_datatable_is_clonable, pg_dump_database, pg_import_dump,
DumpFile, PgDumpOptions,
copy_database_settings, create_database_on_server, ensure_datatable_is_clonable,
pg_dump_database, pg_import_dump, DumpFile, PgDumpOptions,
};
/// A database this request created and filled for one data table of the fork.
/// A database this request created and filled for the data tables of the fork on one source
/// database.
pub(crate) struct MadeCopy {
/// The data table's name, in the parent and in the fork.
pub(crate) name: String,
/// The data tables' names, in the parent and in the fork, the one it was made for first. Data
/// tables on one database share its copy: a resource-backed fork has one resource for them all
/// to be pointed through.
pub(crate) names: Vec<String>,
pub(crate) dbname: String,
pub(crate) behavior: DataTableForkBehavior,
/// The database the source resolved to when it was copied.
@@ -237,6 +243,7 @@ pub(crate) async fn authorize_copies(
parent_w_id: &str,
requests: &[CopyRequest<'_>],
) -> Result<()> {
let mut sources: Vec<(&CopyRequest<'_>, GoverningDatatable)> = Vec::new();
for request in requests {
match request.behavior {
DataTableForkBehavior::KeepOriginal => {
@@ -265,9 +272,50 @@ pub(crate) async fn authorize_copies(
let governing = ensure_datatable_is_clonable(db, parent_w_id, request.name).await?;
ensure_reaches_governing_datatable(db, parent_w_id, request.name, &governing, authed)
.await?;
if governing.datatable.permissions.is_some() {
let under_roles = governing.datatable.permissions.is_some();
if under_roles {
crate::datatable_replay_oss::ensure_replay()?;
}
if let Some((first, first_governing)) = sources
.iter()
.find(|(_, earlier)| same_source(earlier, &governing))
{
ensure_shares_copy(first.name, first.behavior, request)?;
if first_governing.datatable.permissions.is_some() != under_roles {
return Err(Error::BadRequest(format!(
"Data tables '{}' and '{}' use the same database, which the fork copies once, \
but only one of them is under roles",
first.name, request.name
)));
}
}
sources.push((request, governing));
}
Ok(())
}
/// Whether two data tables are on one database. A resource is the governing workspace's.
fn same_source(a: &GoverningDatatable, b: &GoverningDatatable) -> bool {
same_database(&a.datatable.database, &b.datatable.database)
&& (a.workspace_id == b.workspace_id
|| a.datatable
.database
.as_ref()
.is_some_and(|d| d.resource_type.is_windmill_managed()))
}
/// Data tables on one database get one copy, made as the first of them asks.
fn ensure_shares_copy(
first: &str,
behavior: DataTableForkBehavior,
request: &CopyRequest<'_>,
) -> Result<()> {
if behavior != request.behavior {
return Err(Error::BadRequest(format!(
"Data tables '{}' and '{}' use the same database, which the fork copies once: fork \
them with the same behavior",
first, request.name
)));
}
Ok(())
}
@@ -281,10 +329,28 @@ pub(crate) async fn make_copies(
requests: &[CopyRequest<'_>],
) -> Result<Vec<MadeCopy>> {
authorize_copies(db, authed, parent_w_id, requests).await?;
let mut copies = Vec::with_capacity(requests.len());
let mut copies: Vec<MadeCopy> = Vec::with_capacity(requests.len());
// What each copy was made from, by index.
let mut sources: Vec<GoverningDatatable> = Vec::with_capacity(requests.len());
for request in requests {
// Checked again by the fork against the copy's `source_database`, under the locks.
let governing = match ensure_datatable_is_clonable(db, parent_w_id, request.name).await {
Ok(governing) => governing,
Err(e) => return Err(drop_copies_after(db, copies, e).await),
};
let shared = sources.iter().position(|s| same_source(s, &governing));
if let Some(c) = shared {
if let Err(e) = ensure_shares_copy(&copies[c].names[0], copies[c].behavior, request) {
return Err(drop_copies_after(db, copies, e).await);
}
copies[c].names.push(request.name.to_string());
continue;
}
match make_copy(db, authed, parent_w_id, request).await {
Ok(copy) => copies.push(copy),
Ok(copy) => {
copies.push(copy);
sources.push(governing);
}
Err(e) => return Err(drop_copies_after(db, copies, e).await),
}
}
@@ -336,8 +402,8 @@ async fn drop_copy(db: &DB, source_database: &DataTableDatabase, dbname: &str) -
Ok(())
}
DataTableCatalogResourceType::Postgresql => {
// On a server of the workspace's own, where a resource edited meanwhile can already name it
// and nothing locks such an edit: dropping it could take someone's data.
// On a server of the workspace's own, where a resource edited meanwhile can already name it
// and nothing locks such an edit: dropping it could take someone's data.
Err(Error::BadRequest(
"kept on its PostgreSQL server, where it may already be in use; drop it there once \
it is not, before forking the same data table under this id again"
@@ -392,20 +458,25 @@ async fn make_copy(
(server, Some(snapshot))
};
let schema_only = request.behavior == DataTableForkBehavior::SchemaOnly;
let migrations_lock = if schema_only {
Some(lock_migration_runs_on(db, &server, request.name).await?)
} else {
None
};
// Dumped before the database exists, so a source that cannot be read leaves nothing behind.
// Ownership never carries over: the restore runs as the target's connection user. Grants do,
// except on a cluster Windmill manages, which plants its own and where the replay below is what
// reproduces the roles'.
let dump = pg_dump_database(
&server,
PgDumpOptions {
schema_only: request.behavior == DataTableForkBehavior::SchemaOnly,
no_owner: true,
no_acl: managed,
..Default::default()
},
PgDumpOptions { schema_only, no_owner: true, no_acl: managed, ..Default::default() },
)
.await?;
let applied = match migrations_lock {
Some(client) => Some(read_applied_migrations(&client).await?),
None => None,
};
match source_database.resource_type {
DataTableCatalogResourceType::Instance => {
@@ -444,11 +515,12 @@ async fn make_copy(
&server,
&target,
&dump,
applied.as_ref(),
)
.await;
match filled {
Ok(replayed) => Ok(MadeCopy {
name: request.name.to_string(),
names: vec![request.name.to_string()],
dbname: request.dbname.to_string(),
behavior: request.behavior,
source_database,
@@ -484,8 +556,12 @@ async fn fill(
source: &PgDatabase,
target: &PgDatabase,
dump: &DumpFile,
applied: Option<&AppliedMigrations>,
) -> Result<bool> {
pg_import_dump(target, dump).await?;
if let Some(applied) = applied {
write_applied_migrations(db, target, applied).await?;
}
// Held until the replay commits: a role renamed or dropped meanwhile would change what the
// replay names, and a settings save could move the source onto another database or put it under
@@ -554,6 +630,7 @@ async fn fill(
})?;
}
tx.commit().await?;
copy_database_settings(db, source, target).await?;
Ok(replayed)
}
@@ -0,0 +1,319 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! A data table migration's scope: the schemas it declares it touches, written as a
//! `-- schemas sales, billing` annotation in its up SQL. A migration that declares none applies to
//! the whole database.
//!
//! Scope decides ordering, not access: a migration waits on every earlier migration whose scope
//! overlaps its own, and its rollback on every later overlapping one. A whole-database migration
//! overlaps everything, so it is a barrier; migrations on disjoint schemas do not wait on each
//! other. Nothing checks that the SQL stays inside its declared scope.
use std::collections::HashSet;
use windmill_common::error::{Error, Result};
/// The schemas a migration declares, lowercased, or `None` for the whole database.
pub(crate) type Scope = Option<Vec<String>>;
const KEYWORD: &str = "schemas";
/// Read the `-- schemas a, b` annotation from the leading comment block of `code`, the way
/// `SqlAnnotations::datatable_role` reads `-- role`. A leading comment whose first word is
/// `schemas` is an annotation attempt, and a malformed one is an error rather than a silent
/// whole-database scope.
///
/// Names are lowercased: SQL folds unquoted identifiers, and two migrations spelling one schema
/// `Sales` and `sales` must still be ordered against each other. A false overlap only costs
/// waiting; a false disjointness lets a migration run before one it depends on.
pub(crate) fn parse_migration_schemas(code: &str) -> Result<Scope> {
for line in code.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if !line.starts_with("--") {
break;
}
let body = line[2..].trim_start();
let Some(after) = body
.get(..KEYWORD.len())
.filter(|kw| kw.eq_ignore_ascii_case(KEYWORD))
.map(|_| &body[KEYWORD.len()..])
else {
continue;
};
if !after.is_empty()
&& !after.starts_with(char::is_whitespace)
&& !after.starts_with([':', '='])
{
continue;
}
let after = after.trim_start();
let after = after.strip_prefix([':', '=']).unwrap_or(after).trim();
let after = after.strip_suffix(';').unwrap_or(after);
let mut schemas: Vec<String> = vec![];
for piece in after.split(',') {
let name = piece.trim();
if !is_annotatable_schema_name(name) {
return Err(Error::BadRequest(format!(
"Malformed data table migration scope annotation: `{line}`. Write it as \
`-- schemas <name>, <name>` on a line of its own, where each name is \
letters, digits, '_', '$' or '-', or leave it out for a migration that \
applies to the whole database. A comment in the leading block that starts \
with the word 'schemas' is read as this annotation; move it below the first \
statement if it is prose."
)));
}
let name = name.to_ascii_lowercase();
if !schemas.contains(&name) {
schemas.push(name);
}
}
return Ok(Some(schemas));
}
Ok(None)
}
/// Whether `name` can be written in a `-- schemas` annotation.
fn is_annotatable_schema_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 63
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '$' | '-'))
}
pub(crate) fn scopes_overlap(a: &Scope, b: &Scope) -> bool {
match (a, b) {
(Some(a), Some(b)) => a.iter().any(|s| b.contains(s)),
_ => true,
}
}
/// One migration of a data table's chain, as ordering sees it.
pub(crate) struct ChainEntry {
pub(crate) timestamp: i64,
pub(crate) name: String,
/// A malformed annotation counts as the whole database, so it orders conservatively. It is
/// refused only when a migration is written: one stored before scopes existed may open with
/// prose that starts with the word, and must keep running as it always did.
pub(crate) scope: Scope,
}
impl ChainEntry {
pub(crate) fn new(timestamp: i64, name: String, code_up: &str) -> Self {
let scope = parse_migration_schemas(code_up).unwrap_or(None);
Self { timestamp, name, scope }
}
fn label(&self) -> String {
format!("{} ({})", self.timestamp, self.name)
}
}
/// Why `later` has to wait for `earlier`, for an error message.
fn overlap_reason(later: &ChainEntry, earlier: &ChainEntry) -> String {
match (&later.scope, &earlier.scope) {
(_, None) => format!("{} applies to the whole database", earlier.label()),
(None, _) => format!("{} applies to the whole database", later.label()),
(Some(a), Some(b)) => {
let shared: Vec<&str> = a
.iter()
.filter(|s| b.contains(s))
.map(String::as_str)
.collect();
format!("both touch schema {}", shared.join(", "))
}
}
}
/// Which pending migrations one run applies.
pub(crate) enum RunSelection {
/// Every pending migration, or those up to and including a version.
UpTo(Option<i64>),
/// One version.
Only(i64),
/// The pending migrations that declare this schema. A whole-database one is never part of
/// it: running for one schema must not change the others.
Schema(String),
}
/// The chain indices one run applies, in order, or why it cannot start. Every selected migration
/// needs each earlier overlapping migration applied or applied by this same run, so the whole run
/// is refused before anything is applied rather than stopping half-way.
///
/// `Only` is exempt: it is the explicit out-of-order run, which the editors confirm with the user
/// (through `blocked_by`) before asking for it.
pub(crate) fn plan_run(
chain: &[ChainEntry],
applied: &HashSet<i64>,
selection: &RunSelection,
) -> Result<Vec<usize>> {
let pending = |i: usize| !applied.contains(&chain[i].timestamp);
let selected: Vec<usize> = (0..chain.len())
.filter(|&i| {
pending(i)
&& match selection {
RunSelection::UpTo(None) => true,
RunSelection::UpTo(Some(up_to)) => chain[i].timestamp <= *up_to,
RunSelection::Only(version) => chain[i].timestamp == *version,
RunSelection::Schema(schema) => {
chain[i].scope.as_ref().is_some_and(|s| s.contains(schema))
}
}
})
.collect();
if matches!(selection, RunSelection::Only(_)) {
return Ok(selected);
}
let in_run: HashSet<usize> = selected.iter().copied().collect();
for &i in &selected {
let blocker = (0..i).find(|&j| {
pending(j) && !in_run.contains(&j) && scopes_overlap(&chain[i].scope, &chain[j].scope)
});
if let Some(j) = blocker {
return Err(Error::BadRequest(format!(
"Migration {} cannot run before {}, which is not applied yet: {}. Apply it first, \
or run all pending migrations.",
chain[i].label(),
chain[j].label(),
overlap_reason(&chain[i], &chain[j]),
)));
}
}
Ok(selected)
}
/// The earliest pending migration `chain[i]` has to wait for, when it is pending; the latest
/// applied migration that has to be rolled back before it, when it is applied.
pub(crate) fn blocked_by(chain: &[ChainEntry], applied: &HashSet<i64>, i: usize) -> Option<usize> {
let is_applied = |j: usize| applied.contains(&chain[j].timestamp);
let overlaps = |j: usize| scopes_overlap(&chain[i].scope, &chain[j].scope);
if is_applied(i) {
(i + 1..chain.len())
.rev()
.find(|&j| is_applied(j) && overlaps(j))
} else {
(0..i).find(|&j| !is_applied(j) && overlaps(j))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn s(names: &[&str]) -> Scope {
Some(names.iter().map(|n| n.to_string()).collect())
}
#[test]
fn schemas_annotation_is_read_from_the_leading_comment_block() {
let p = |code| parse_migration_schemas(code).unwrap();
assert_eq!(p("-- schemas sales\nBEGIN;"), s(&["sales"]));
assert_eq!(
p("-- role analyst\n-- Schemas: Sales, billing,sales;\nBEGIN;"),
s(&["sales", "billing"])
);
assert_eq!(p("-- schemas=a\nBEGIN;"), s(&["a"]));
assert_eq!(p("BEGIN;\n-- schemas sales"), None);
assert_eq!(p("-- schemasx sales\nBEGIN;"), None);
assert_eq!(p("-- up migration: x\nBEGIN;"), None);
for malformed in [
"-- schemas\nBEGIN;",
"-- schemas:\nBEGIN;",
"-- schemas sales billing\nBEGIN;",
"-- schemas: changes for the orders table\nBEGIN;",
"-- schemas a,,b\nBEGIN;",
"-- schemas a.b\nBEGIN;",
] {
assert!(parse_migration_schemas(malformed).is_err(), "{malformed}");
}
}
fn chain(entries: &[(i64, Scope)]) -> Vec<ChainEntry> {
entries
.iter()
.map(|(t, scope)| ChainEntry {
timestamp: *t,
name: format!("m{t}"),
scope: scope.clone(),
})
.collect()
}
fn versions(c: &[ChainEntry], idx: Vec<usize>) -> Vec<i64> {
idx.into_iter().map(|i| c[i].timestamp).collect()
}
#[test]
fn a_run_waits_only_on_overlapping_earlier_migrations() {
let c = chain(&[
(1, None),
(2, s(&["sales"])),
(3, s(&["billing"])),
(4, s(&["sales", "billing"])),
(5, None),
(6, s(&["sales"])),
]);
let applied: HashSet<i64> = [1].into();
// Disjoint schemas don't wait on each other; a cross-schema migration waits on both, a
// whole-database one on everything.
assert_eq!(blocked_by(&c, &applied, 2).map(|j| c[j].timestamp), None);
assert_eq!(blocked_by(&c, &applied, 3).map(|j| c[j].timestamp), Some(2));
assert_eq!(blocked_by(&c, &applied, 4).map(|j| c[j].timestamp), Some(2));
// An explicit single run is the confirmed override.
assert_eq!(
versions(&c, plan_run(&c, &applied, &RunSelection::Only(4)).unwrap()),
vec![4]
);
// Running for a schema takes what declares it, refuses past a pending whole-database
// migration, and refuses when a selected migration also needs another schema's.
let for_schema =
|a: &HashSet<i64>, x: &str| plan_run(&c, a, &RunSelection::Schema(x.into()));
let err = for_schema(&applied, "sales").unwrap_err().to_string();
assert!(err.contains("4 (m4)") && err.contains("3 (m3)"), "{err}");
let applied: HashSet<i64> = [1, 3].into();
let err = for_schema(&applied, "sales").unwrap_err().to_string();
assert!(
err.contains("5 (m5)") && err.contains("whole database"),
"{err}"
);
let applied: HashSet<i64> = [1, 3, 5].into();
assert_eq!(
versions(&c, for_schema(&applied, "sales").unwrap()),
vec![2, 4, 6]
);
// Everything pending in order needs no check: each earlier pending one is in the run.
assert_eq!(
versions(
&c,
plan_run(&c, &[1].into(), &RunSelection::UpTo(None)).unwrap()
),
vec![2, 3, 4, 5, 6]
);
}
#[test]
fn a_rollback_waits_on_later_overlapping_migrations() {
let c = chain(&[
(1, None),
(2, s(&["sales"])),
(3, s(&["billing"])),
(4, s(&["sales"])),
]);
let applied: HashSet<i64> = [1, 2, 3, 4].into();
let blocker = |i| blocked_by(&c, &applied, i).map(|j| c[j].timestamp);
assert_eq!(blocker(2), None);
assert_eq!(blocker(1), Some(4));
assert_eq!(blocker(0), Some(4));
}
}
@@ -10,6 +10,9 @@
//! management, and the workspace-merge diff helper. Split out of `workspaces.rs`
//! to keep that file focused on core workspace configuration.
use crate::datatable_migration_scope::{
blocked_by, parse_migration_schemas, plan_run, ChainEntry, RunSelection,
};
use crate::workspaces::{
managed_datatable_kind, pg_dump_database, strip_unreplayable_dump_lines, ItemComparison,
PgDumpOptions,
@@ -165,6 +168,19 @@ fn ensure_not_operator(authed: &ApiAuthed) -> Result<()> {
Ok(())
}
/// Whether new migrations declare a scope, which the editors push for but do not require.
fn log_migration_scope(scope: &crate::datatable_migration_scope::Scope) {
windmill_common::feature_usage::log_feature_usage(
"datatable",
"migration_scope",
if scope.is_some() {
"scoped"
} else {
"whole_database"
},
);
}
#[derive(Serialize)]
struct AppliedMigration {
version: i64,
@@ -182,8 +198,11 @@ struct RunDatatableMigrationsQuery {
up_to: Option<i64>,
/// When set, apply only this specific migration version (if not already
/// applied), ignoring any other pending migrations. Takes precedence over
/// `up_to`.
/// `up_to` and `schema`.
only: Option<i64>,
/// When set, apply only the pending migrations that declare this schema in their
/// `-- schemas` annotation. Takes precedence over `up_to`.
schema: Option<String>,
}
/// Build the `database` argument for a migration job. Both resource-backed and
@@ -509,6 +528,127 @@ async fn hold_migration_lock(
}
}
/// The `_wm_migrations` rows of a database a schema-only copy is being made of, read with
/// [`lock_migration_runs_on`] held so they describe the schema the dump captured.
pub(crate) struct AppliedMigrations {
/// `None` when the source has no `_wm_migrations`.
schema: Option<String>,
datatables: Vec<String>,
versions: Vec<i64>,
installed_at: Vec<String>,
}
/// Hold the migration-run lock on database `pg`, for a copy whose schema has to agree with the
/// `_wm_migrations` rows it carries. The lock is per database, whichever data table names it.
pub(crate) async fn lock_migration_runs_on(
db: &DB,
pg: &PgDatabase,
label: &str,
) -> Result<tokio_postgres::Client> {
let (client, connection) = pg.connect(Some(db)).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::error!("Datatable connection error: {}", e);
}
});
hold_migration_lock(&client, label, MIGRATION_LOCK_WAIT_SECS).await?;
Ok(client)
}
/// Read every `_wm_migrations` row of the database `client` is connected to, along with the
/// schema the table is in: the copy is written to by a connection whose `search_path` need not
/// resolve the way this one does.
pub(crate) async fn read_applied_migrations(
client: &tokio_postgres::Client,
) -> Result<AppliedMigrations> {
let read_err = |e: tokio_postgres::Error| {
Error::internal_err(format!(
"Failed to read _wm_migrations: {}",
pg_error_message(&e)
))
};
let schema: Option<String> = client
.query_one(
"SELECT n.nspname::text FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
RIGHT JOIN (SELECT to_regclass('_wm_migrations')::oid AS oid) t ON c.oid = t.oid",
&[],
)
.await
.map_err(read_err)?
.get(0);
let mut applied =
AppliedMigrations { schema, datatables: vec![], versions: vec![], installed_at: vec![] };
let Some(schema) = applied.schema.as_deref() else {
return Ok(applied);
};
let rows = client
.query(
&format!(
// ISO 8601 in UTC: `::text` follows the session's `DateStyle`, which the copy's
// session need not parse the same way.
"SELECT datatable, version, \
to_char(installed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS.US') \
FROM {}._wm_migrations",
render_db_quoted_identifier(schema, DbType::Postgresql)
),
&[],
)
.await
.map_err(read_err)?;
for row in rows {
applied.datatables.push(row.get(0));
applied.versions.push(row.get(1));
applied.installed_at.push(row.get(2));
}
Ok(applied)
}
/// Write the rows [`read_applied_migrations`] read into a copy restored from a schema-only dump,
/// which brings `_wm_migrations` over empty: without them the copy reports every migration its
/// schema already holds as pending, and running them fails on objects that exist.
pub(crate) async fn write_applied_migrations(
db: &DB,
target: &PgDatabase,
applied: &AppliedMigrations,
) -> Result<()> {
let Some(schema) = applied.schema.as_deref() else {
return Ok(());
};
if applied.versions.is_empty() {
return Ok(());
}
let (client, connection) = target.connect(Some(db)).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::error!("Datatable connection error: {}", e);
}
});
client
.execute(
&format!(
"INSERT INTO {}._wm_migrations (datatable, version, installed_at) \
SELECT d, v, i::timestamp AT TIME ZONE 'UTC' \
FROM unnest($1::text[], $2::bigint[], $3::text[]) AS t(d, v, i) \
ON CONFLICT DO NOTHING",
render_db_quoted_identifier(schema, DbType::Postgresql)
),
&[
&applied.datatables,
&applied.versions,
&applied.installed_at,
],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to copy _wm_migrations into '{}': {}",
target.dbname,
pg_error_message(&e)
))
})?;
Ok(())
}
/// Whether `e` is the lock wait running out, as opposed to the database being unreachable.
fn is_migration_lock_busy(e: &Error) -> bool {
matches!(e, Error::Generic(StatusCode::CONFLICT, _))
@@ -541,7 +681,9 @@ async fn read_applied_versions_on_client(
/// Each migration runs as a normal Windmill `postgresql` job (permissioned as
/// the requester, labelled `datatable_migration`); applied versions are then
/// recorded in the data table's own `_wm_migrations` table, so only migrations
/// not recorded there are run, in ascending `timestamp` order.
/// not recorded there are run, in ascending `timestamp` order. A run for one
/// schema is refused while an earlier migration its selection overlaps is pending
/// (see `datatable_migration_scope`).
async fn run_datatable_migrations(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -587,30 +729,29 @@ async fn run_datatable_migrations(
let applied_versions = read_applied_versions_on_client(&client, &datatable_name).await?;
let selection = if let Some(only) = query.only {
RunSelection::Only(only)
} else if let Some(schema) = query.schema.as_deref() {
RunSelection::Schema(schema.to_ascii_lowercase())
} else {
RunSelection::UpTo(query.up_to)
};
// How the user scoped the run, for the counter emitted on the first migration
// that lands below.
let scope = if query.only.is_some() {
"only"
} else if query.up_to.is_some() {
"up_to"
} else {
"all"
let scope = match selection {
RunSelection::Only(_) => "only",
RunSelection::Schema(_) => "schema",
RunSelection::UpTo(Some(_)) => "up_to",
RunSelection::UpTo(None) => "all",
};
let chain: Vec<ChainEntry> = migrations
.iter()
.map(|m| ChainEntry::new(m.timestamp, m.name.clone(), &m.code_up))
.collect();
let plan = plan_run(&chain, &applied_versions, &selection)?;
let mut applied = Vec::new();
for m in migrations {
if let Some(only) = query.only {
// Run a single specific migration, skipping every other one.
if m.timestamp != only {
continue;
}
} else if query.up_to.is_some_and(|up_to| m.timestamp > up_to) {
// Migrations are ordered ascending, so once we pass `up_to` we're done.
break;
}
if applied_versions.contains(&m.timestamp) {
continue;
}
for m in plan.into_iter().map(|i| &migrations[i]) {
ensure_migration_role_allowed(
&db,
&w_id,
@@ -642,7 +783,7 @@ async fn run_datatable_migrations(
pg_error_message(&e)
))
})?;
applied.push(AppliedMigration { version: m.timestamp, name: m.name });
applied.push(AppliedMigration { version: m.timestamp, name: m.name.clone() });
// One event per run that moved the data table forward, emitted on the
// first migration that lands rather than after the loop: a later one
// failing returns early, and that run still advanced the data table. A
@@ -865,6 +1006,13 @@ struct DatatableMigrationWithStatus {
#[serde(skip_serializing_if = "Option::is_none")]
code_down: Option<String>,
status: DatatableMigrationRunStatus,
/// The schemas its `-- schemas` annotation declares; absent for the whole database.
#[serde(skip_serializing_if = "Option::is_none")]
schemas: Option<Vec<String>>,
/// When not run: the earliest pending migration that has to be applied before it. When run:
/// the latest applied migration that has to be rolled back before it.
#[serde(skip_serializing_if = "Option::is_none")]
blocked_by: Option<i64>,
}
#[derive(Serialize)]
@@ -981,9 +1129,14 @@ async fn datatable_migrations_status(
Err(e) => (None, Some(e.to_string())),
};
let chain: Vec<ChainEntry> = defs
.iter()
.map(|m| ChainEntry::new(m.timestamp, m.name.clone(), &m.code_up))
.collect();
let migrations = defs
.into_iter()
.map(|m| {
.enumerate()
.map(|(i, m)| {
let status = match &applied {
Some(set) if set.contains(&m.timestamp) => DatatableMigrationRunStatus::Ran,
Some(_) => DatatableMigrationRunStatus::NotRun,
@@ -995,6 +1148,11 @@ async fn datatable_migrations_status(
code_up: m.code_up,
code_down: m.code_down,
status,
schemas: chain[i].scope.clone(),
blocked_by: applied
.as_ref()
.and_then(|set| blocked_by(&chain, set, i))
.map(|j| chain[j].timestamp),
}
})
.collect();
@@ -1333,6 +1491,7 @@ async fn create_datatable_migration(
ensure_not_operator(&authed)?;
validate_datatable_path_segment(&datatable_name)?;
validate_migration_name(&payload.name)?;
let scope = parse_migration_schemas(&payload.code_up)?;
ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?;
ensure_may_edit_migration(
&db,
@@ -1379,6 +1538,7 @@ async fn create_datatable_migration(
.await?;
windmill_common::feature_usage::log_feature_usage("datatable", "migration_created", "manual");
log_migration_scope(&scope);
Ok(Json(DatatableMigration {
datatable: datatable_name,
@@ -1581,6 +1741,16 @@ async fn upsert_datatable_migration(
if unchanged {
return Ok(upserted_message);
}
// Only up SQL being written is held to the annotation: a stored up that opens with prose
// starting with the word `schemas` predates scopes, and must still accept a down added to it.
let up_changed = existing
.as_ref()
.is_none_or(|existing| existing.code_up != payload.code_up);
let scope = if up_changed {
parse_migration_schemas(&payload.code_up)?
} else {
None
};
ensure_may_edit_migration(
&db,
&w_id,
@@ -1709,6 +1879,9 @@ async fn upsert_datatable_migration(
"edited"
},
);
if existing.is_none() {
log_migration_scope(&scope);
}
Ok(upserted_message)
}
@@ -4,6 +4,7 @@ pub mod data_metrics;
pub mod datatable_acl;
pub mod datatable_acl_oss;
pub mod datatable_clone;
pub mod datatable_migration_scope;
pub mod datatable_migrations;
pub mod datatable_permissions;
pub mod datatable_permissions_oss;
+399 -25
View File
@@ -1766,6 +1766,43 @@ async fn set_slack_oauth_config(
"Both client ID and client secret are required".to_string(),
));
}
// A settings.yaml pulled by `wmill sync` holds the secret encrypted, and a CLI that
// predates the settings document pushes it here as it reads it. One encrypted under another
// workspace's key keeps the stored secret, as the settings document does, so that CLI's push
// does not abort over it.
let mut req = req;
if let Some(ciphertext) = req
.slack_oauth_client_secret
.strip_prefix(windmill_common::variables::SETTINGS_ENCRYPTED_PREFIX)
{
match windmill_common::variables::build_crypt(&db, &w_id)
.await?
.decrypt_str(ciphertext)
{
Ok(plain) => req.slack_oauth_client_secret = plain,
Err(_) => {
let stored = sqlx::query_scalar!(
"SELECT slack_oauth_client_secret FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&db)
.await?
.flatten();
tracing::warn!(
workspace_id = %w_id,
"Slack client secret left unchanged: it is encrypted with another workspace's key"
);
match stored {
Some(stored) => req.slack_oauth_client_secret = stored,
None => {
return Ok("Slack OAuth config left unchanged: the client secret is \
encrypted with another workspace's key"
.to_string())
}
}
}
}
}
let mut tx = db.begin().await?;
@@ -3595,6 +3632,97 @@ pub(crate) async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile)
Ok(())
}
/// Carry the source database's own settings (`ALTER DATABASE ... SET`) over to a copy. `pg_dump`
/// only emits them with `--create`, so a copy otherwise resolves unqualified names, its
/// `_wm_migrations` included, through the server's default `search_path` rather than the source's.
/// A setting the copy's connection may not set is skipped with a warning rather than failing it.
/// Called once nothing else writes to the copy, which a copied read-only default would refuse.
pub(crate) async fn copy_database_settings(
db: &DB,
source: &PgDatabase,
target: &PgDatabase,
) -> Result<()> {
let (source_client, connection) = source.connect(Some(db)).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::error!("Datatable connection error: {}", e);
}
});
let settings: Vec<String> = source_client
.query(
"SELECT unnest(setconfig) FROM pg_db_role_setting WHERE setrole = 0 \
AND setdatabase = (SELECT oid FROM pg_database WHERE datname = current_database())",
&[],
)
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to read the settings of database '{}': {}",
source.dbname,
pg_error_message(&e)
))
})?
.iter()
.map(|row| row.get(0))
.collect();
drop(source_client);
if settings.is_empty() {
return Ok(());
}
let (mut client, connection) = target.connect(Some(db)).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
tracing::error!("Datatable connection error: {}", e);
}
});
let quoted_db = render_db_quoted_identifier(&target.dbname, DbType::Postgresql);
for setting in settings {
let Some((name, value)) = setting.split_once('=') else {
continue;
};
if name.is_empty()
|| !name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
{
tracing::warn!(
"Not copying database setting '{name}' to '{}'",
target.dbname
);
continue;
}
let quoted_name = name
.split('.')
.map(|part| render_db_quoted_identifier(part, DbType::Postgresql))
.collect::<Vec<_>>()
.join(".");
// `FROM CURRENT` takes the value in the stored text form, which `SET name TO value` would
// need re-quoted per setting type. Set transaction-locally: a session-wide `role` or
// `default_transaction_read_only` would make every later `ALTER DATABASE` fail.
let applied = async {
let tx = client.transaction().await?;
tx.execute("SELECT set_config($1, $2, true)", &[&name, &value])
.await?;
tx.batch_execute(&format!(
"ALTER DATABASE {quoted_db} SET {quoted_name} FROM CURRENT"
))
.await?;
tx.commit().await
}
.await;
if let Err(e) = applied {
tracing::warn!(
"Could not copy setting '{name}' of database '{}' to '{}': {}",
source.dbname,
target.dbname,
pg_error_message(&e)
);
}
}
Ok(())
}
#[derive(Deserialize)]
struct CreatePgDatabaseRequest {
/// The datatable source to determine connection info: 'datatable://name' or '$res:path'
@@ -3858,12 +3986,33 @@ async fn import_pg_database(
// the ACLs are user intent (`REVOKE ... FROM PUBLIC`) and dropping them widens access.
let no_acl = target_kind.is_some() || source_kind.is_some();
// Only a database made to be a copy takes on the source's bookkeeping and settings: without an
// override the target is a data table's own live database, whose settings are its owner's.
let fresh_copy = req.target_dbname_override.is_some();
let migrations_lock = if schema_only && fresh_copy {
Some(
crate::datatable_migrations::lock_migration_runs_on(&db, &source_pg, &req.source)
.await?,
)
} else {
None
};
let dump_file = pg_dump_database(
&source_pg,
PgDumpOptions { schema_only, no_owner: true, no_acl, ..Default::default() },
)
.await?;
let applied = match migrations_lock {
Some(client) => Some(crate::datatable_migrations::read_applied_migrations(&client).await?),
None => None,
};
pg_import_dump(&target_pg, &dump_file).await?;
if let Some(applied) = applied {
crate::datatable_migrations::write_applied_migrations(&db, &target_pg, &applied).await?;
}
if fresh_copy {
copy_database_settings(&db, &source_pg, &target_pg).await?;
}
if let Some(tx) = fork_lock {
tx.commit().await?;
}
@@ -8399,7 +8548,7 @@ async fn clone_apps(
// Clone app scripts with recomputed hashes
let app_scripts = sqlx::query!(
"SELECT app, hash, lock, code, code_sha256
"SELECT id, app, hash, lock, code, code_sha256, modules
FROM app_script
WHERE app = ANY(SELECT id FROM app WHERE workspace_id = $1)",
source_workspace_id
@@ -8407,34 +8556,104 @@ async fn clone_apps(
.fetch_all(&mut **tx)
.await?;
let mut app_script_id_mapping: HashMap<i64, i64> = HashMap::new();
for app_script in app_scripts {
if let Some(&new_app_id) = app_id_mapping.get(&app_script.app) {
// Recompute hash using app_id, code_sha256, and lock
// Recompute hash using app_id, code_sha256, lock and modules
let mut hasher = Sha256::new();
hasher.update(new_app_id.to_be_bytes());
hasher.update(hex::decode(&app_script.code_sha256)?);
if let Some(lock) = &app_script.lock {
hasher.update(lock.as_bytes());
}
if let Some(modules) = &app_script.modules {
hasher.update(modules.to_string());
}
let new_hash = hex::encode(hasher.finalize());
sqlx::query!(
"INSERT INTO app_script (app, hash, lock, code, code_sha256)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING",
let new_id = sqlx::query_scalar!(
"INSERT INTO app_script (app, hash, lock, code, code_sha256, modules)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (hash) DO UPDATE SET app = EXCLUDED.app -- trivial update to return the id
RETURNING id",
new_app_id,
new_hash,
app_script.lock,
app_script.code,
app_script.code_sha256,
app_script.modules,
)
.execute(&mut **tx)
.fetch_one(&mut **tx)
.await?;
app_script_id_mapping.insert(app_script.id, new_id);
}
}
// A version's lite value is what points each inline script at its `app_script` row (lock and
// modules). Without it the fork serves the full value, whose inline scripts carry no id and
// so run as bare code: no lock, and no modules for a raw app's sibling imports. Every
// version, not just `versions.last()`: the fork's `versions` is rebuilt from all rows above,
// so its current one can be a row the parent's array never listed.
let source_versions: Vec<i64> = version_id_mapping.keys().copied().collect();
let lite_versions = sqlx::query!(
"SELECT id, value AS \"value!: serde_json::Value\" FROM app_version_lite WHERE id = ANY($1)",
&source_versions,
)
.fetch_all(&mut **tx)
.await?;
let (mut lite_ids, mut lite_values) = (vec![], vec![]);
for lite in lite_versions {
let Some(&new_version_id) = version_id_mapping.get(&lite.id) else {
continue;
};
let mut value = lite.value;
// Lite inline scripts have no code of their own, so one left without a row cannot run.
if !remap_app_script_ids(&mut value, &app_script_id_mapping) {
continue;
}
if let Some(object) = value.as_object_mut() {
object.insert("version".to_string(), serde_json::json!(new_version_id));
}
lite_ids.push(new_version_id);
lite_values.push(value);
}
sqlx::query!(
"INSERT INTO app_version_lite (id, value)
SELECT * FROM UNNEST($1::bigint[], $2::jsonb[])
ON CONFLICT (id) DO NOTHING",
&lite_ids,
&lite_values,
)
.execute(&mut **tx)
.await?;
Ok(app_id_mapping)
}
/// Points every `inlineScript.id` of a cloned lite app value at the fork's copy of that row.
/// False when some id has no copy.
fn remap_app_script_ids(value: &mut serde_json::Value, mapping: &HashMap<i64, i64>) -> bool {
match value {
serde_json::Value::Object(object) => {
if let Some(serde_json::Value::Object(script)) = object.get_mut("inlineScript") {
if let Some(old_id) = script.get("id").and_then(serde_json::Value::as_i64) {
let Some(new_id) = mapping.get(&old_id) else {
return false;
};
script.insert("id".to_string(), serde_json::json!(new_id));
}
}
object
.values_mut()
.all(|child| remap_app_script_ids(child, mapping))
}
serde_json::Value::Array(array) => array
.iter_mut()
.all(|child| remap_app_script_ids(child, mapping)),
_ => true,
}
}
async fn clone_raw_apps(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
@@ -8826,11 +9045,12 @@ async fn apply_forked_datatable(
fdt: &ForkedDatatableInfo,
copy: &crate::datatable_clone::MadeCopy,
) -> Result<()> {
windmill_common::validate_dbname(&fdt.new_dbname)?;
if !fdt.new_dbname.starts_with("wm_fork_") {
// Not `fdt.new_dbname`: a data table sharing its database with an earlier one shares its copy.
let dbname = copy.dbname.as_str();
windmill_common::validate_dbname(dbname)?;
if !dbname.starts_with("wm_fork_") {
return Err(Error::BadRequest(format!(
"Forked datatable database name '{}' must start with 'wm_fork_'",
fdt.new_dbname
"Forked datatable database name '{dbname}' must start with 'wm_fork_'"
)));
}
// Held until the fork commits, as the permissions save holds them: the source moved onto
@@ -8919,27 +9139,27 @@ async fn apply_forked_datatable(
if database.resource_type == DataTableCatalogResourceType::ExternalInstance {
windmill_common::external_instance_pg::ensure_external_instance_database_registered(
tx,
&fdt.new_dbname,
tx, dbname,
)
.await?;
}
if database.resource_type.is_windmill_managed() {
// Held until the fork commits, as every save newly naming a database takes it: none may
// claim the copy between the check below and this fork's entry landing on it.
windmill_common::datatable_roles::lock_instance_databases_governance(
&mut **tx,
[fdt.new_dbname.as_str()],
)
.await?;
windmill_common::datatable_roles::lock_instance_databases_governance(&mut **tx, [dbname])
.await?;
}
// The data tables after the first on a shared copy find it named by the first, and the
// resource it is reached through already pointed at it.
let first_on_copy = copy.names.first() == Some(&fdt.name);
if database.resource_type.is_windmill_managed()
&& first_on_copy
&& !windmill_api_auth::is_super_admin_authed(db, authed).await?
{
windmill_common::ensure_fork_database_available_to(
&mut **tx,
database.resource_type,
&fdt.new_dbname,
dbname,
parent_w_id,
)
.await?;
@@ -8950,7 +9170,7 @@ async fn apply_forked_datatable(
// on the same cluster as its source, so it keeps the source's kind.
let new_database = serde_json::json!({
"resource_type": database.resource_type,
"resource_path": &fdt.new_dbname,
"resource_path": dbname,
});
sqlx::query(
r#"UPDATE workspace_settings
@@ -8964,7 +9184,7 @@ async fn apply_forked_datatable(
.bind(new_database)
.execute(&mut **tx)
.await?;
} else {
} else if first_on_copy {
// Resource: point it at the copy and mark it ws_specific. What the settings clone carried is
// the source's resource and variables as they are now, which an edit made while the copy
// was being made can have changed, and changed back: the clone has to be what the copy's
@@ -8987,7 +9207,7 @@ async fn apply_forked_datatable(
)
.bind(forked_w_id)
.bind(resource_path)
.bind(&fdt.new_dbname)
.bind(dbname)
.execute(&mut **tx)
.await?;
@@ -9917,9 +10137,12 @@ async fn write_workspace_fork(
// Update forked datatable settings to point to new databases
for fdt in &nw.forked_datatables {
let copy = copies.iter().find(|c| c.name == fdt.name).ok_or_else(|| {
Error::internal_err(format!("No copy was made of data table '{}'", fdt.name))
})?;
let copy = copies
.iter()
.find(|c| c.names.contains(&fdt.name))
.ok_or_else(|| {
Error::internal_err(format!("No copy was made of data table '{}'", fdt.name))
})?;
apply_forked_datatable(
&db,
&mut tx,
@@ -9993,7 +10216,7 @@ async fn write_workspace_fork(
.map(|c| {
format!(
"{} ({})",
c.name,
c.names.join(" + "),
match c.behavior {
DataTableForkBehavior::SchemaOnly => "schema_only",
_ => "schema_and_data",
@@ -14758,3 +14981,154 @@ async fn set_ws_specific(
body.value, body.item_kind, body.path
))
}
/// A setting the workspace settings document writes, each through the endpoint that owns it,
/// so applying a document validates, audits and has the side effects of the matching UI save.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsEdit {
Webhook,
AutoInvite,
InstanceGroups,
ErrorHandler,
SuccessHandler,
LargeFileStorage,
GitSync,
DefaultScripts,
DefaultApp,
Color,
OperatorSettings,
Datatable,
SlackCommand,
SetSlackOAuth,
DeleteSlackOAuth,
Ducklake,
DbtWarehouses,
DeployUi,
GuestAccess,
GuestJwtKey,
PublicAppRateLimit,
AddAdminsAndDevelopersToForks,
/// Body: `{ datatable, permissioned, default_role, roles: [{ id, tenants }] }`, role ids.
DatatablePermissions,
CreateProtectionRule,
/// Body: the update request plus `rule_name`, the rule it updates.
UpdateProtectionRule,
/// Body: `{ name }`.
DeleteProtectionRule,
}
fn settings_edit_body<T: serde::de::DeserializeOwned>(
edit: SettingsEdit,
body: serde_json::Value,
) -> Result<Json<T>> {
serde_json::from_value(body)
.map(Json)
.map_err(|e| Error::BadRequest(format!("Invalid value for {edit:?}: {e}")))
}
/// Returns the endpoint's JSON response, or `null` for one that answers with prose.
pub async fn apply_settings_edit(
authed: ApiAuthed,
db: DB,
w_id: String,
edit: SettingsEdit,
body: serde_json::Value,
) -> Result<serde_json::Value> {
use SettingsEdit::*;
let a = authed.clone();
let (d, p) = (Extension(db), Path(w_id));
match edit {
Webhook => edit_webhook(a, d, p, authed, settings_edit_body(edit, body)?).await?,
AutoInvite => edit_auto_invite(a, d, p, settings_edit_body(edit, body)?).await?,
InstanceGroups => edit_instance_groups(a, d, p, settings_edit_body(edit, body)?).await?,
ErrorHandler => {
edit_error_handler(a, d, p, authed, settings_edit_body(edit, body)?).await?
}
SuccessHandler => edit_success_handler(a, d, p, settings_edit_body(edit, body)?).await?,
LargeFileStorage => {
edit_large_file_storage_config(a, d, p, authed, settings_edit_body(edit, body)?).await?
}
GitSync => edit_git_sync_config(a, d, p, authed, settings_edit_body(edit, body)?).await?,
DefaultScripts => {
edit_default_scripts(a, d, p, authed, settings_edit_body(edit, body)?).await?
}
#[cfg(feature = "enterprise")]
DefaultApp => edit_default_app(a, d, p, authed, settings_edit_body(edit, body)?).await?,
#[cfg(not(feature = "enterprise"))]
DefaultApp => edit_default_app(a, d, p, settings_edit_body(edit, body)?).await?,
Color => change_workspace_color(a, p, d, settings_edit_body(edit, body)?).await?,
OperatorSettings => {
update_operator_settings(a, p, d, settings_edit_body(edit, body)?).await?
}
Datatable => {
let Json(result) =
edit_datatable_config(a, d, p, authed, settings_edit_body(edit, body)?).await?;
return serde_json::to_value(result).map_err(|e| Error::internal_err(e.to_string()));
}
SlackCommand => {
edit_slack_command(a, d, p, authed, settings_edit_body(edit, body)?).await?
}
SetSlackOAuth => set_slack_oauth_config(a, d, p, settings_edit_body(edit, body)?).await?,
DeleteSlackOAuth => delete_slack_oauth_config(a, d, p).await?,
Ducklake => edit_ducklake_config(a, d, p, authed, settings_edit_body(edit, body)?).await?,
DbtWarehouses => {
edit_dbt_warehouses(a, d, p, authed, settings_edit_body(edit, body)?).await?
}
#[cfg(feature = "enterprise")]
DeployUi => edit_deploy_ui_config(a, d, p, authed, settings_edit_body(edit, body)?).await?,
#[cfg(not(feature = "enterprise"))]
DeployUi => edit_deploy_ui_config(a, d, p).await?,
GuestAccess => edit_guest_access(a, d, p, settings_edit_body(edit, body)?).await?,
GuestJwtKey => edit_guest_jwt_key(a, d, p, settings_edit_body(edit, body)?).await?,
PublicAppRateLimit => {
edit_public_app_rate_limit(d, p, a, settings_edit_body(edit, body)?).await?
}
AddAdminsAndDevelopersToForks => {
edit_add_admins_and_developers_to_forks(a, d, p, settings_edit_body(edit, body)?)
.await?
}
#[cfg(all(feature = "private", feature = "enterprise"))]
DatatablePermissions => {
#[derive(Deserialize)]
struct Body {
datatable: String,
#[serde(flatten)]
req: crate::datatable_permissions_ee::SetDatatablePermissions,
}
let Json(Body { datatable, req }) = settings_edit_body(edit, body)?;
crate::datatable_permissions_oss::set_datatable_permissions(
a,
d,
Path((p.0, datatable)),
Json(req),
)
.await?
}
#[cfg(not(all(feature = "private", feature = "enterprise")))]
DatatablePermissions => {
crate::datatable_permissions_oss::set_datatable_permissions(a).await?
}
CreateProtectionRule => {
create_protection_rule(a, d, p, settings_edit_body(edit, body)?).await?
}
UpdateProtectionRule => {
#[derive(Deserialize)]
struct Body {
rule_name: String,
#[serde(flatten)]
req: UpdateProtectionRuleRequest,
}
let Json(Body { rule_name, req }) = settings_edit_body(edit, body)?;
update_protection_rule(a, d, Path((p.0, rule_name)), Json(req)).await?
}
DeleteProtectionRule => {
#[derive(Deserialize)]
struct Body {
name: String,
}
let Json(Body { name }) = settings_edit_body(edit, body)?;
delete_protection_rule(a, d, Path((p.0, name))).await?
}
};
Ok(serde_json::Value::Null)
}
@@ -1,4 +1,4 @@
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use windmill_api_auth::{require_super_admin, ApiAuthed};
use windmill_common::DB;
@@ -1486,20 +1486,39 @@ pub async fn drop_forked_datatable_databases(
serde_json::from_value(datatable_config).unwrap_or_default();
let mut errors: Vec<String> = Vec::new();
// Data tables cloned from one database share its copy, which is dropped once for all of them.
// Keyed by where the database lives too: copies on other clusters or servers can share a name.
let mut dropped_dbs: HashSet<(String, Option<u16>, String)> = HashSet::new();
// Only a clone is droppable, and a clone is terminal by construction: a kept data table is a
// pointer at the parent's database, which this fork does not own.
let cloned_database = |name: &str| {
datatables
.get(name)
.filter(|dt| dt.forked_from.is_some())
.and_then(|dt| dt.database.as_ref())
};
for dt_name in &req.datatable_names {
// Only a clone is droppable, and a clone is terminal by construction: a kept data table is
// a pointer at the parent's database, which this fork does not own.
let database = match datatables.get(dt_name) {
Some(dt) if dt.forked_from.is_some() => match dt.database.as_ref() {
Some(database) => database,
None => continue,
},
_ => continue,
let Some(database) = cloned_database(dt_name) else {
continue;
};
if database.resource_type.is_windmill_managed() {
let db_to_drop = &database.resource_path;
let cluster = database.resource_type.as_ref().to_string();
if !dropped_dbs.insert((cluster, None, db_to_drop.clone())) {
continue;
}
let sharing: Vec<String> = req
.datatable_names
.iter()
.filter(|name| {
cloned_database(name).is_some_and(|d| {
d.resource_type == database.resource_type && &d.resource_path == db_to_drop
})
})
.cloned()
.collect();
if !db_to_drop.starts_with("wm_fork_") {
errors.push(format!(
"Refusing to drop instance database '{}' for datatable://{}: name does not start with 'wm_fork_'",
@@ -1513,12 +1532,8 @@ pub async fn drop_forked_datatable_databases(
// A task of its own, so a client going away cannot stop it between dropping the
// database and committing the entry's removal.
let dropped = tokio::spawn({
let (db, w_id, dt_name, db_to_drop) = (
db.clone(),
w_id.clone(),
dt_name.clone(),
db_to_drop.clone(),
);
let (db, w_id, sharing, db_to_drop) =
(db.clone(), w_id.clone(), sharing.clone(), db_to_drop.clone());
let resource_type = database.resource_type;
async move {
let mut tx = db.begin().await?;
@@ -1527,26 +1542,29 @@ pub async fn drop_forked_datatable_databases(
// rename this entry, or point another one here, either side of the check below.
windmill_common::workspaces::lock_fork_datatables(&mut tx, &w_id).await?;
// The snapshot above was read unlocked: a save committing since could have
// repointed this entry, and the entry is removed below whatever it names by then.
let current = sqlx::query_scalar::<_, Option<serde_json::Value>>(
"SELECT datatable->'datatables'->$2 FROM workspace_settings
WHERE workspace_id = $1 FOR UPDATE",
)
.bind(&w_id)
.bind(&dt_name)
.fetch_optional(&mut *tx)
.await?
.flatten()
.and_then(|v| serde_json::from_value::<DataTable>(v).ok());
if !current.is_some_and(|dt| {
dt.forked_from.is_some()
&& dt.database.is_some_and(|d| {
d.resource_type == resource_type && d.resource_path == db_to_drop
})
}) {
return Err(Error::BadRequest(
"the data table changed while it was being cleaned up".to_string(),
));
// repointed these entries, and they are removed below whatever they name by then.
for dt_name in &sharing {
let current = sqlx::query_scalar::<_, Option<serde_json::Value>>(
"SELECT datatable->'datatables'->$2 FROM workspace_settings
WHERE workspace_id = $1 FOR UPDATE",
)
.bind(&w_id)
.bind(dt_name)
.fetch_optional(&mut *tx)
.await?
.flatten()
.and_then(|v| serde_json::from_value::<DataTable>(v).ok());
if !current.is_some_and(|dt| {
dt.forked_from.is_some()
&& dt.database.is_some_and(|d| {
d.resource_type == resource_type
&& d.resource_path == db_to_drop
})
}) {
return Err(Error::BadRequest(format!(
"data table '{dt_name}' changed while it was being cleaned up"
)));
}
}
let external = resource_type
== windmill_common::workspaces::DataTableCatalogResourceType::ExternalInstance;
@@ -1564,11 +1582,13 @@ pub async fn drop_forked_datatable_databases(
)
.await?;
// Before the entry is removed: child fork pointers are found through it.
let uses = windmill_common::workspaces::managed_database_uses(
let names: Vec<&str> = sharing.iter().map(String::as_str).collect();
let uses = windmill_common::workspaces::managed_database_uses_except(
&mut tx,
resource_type,
&db_to_drop,
Some((w_id.as_str(), dt_name.as_str())),
&w_id,
&names,
)
.await?;
if !uses.is_empty() {
@@ -1579,20 +1599,22 @@ pub async fn drop_forked_datatable_databases(
}
// The entry goes with the database: a fork this one is cloned into afterwards
// must not inherit a pointer at a data table whose database is gone.
sqlx::query(
"UPDATE workspace_settings SET datatable = datatable #- ARRAY['datatables', $2]
WHERE workspace_id = $1",
)
.bind(&w_id)
.bind(&dt_name)
.execute(&mut *tx)
.await?;
for dt_name in &sharing {
sqlx::query(
"UPDATE workspace_settings SET datatable = datatable #- ARRAY['datatables', $2]
WHERE workspace_id = $1",
)
.bind(&w_id)
.bind(dt_name)
.execute(&mut *tx)
.await?;
}
if external {
// Checks the uses of the database itself, and unregisters it.
windmill_common::external_instance_pg::drop_external_instance_database_unchecked(
&mut tx,
&db_to_drop,
Some((w_id.as_str(), dt_name.as_str())),
sharing.first().map(|name| (w_id.as_str(), name.as_str())),
)
.await?;
} else {
@@ -1615,10 +1637,38 @@ pub async fn drop_forked_datatable_databases(
if let Err(e) = dropped {
errors.push(format!(
"Could not drop instance database '{}' for datatable://{}: {}",
db_to_drop, dt_name, e
db_to_drop,
sharing.join(", datatable://"),
e
));
}
} else {
// Clones on one resource share its copy, which stays while any of them is kept. Only
// clones: a kept entry naming the resource cannot be put in the request.
let mut kept: Vec<&String> = datatables
.iter()
.filter(|(name, dt)| {
!req.datatable_names.contains(name)
&& dt.forked_from.is_some()
&& dt.database.as_ref().is_some_and(|d| {
d.resource_type == database.resource_type
&& d.resource_path == database.resource_path
})
})
.map(|(name, _)| name)
.collect();
if !kept.is_empty() {
kept.sort();
errors.push(format!(
"Could not drop the database of datatable://{dt_name}: it is still used by data \
tables {}",
kept.iter()
.map(|name| format!("'{name}'"))
.collect::<Vec<_>>()
.join(", ")
));
continue;
}
let fork_pg = match crate::workspaces::resolve_pg_source_checked(
&db,
&user_db,
@@ -1673,6 +1723,9 @@ pub async fn drop_forked_datatable_databases(
));
continue;
}
if !dropped_dbs.insert((parent_pg.host.clone(), parent_pg.port, db_to_drop.clone())) {
continue;
}
match parent_pg.connect(Some(&db)).await {
Ok((client, connection)) => {
+2 -2
View File
@@ -34948,7 +34948,7 @@
},
"early_return": {
"type": "string",
"description": "JavaScript expression to return early from the flow"
"description": "Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."
},
"chat_input_enabled": {
"type": "boolean",
@@ -48161,7 +48161,7 @@
},
"early_return": {
"type": "string",
"description": "JavaScript expression to return early from the flow"
"description": "Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead."
},
"chat_input_enabled": {
"type": "boolean",
+11 -3
View File
@@ -11884,8 +11884,11 @@ paths:
early_return:
type: string
description: >-
JavaScript expression to return early from the
flow
Id of the step whose result a sync webhook returns
as soon as that step completes, while the rest of
the flow keeps running. When the step does not
run, or fails while the flow has a failure module,
the flow's final result is returned instead.
chat_input_enabled:
type: boolean
description: Whether this flow accepts chat-style input
@@ -19210,7 +19213,12 @@ paths:
description: Execution priority (higher numbers run first)
early_return:
type: string
description: JavaScript expression to return early from the flow
description: >-
Id of the step whose result a sync webhook returns as
soon as that step completes, while the rest of the flow
keeps running. When the step does not run, or fails
while the flow has a failure module, the flow's final
result is returned instead.
chat_input_enabled:
type: boolean
description: Whether this flow accepts chat-style input
+166 -10
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.833.0
version: 1.835.0
title: Windmill API
contact:
@@ -5184,6 +5184,107 @@ paths:
- has_instance_ai_config
- uses_instance_ai_config
/w/{workspace}/workspaces/settings_document:
get:
summary: get the workspace settings document
description: >-
The workspace's settings in the layout of the settings.yaml that `wmill sync` writes.
Admin only.
operationId: getWorkspaceSettingsDocument
x-mcp-tool: true
x-mcp-tool-include-query-params: []
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: secrets
description: >-
How secrets are written: `redacted` (the default) replaces each set secret with
`__SENSITIVE_AND_UNCHANGED__`; `encrypted` writes it encrypted with the workspace key,
prefixed with `$encrypted:`.
in: query
schema:
type: string
enum: [redacted, encrypted]
responses:
"200":
description: the settings document
content:
application/json:
schema:
type: object
additionalProperties: true
post:
summary: apply a workspace settings document
description: >-
Applies a settings document, each setting through the endpoint that owns it. Admin only.
operationId: applyWorkspaceSettingsDocument
x-mcp-tool: true
x-mcp-instructions: "Send only the top-level keys to change, each whole as getWorkspaceSettingsDocument returns it: a key the document omits is left as it is, and a key set to null clears it. Keep `__SENSITIVE_AND_UNCHANGED__` for a secret you were not given. Call with dry_run true first, show the user the changes and apply only once they agree."
x-mcp-tool-include-fields:
- document
- dry_run
x-mcp-tool-opaque-fields:
- document
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
document:
description: >-
The settings to apply, in the layout of the document. A secret given as
`__SENSITIVE_AND_UNCHANGED__` keeps its stored value; one prefixed with
`$encrypted:` is decrypted with the workspace key.
type: object
additionalProperties: true
replace:
description: >-
Clear the settings `wmill sync push` clears when settings.yaml omits them.
Otherwise a setting the document omits is left as it is.
type: boolean
dry_run:
description: Only compute the changes, without applying them.
type: boolean
required:
- document
responses:
"200":
description: the changes, applied unless dry_run
content:
application/json:
schema:
type: object
properties:
changes:
type: array
items:
type: object
properties:
key:
type: string
before: {}
after: {}
required:
- key
- before
- after
applied:
type: boolean
warnings:
type: array
items:
type: string
required:
- changes
- applied
/w/{workspace}/workspaces/get_copilot_settings_state:
get:
summary: get copilot settings state
@@ -5967,6 +6068,12 @@ paths:
schema:
type: integer
format: int64
- name: schema
in: query
required: false
description: apply only the pending migrations whose `-- schemas` annotation declares this schema
schema:
type: string
responses:
"200":
description: applied migrations
@@ -9367,6 +9474,12 @@ paths:
required: false
schema:
type: boolean
- name: starred_only
description: Only the `ai_agent` resources the caller starred
in: query
required: false
schema:
type: boolean
responses:
"200":
description: resource list
@@ -12783,7 +12896,7 @@ paths:
/w/{workspace}/runnables/list:
get:
summary: list runnables (scripts, flows, apps) merged, ordered and keyset-paginated
summary: list runnables (scripts, flows, apps, agents) merged, ordered and keyset-paginated
operationId: listRunnables
tags:
- script
@@ -12800,7 +12913,7 @@ paths:
- $ref: "#/components/parameters/OrderDesc"
- name: kinds
in: query
description: comma-separated subset of script,flow,app (default all)
description: comma-separated subset of script,flow,app,agent (default all)
schema:
type: string
- name: show_archived
@@ -12874,7 +12987,7 @@ paths:
- $ref: "#/components/parameters/WorkspaceId"
- name: kinds
in: query
description: comma-separated subset of script,flow,app (default all)
description: comma-separated subset of script,flow,app,agent (default all)
schema:
type: string
- name: include_without_main
@@ -15487,6 +15600,12 @@ paths:
type: integer
tag:
type: string
modules:
type: object
nullable: true
description: "Additional modules keyed by relative file path. Honored in preview only: a deployed app runs the modules stored with its runnable."
additionalProperties:
$ref: "#/components/schemas/ScriptModule"
required:
- content
- language
@@ -15515,6 +15634,14 @@ paths:
description: "Map of relative-import script path -> temp storage hash. Only honored for inline-script (raw_code) execution so app dev resolves those imports from not-yet-deployed local content."
additionalProperties:
type: string
on_behalf_of:
type: string
nullable: true
description: "Preview mode (force_viewer_static_fields set) only: run as this identity (an email, `u/<username>` or `g/<group>`) instead of the caller. Naming anyone other than yourself requires being an admin or a member of wm_deployers."
viewer_data_access:
type: boolean
nullable: true
description: "Preview mode only: the inline runnable's viewer_data_access policy setting (run mode reads it from the deployed policy)."
required:
- args
- component
@@ -24541,7 +24668,7 @@ paths:
type: string
favorite_kind:
type: string
enum: [flow, app, script, raw_app, asset]
enum: [flow, app, script, raw_app, asset, agent]
responses:
"200":
description: star item
@@ -24564,7 +24691,7 @@ paths:
type: string
favorite_kind:
type: string
enum: [flow, app, script, raw_app, asset]
enum: [flow, app, script, raw_app, asset, agent]
responses:
"200":
description: unstar item
@@ -30236,9 +30363,10 @@ components:
description: |
A row in the merged runnables listing. `type` is the discriminator;
kind-specific fields (hash/language/kind for scripts, execution_mode/
version for apps) are present only for that kind. `edited_at` is the
unified last-updated time (a script's created_at, a flow/app's edit
time).
version for apps, agent_memory/ws_specific for agents) are present only
for that kind. An agent is an `ai_agent` resource whose description is
reported as `summary`. `edited_at` is the unified last-updated time (a
script's created_at, a flow/app/agent's edit time).
properties:
type:
type: string
@@ -30246,6 +30374,7 @@ components:
- script
- flow
- app
- agent
path:
type: string
summary:
@@ -30313,6 +30442,11 @@ components:
version:
type: integer
format: int64
agent_memory:
description: agent-only. The agent's `memory` setting.
ws_specific:
type: boolean
description: agent-only. Whether the agent is workspace-specific, which keeps it out of deploys to other workspaces.
required:
- type
- path
@@ -32285,6 +32419,14 @@ components:
description: "Map of relative-import script path -> temp storage hash so the preview job resolves those imports from not-yet-deployed local content instead of the deployed script"
additionalProperties:
type: string
on_behalf_of:
type: string
nullable: true
description: "Run the preview as this identity (an email, `u/<username>` or `g/<group>`) instead of the caller. Naming anyone other than yourself requires being an admin or a member of wm_deployers."
viewer_data_access:
type: boolean
nullable: true
description: "Data table and object storage access as the caller rather than as the job's identity, as a full-code app's inline runnable flagged viewer_data_access does for its viewer."
required:
- args
@@ -36629,6 +36771,10 @@ components:
description: "Map of relative-import script path -> temp storage hash, propagated to each flow step so inline-script relative imports resolve from not-yet-deployed local content instead of the deployed script"
additionalProperties:
type: string
on_behalf_of:
type: string
nullable: true
description: "Run the preview as this identity (an email, `u/<username>` or `g/<group>`) instead of the caller. Naming anyone other than yourself requires being an admin or a member of wm_deployers."
required:
- value
@@ -36725,7 +36871,8 @@ components:
the app bundle so `windmill-client` calls run as the viewer. Must
be a subset of the server's curated allowlist (jobs:run, jobs:read,
users:read, resources:read, variables:read, flow_conversations:read,
flow_conversations:write).
flow_conversations:write). Every such token can call whoami without
consent, whether `users:read` is declared or not.
ListableApp:
type: object
@@ -37321,6 +37468,15 @@ components:
- ran
- not_run
- unknown
schemas:
type: array
description: the schemas its `-- schemas` annotation declares; absent when it applies to the whole database
items:
type: string
blocked_by:
type: integer
format: int64
description: when not run, the earliest pending migration that must be applied first; when run, the latest applied migration that must be rolled back first
DataTableSchema:
type: object
required: [datatable_name, schemas]
+113 -31
View File
@@ -61,7 +61,7 @@ use windmill_common::{
APP_WORKSPACED_ROUTE,
},
auth::{APP_EMBED_TOKEN_LABEL_PREFIX, RAW_APP_SDK_TOKEN_LABEL_PREFIX, TOKEN_PREFIX_LEN},
cache::{self, future::FutureCachedExt},
cache,
db::{DbWithOptAuthed, UserDB},
error::{to_anyhow, Error, JsonResult, Result},
jobs::{
@@ -1502,8 +1502,9 @@ async fn get_public_app_by_secret(
/// - `users:read` → `users/whoami` only.
/// - `folders:read` → `folders/listnames` only.
/// Plus two path-scoped scopes minted per app (see `mint_app_embed_token`):
/// - `apps:read:<path>` → the app's own definition (`apps/get/p/<path>`); no
/// `apps:write`, so management routes are unreachable.
/// - `apps:read:<path>` → the app's own definition (`apps/get/p/<path>` and
/// `apps/get/lite/<path>`); no `apps:write`, so management
/// routes are unreachable.
/// - `apps:run:<path>` → run THIS app's components (`execute_component`, which
/// re-checks the path); `apps_u/*` public-serving routes.
pub const APP_EMBED_SCOPES: [&str; 5] = [
@@ -4289,6 +4290,12 @@ pub struct ExecuteApp {
/// inline-script (raw_code, preview) execution so `wmill app dev` resolves
/// those imports from not-yet-deployed local content instead of deployed.
pub temp_script_refs: Option<HashMap<String, String>>,
/// Preview mode only: run as this identity instead of the caller, as the deployed app
/// would in publisher mode. See `jobs::preview_on_behalf_of`.
pub on_behalf_of: Option<String>,
/// Preview mode only: the inline runnable's `viewer_data_access` policy setting, which run
/// mode reads from the deployed policy instead.
pub viewer_data_access: Option<bool>,
}
fn digest(code: &str) -> String {
@@ -4602,6 +4609,7 @@ async fn execute_component(
force_viewer_allow_user_resources,
force_viewer_sensitive_inputs,
force_viewer_delete_after_secs,
viewer_data_access,
..
} => (
&Policy { stated_execution_mode: Some(ExecutionMode::Viewer), ..Default::default() },
@@ -4612,8 +4620,9 @@ async fn execute_component(
delete_after_secs: force_viewer_delete_after_secs,
sensitive_inputs: force_viewer_sensitive_inputs.unwrap_or_default(),
tag: None,
// A preview runs as the caller, whose roles these are either way.
viewer_data_access: false,
// Never for a job token: see `run_preview_script`.
viewer_data_access: opt_authed.as_ref().is_some_and(|a| a.job_id.is_none())
&& viewer_data_access.unwrap_or(false),
},
None,
),
@@ -4666,34 +4675,81 @@ async fn execute_component(
// `app_script` id. Its resolved `rawscript/<sha>` key must be present
// in the policy triggerables below — it must never resolve via the
// Viewer default fallback. Without `id` the caller supplies the code
// verbatim; with `id` it selects any `app_script` row by number (no
// app/workspace scoping), so both let a caller run code the publisher
// never pinned for this app.
// verbatim, which lets a caller run code the publisher never pinned
// for this app; with `id` the row must also belong to this app.
let is_inline_raw_code = payload.raw_code.is_some();
// The policy key covers only an inline script's own code, not the lock and modules
// its `app_script` row runs with, so it cannot tell the deployed row from an older
// one (or from id-less code, which runs with neither). Once the deployed version has
// its lite value, inline code runs only through the rows that value points at.
// Checked on every run: a deploy moves it.
if is_inline_raw_code {
let deployed = sqlx::query!(
"SELECT l.id IS NOT NULL as \"has_lite!\",
coalesce(jsonb_path_exists(l.value, '$.**.inlineScript.id ? (@ == $id)',
jsonb_build_object('id', $3::bigint)), false) as \"is_deployed_row!\"
FROM app a
LEFT JOIN app_version_lite l ON l.id = a.versions[array_upper(a.versions, 1)]
WHERE a.path = $1 AND a.workspace_id = $2",
path,
&w_id,
payload.id,
)
.fetch_optional(&db)
.await?;
if deployed.is_some_and(|d| d.has_lite && !d.is_deployed_row) {
return Err(Error::NotAuthorized(format!(
"This runnable is not part of the deployed version of app {path}, reload the app"
)));
}
}
// Compute the path for the triggerables map:
// - flow: `flow/<payload.path>`
// - script: `script/<payload.path>`
// - inline script: `rawscript/<sha256(raw_code.content)>`
let app_script_key: Arc<String>;
let path = match &payload {
// flow or script: just use the `payload.path`.
ExecuteApp { path: Some(path), .. } => path,
// inline script: without entry in the `app_script` table.
ExecuteApp { raw_code: Some(raw_code), id: None, .. } => &digest(&raw_code.content),
// inline script: with an entry in the `app_script` table.
// Scoped to this app: the policy key covers only the entrypoint's code, while the
// row also carries the modules (and lock) the job runs, so another app's row with
// the same entrypoint would run its own modules under this app's identity.
ExecuteApp { raw_code: Some(_), id: Some(id), .. } => {
let cache = cache::anon!({ u64 => Arc<String> } in "appscriptpath" <= 10000);
// `id` is unique, cache the result.
&*sqlx::query_scalar!(
"SELECT format('rawscript/%s', code_sha256) as \"path!: String\"
FROM app_script WHERE id = $1 LIMIT 1",
id
)
.fetch_one(&db)
.map_err(Into::<Error>::into)
.map_ok(Arc::new)
.cached(cache, *id as u64)
.await?
lazy_static! {
static ref APP_SCRIPT_KEY_CACHE: cache::Cache<(String, String, i64), Arc<String>> =
cache::Cache::new(10000);
}
let cache_key = (w_id.to_string(), path.to_string(), *id);
app_script_key = match APP_SCRIPT_KEY_CACHE.get(&cache_key) {
Some(key) => key,
None => {
let key = sqlx::query_scalar!(
"SELECT format('rawscript/%s', s.code_sha256) as \"path!: String\"
FROM app_script s JOIN app a ON a.id = s.app
WHERE s.id = $1 AND a.path = $2 AND a.workspace_id = $3",
id,
path,
&w_id,
)
.fetch_optional(&db)
.await?
.map(Arc::new)
.ok_or_else(|| {
Error::NotAuthorized(format!(
"App script {id} does not belong to app {path}"
))
})?;
// A row never changes app, so a hit stays valid.
APP_SCRIPT_KEY_CACHE.insert(cache_key, key.clone());
key
}
};
&**app_script_key
}
_ => unreachable!(),
};
@@ -4802,8 +4858,19 @@ async fn execute_component(
}
}
let (username, permissioned_as, email) =
let (username, mut permissioned_as, mut email) =
get_on_behalf_details_from_policy_and_authed(&policy, &opt_authed, &w_id, &db).await?;
let preview_obo = match opt_authed.as_ref().filter(|_| is_preview) {
Some(authed) => {
crate::jobs::preview_on_behalf_of(authed, payload.on_behalf_of.as_deref(), &w_id, &db)
.await?
}
None => None,
};
if let Some(obo) = preview_obo.as_ref() {
permissioned_as = obo.permissioned_as.clone();
email = obo.email.clone();
}
let resolved_delete_secs =
resolve_delete_after_secs(None, policy_triggerables.delete_after_secs);
@@ -4914,16 +4981,16 @@ async fn execute_component(
.await?;
}
}
// Identity is already resolved to the requesting user in preview mode (the
// policy is forced to `ExecutionMode::Viewer`, so the job runs as the
// caller). The enqueue stays root-isolated as before — switching the insert
// to user-RLS is not what contains the bypass (the auth guards above are)
// and would add unnecessary breakage risk to the legitimate editor flow.
// In preview mode the identity is the caller (the policy is forced to
// `ExecutionMode::Viewer`), or the gated `on_behalf_of` resolved above. The
// enqueue stays root-isolated — switching the insert to user-RLS is not what
// contains the bypass (the auth guards above are) and would add unnecessary
// breakage risk to the legitimate editor flow.
let tx = PushIsolationLevel::IsolatedRoot(db.clone());
// An app component runs on-behalf of the APP identity (resolved above), never
// the referenced runnable's own `on_behalf_of` — else a Viewer-mode app could
// execute as that identity and a preview would run as it, not the caller.
// execute as that identity and a preview would run as it, not as the editor chose.
// (Direct `/jobs/run` still honors a runnable's `on_behalf_of`.)
let (email, permissioned_as) = (email.as_str(), permissioned_as);
@@ -4932,8 +4999,8 @@ async fn execute_component(
// Stamp app-origination (trigger_kind='app' + trigger=<app path>), the signal
// the deployed-app S3 provenance gate trusts (unforgeable via `/jobs/run`).
// Deployed runs only: a preview runs as the caller and is read back as the caller
// (viewer-scoped), so it must never be app-provenanced (else it could forge one).
// Deployed runs only: a preview runs request-supplied code and is read back as the
// caller (viewer-scoped), so it must never be app-provenanced (else it could forge one).
let app_trigger =
(!is_preview).then(|| TriggerMetadata::new(Some(path.to_string()), JobTriggerKind::App));
@@ -4941,6 +5008,7 @@ async fn execute_component(
Some(authed) => windmill_api_auth::caller_scope_ceiling(&db, authed).await?,
None => None,
};
let preview_obo_author = preview_obo.as_ref().and(opt_authed.clone());
let (uuid, mut tx) = push(
&db,
tx,
@@ -4980,6 +5048,11 @@ async fn execute_component(
)
.await?;
if let (Some(obo), Some(authed)) = (preview_obo.as_ref(), preview_obo_author.as_ref()) {
crate::jobs::audit_preview_on_behalf_of(&mut tx, authed, &w_id, uuid, Some(path), obo)
.await?;
}
if policy_triggerables.viewer_data_access && is_inline {
sqlx::query!(
"UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1",
@@ -6706,8 +6779,8 @@ mod embed_token_tests {
"POST",
),
("/api/w/test/jobs/restart/f/some-uuid", "POST"),
// `users:read` is presented to the viewer as "read your identity", so
// the workspace member directory must stay out of reach.
// `users:read` reaches whoami only: the workspace member directory must
// stay out of reach.
("/api/w/test/users/list", "GET"),
("/api/w/test/users/list_usage", "GET"),
("/api/w/test/users/username_to_email/admin", "GET"),
@@ -6718,6 +6791,15 @@ mod embed_token_tests {
"SDK token should deny {method} {path}"
);
}
// Whoami needs no declared scope.
let jobs_only = [
"jobs:run".to_string(),
windmill_api_auth::scopes::RAW_APP_SDK_SENTINEL.to_string(),
];
assert!(
check_scopes_for_route(Some(&jobs_only[..]), "/api/w/test/users/whoami", "GET").is_ok()
);
}
/// The token carries path-scoped `apps:run:<own path>` and `apps:read:<own path>`
+6
View File
@@ -113,6 +113,12 @@ lazy_static::lazy_static! {
(20260909163047, include_str!(
"../../migrations/20260909163047_workspace_delete_cascade_indexes.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
(20261009173546, include_str!(
"../../migrations/20261009173546_agent_owner_prefix_index.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY")),
(20261015114712, include_str!(
"../../migrations/20261015114712_queue_workspace_scheduled_for_index.up.sql"
).replace("CREATE INDEX", "CREATE INDEX CONCURRENTLY").replace("DROP INDEX", "DROP INDEX CONCURRENTLY")),
// The widening fails every query the previous release's still-running
// servers prepared on the column; `variables::widen_variable_value` does it
// once they are gone.
+13 -29
View File
@@ -24,7 +24,7 @@ use windmill_common::{
users::resolve_username_to_email,
utils::{check_proper_path, require_admin, strip_json_nul},
variables::{build_crypt, encrypt},
workspaces::{operator_builder_rights, BuilderKind},
workspaces::operator_can_draft,
};
pub fn workspaced_service() -> Router {
@@ -109,12 +109,6 @@ async fn list_drafts(
Path(w_id): Path<String>,
Query(query): Query<ListDraftsQuery>,
) -> Result<Json<Vec<DraftListItem>>> {
// Without builder rights an operator has no drafts of their own (they can't write any, see
// `require_can_write_path`), so this list is always empty for them. They can still READ some
// collaborators' drafts via `/drafts/get`.
if authed.is_operator && !operator_builder_rights(&db, &w_id).await?.any() {
return Ok(Json(vec![]));
}
let all_users = query.all_users.unwrap_or(false);
// Only honor `compare_to_workspace` when it is genuinely this workspace's
// parent (a fork comparing against its source). Any other value is dropped
@@ -1252,8 +1246,7 @@ fn table_for_kind(kind: UserDraftItemKind) -> Option<&'static str> {
}
/// Resolves to `Ok(())` if `authed` may SAVE a draft at `path`. Operators are
/// rejected, except for flow drafts in a workspace that granted them builder
/// rights. Two layers:
/// rejected, except for a kind they could deploy (`operator_can_draft`). Two layers:
/// 1. Claim-based namespace rules (admin, own `u/`, member `g/`, writable
/// `f/`) — mirror what RLS reads from the same JWT claims, and are the
/// ENTIRE check for draft-only paths (no deployed row for RLS to use).
@@ -1270,26 +1263,17 @@ pub(crate) async fn require_can_write_path(
if authed.is_admin {
return Ok(());
}
// Operators are read-only and never WRITE drafts, except of a kind the workspace granted them
// the matching builder right for: a flows-only workspace must not get raw-app drafts through
// here. Read access is deliberately asymmetric:
// `require_can_read_path` has no operator block, so an operator can still READ a draft they
// can read via `/drafts/get`, mirroring their read access to deployed content. Intended.
if authed.is_operator {
let allowed = match kind {
UserDraftItemKind::Flow => Some(BuilderKind::Flows),
UserDraftItemKind::RawApp => Some(BuilderKind::Apps),
_ => None,
};
let granted = match allowed {
Some(kind) => operator_builder_rights(db, w_id).await?.has(kind),
None => false,
};
if !granted {
return Err(Error::PermissionDenied(
"operators cannot save drafts".to_string(),
));
}
// Operators WRITE drafts only of a kind they could deploy: resources and variables always,
// flows and full-code apps under their builder right, schedules and triggers under the manage
// rights. The kind has to be checked, or one right would open drafts of kinds it says nothing
// about. Read access is deliberately asymmetric: `require_can_read_path` has no operator block,
// so an operator can still READ a draft they can read via `/drafts/get`, mirroring their read
// access to deployed content. Intended.
if authed.is_operator && !operator_can_draft(db, w_id, kind).await? {
return Err(Error::PermissionDenied(format!(
"operators cannot save {} drafts in this workspace",
kind.as_str()
)));
}
// Cheap claim-based namespace checks first: they evaluate the same JWT
// claims RLS reads, so the outcome matches the policies while sparing the
+1
View File
@@ -32,6 +32,7 @@ pub enum FavoriteKind {
#[allow(non_camel_case_types)]
Raw_App,
Asset,
Agent,
}
#[derive(Deserialize)]
pub struct Favorite {
+171 -13
View File
@@ -115,7 +115,9 @@ use windmill_common::{
add_virtual_items_if_necessary, resolve_maybe_value, ApprovalSkin, FlowModule, FlowValue,
Suspend,
},
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
jobs::{
script_path_to_payload, CompletedJob, JobKind, JobPayload, OnBehalfOf, QueuedJob, RawCode,
},
oauth2::HmacSha256,
query_builders,
scripts::{ScriptHash, ScriptLang},
@@ -6736,6 +6738,12 @@ struct Preview {
/// preview job resolves those imports from not-yet-deployed local content
/// (uploaded to raw_script_temp) instead of the deployed script.
temp_script_refs: Option<HashMap<String, String>>,
/// Run the preview as this identity (an email or a `u/`/`g/` principal) instead of the
/// caller. See `preview_on_behalf_of`.
on_behalf_of: Option<String>,
/// Data table and object storage access as the caller rather than as the job's identity,
/// as a full-code app's inline runnable flagged `viewer_data_access` does for its viewer.
viewer_data_access: Option<bool>,
}
#[cfg(feature = "run_inline")]
@@ -6768,6 +6776,8 @@ struct PreviewFlow {
/// each flow step so inline-script relative imports resolve from
/// not-yet-deployed local content instead of the deployed script.
temp_script_refs: Option<HashMap<String, String>>,
/// See `Preview::on_behalf_of`.
on_behalf_of: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -8930,6 +8940,112 @@ fn operator_preview_refusal(job_id: Option<Uuid>) -> error::Error {
error::Error::PermissionDenied(reason.to_string())
}
/// The identity a preview asks to run as, or `None` when it runs as the caller.
///
/// A preview is request-supplied code, so running it as someone else is impersonation, not
/// "running their runnable": only those who may set a runnable's `on_behalf_of` on deploy
/// (admins and `wm_deployers`) may ask for it. Naming oneself is always allowed. Operators are
/// refused even in `wm_deployers`: the datatable-query exemption lets them reach this route
/// without being able to deploy anything.
pub(crate) async fn preview_on_behalf_of(
authed: &ApiAuthed,
requested: Option<&str>,
w_id: &str,
db: &DB,
) -> error::Result<Option<OnBehalfOf>> {
let Some(requested) = requested.filter(|s| !s.is_empty()) else {
return Ok(None);
};
let own = username_to_permissioned_as(&authed.username);
if requested == own || requested == authed.email {
return Ok(None);
}
if authed.is_operator || !windmill_common::can_preserve_on_behalf_of(authed) {
return Err(Error::PermissionDenied(
"Only admins and members of wm_deployers can run a preview on behalf of another user"
.to_string(),
));
}
let permissioned_as =
windmill_common::resolve_on_behalf_of(None, Some(requested), true, authed, w_id, db)
.await?
.unwrap_or(own.clone());
if permissioned_as == own {
return Ok(None);
}
let email =
windmill_common::users::get_email_from_permissioned_as_uncached(&permissioned_as, w_id, db)
.await?;
Ok(Some(OnBehalfOf { email, permissioned_as }))
}
/// Audits the impersonation and opens the job's own logs with it, so whoever reads the run sees
/// who it ran as and who asked, not only the identity columns.
pub(crate) async fn audit_preview_on_behalf_of(
tx: &mut Transaction<'_, Postgres>,
authed: &ApiAuthed,
w_id: &str,
job_id: Uuid,
path: Option<&str>,
obo: &OnBehalfOf,
) -> error::Result<()> {
let mut params = HashMap::from([("on_behalf_of", obo.permissioned_as.as_str())]);
if let Some(path) = path {
params.insert("path", path);
}
audit_log(
&mut **tx,
authed,
"jobs.run.preview_on_behalf_of",
ActionKind::Execute,
w_id,
Some(&job_id.to_string()),
Some(params),
)
.await?;
sqlx::query!(
"INSERT INTO job_logs (logs, job_id, workspace_id) VALUES ($1, $2, $3) ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)",
format!(
"Preview run on behalf of {} ({}), requested by {}\n",
obo.permissioned_as, obo.email, authed.email
),
job_id,
w_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
/// The push identity and isolation level for a preview: the caller's own, or the requested
/// `on_behalf_of`'s, pushed through the root pool like a deployed `on_behalf_of` run.
fn preview_push_identity(
authed: &ApiAuthed,
user_db: &UserDB,
db: &DB,
obo: Option<&OnBehalfOf>,
) -> (
String,
String,
Option<windmill_common::db::Authed>,
PushIsolationLevel<'static>,
) {
match obo {
Some(obo) => (
obo.email.clone(),
obo.permissioned_as.clone(),
None,
PushIsolationLevel::IsolatedRoot(db.clone()),
),
None => (
authed.email.clone(),
username_to_permissioned_as(&authed.username),
Some(authed.clone().into()),
PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()),
),
}
}
async fn run_preview_script(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -8964,7 +9080,13 @@ async fn run_preview_script(
require_path_read_access_for_preview(&authed, &preview.path)?;
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(preview.tag.clone());
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let obo = preview_on_behalf_of(&authed, preview.on_behalf_of.as_deref(), &w_id, &db).await?;
let (email, permissioned_as, push_authed, tx) =
preview_push_identity(&authed, &user_db, &db, obo.as_ref());
let audited_path = preview.path.clone();
// A job token's end user is whoever ran the calling job, who never consented to that job's
// code reading data as them: only a direct caller, reading as themselves, may ask for it.
let viewer_data_access = authed.job_id.is_none() && preview.viewer_data_access.unwrap_or(false);
let preview_args = preview.args.unwrap_or_default();
let mut extra = HashMap::new();
@@ -9010,8 +9132,8 @@ async fn run_preview_script(
},
push_args,
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
&email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
@@ -9028,7 +9150,7 @@ async fn run_preview_script(
run_query.timeout,
None,
None,
Some(&authed.clone().into()),
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(None),
@@ -9036,6 +9158,19 @@ async fn run_preview_script(
scope_ceiling.as_deref(),
)
.await?;
let mut tx = tx;
if let Some(obo) = obo.as_ref() {
audit_preview_on_behalf_of(&mut tx, &authed, &w_id, uuid, audited_path.as_deref(), obo)
.await?;
}
if viewer_data_access {
sqlx::query!(
"UPDATE job_perms SET viewer_data_access = true WHERE job_id = $1",
uuid
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok((StatusCode::CREATED, uuid.to_string()))
@@ -9387,7 +9522,11 @@ async fn run_bundle_preview_script(
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(preview.tag.clone());
let ltx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let obo =
preview_on_behalf_of(&authed, preview.on_behalf_of.as_deref(), &w_id, &db).await?;
let (email, permissioned_as, push_authed, ltx) =
preview_push_identity(&authed, &user_db, &db, obo.as_ref());
let audited_path = preview.path.clone();
let args = preview.args.unwrap_or_default();
@@ -9435,8 +9574,8 @@ async fn run_bundle_preview_script(
}),
push_args,
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
&email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
@@ -9453,7 +9592,7 @@ async fn run_bundle_preview_script(
run_query.timeout,
None,
None,
Some(&authed.clone().into()),
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(None),
@@ -9461,6 +9600,18 @@ async fn run_bundle_preview_script(
scope_ceiling.as_deref(),
)
.await?;
let mut ntx = ntx;
if let Some(obo) = obo.as_ref() {
audit_preview_on_behalf_of(
&mut ntx,
&authed,
&w_id,
uuid,
audited_path.as_deref(),
obo,
)
.await?;
}
job_id = Some(uuid);
tx = Some(ntx);
}
@@ -10138,7 +10289,9 @@ async fn run_preview_flow_job(
}
let scheduled_for = run_query.get_scheduled_for(&db).await?;
let tag = run_query.tag.clone().or(raw_flow.tag.clone());
let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into());
let obo = preview_on_behalf_of(&authed, raw_flow.on_behalf_of.as_deref(), &w_id, &db).await?;
let (email, permissioned_as, push_authed, tx) =
preview_push_identity(&authed, &user_db, &db, obo.as_ref());
let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false);
let flow_path = raw_flow.path.clone().unwrap_or_default();
@@ -10171,8 +10324,8 @@ async fn run_preview_flow_job(
},
PushArgs::from(&flow_args),
authed.display_username(),
&authed.email,
username_to_permissioned_as(&authed.username),
&email,
permissioned_as,
authed.token_prefix.as_deref(),
authed.username_override.as_deref(),
scheduled_for,
@@ -10189,7 +10342,7 @@ async fn run_preview_flow_job(
None,
None,
None,
Some(&authed.clone().into()),
push_authed.as_ref(),
false,
end_user_email,
authed.trigger_or_fallback(None),
@@ -10198,6 +10351,11 @@ async fn run_preview_flow_job(
)
.await?;
if let Some(obo) = obo.as_ref() {
let audited_path = Some(flow_path.as_str()).filter(|p| !p.is_empty());
audit_preview_on_behalf_of(&mut tx, &authed, &w_id, uuid, audited_path, obo).await?;
}
// Set memory_id if provided (for agent memory)
if let Some(memory_id) = run_query.memory_key(&w_id, &flow_path) {
set_flow_memory_id(&mut tx, uuid, memory_id).await?;
+1
View File
@@ -209,6 +209,7 @@ mod workspaces;
#[cfg(feature = "private")]
pub mod workspaces_ee;
mod workspaces_export;
mod workspace_settings_document;
#[cfg(feature = "mcp")]
mod mcp_tools;
@@ -56,6 +56,46 @@ pub fn all_tools() -> Vec<EndpointTool> {
query_field_renames: None,
body_field_renames: None,
},
EndpointTool {
name: Cow::Borrowed("getWorkspaceSettingsDocument"),
description: Cow::Borrowed("get the workspace settings document: The workspace's settings in the layout of the settings.yaml that `wmill sync` writes. Admin only"),
instructions: Cow::Borrowed(""),
path: Cow::Borrowed("/w/{workspace}/workspaces/settings_document"),
method: Cow::Borrowed("GET"),
path_params_schema: None,
query_params_schema: None,
body_schema: None,
query_field_renames: None,
body_field_renames: None,
},
EndpointTool {
name: Cow::Borrowed("applyWorkspaceSettingsDocument"),
description: Cow::Borrowed("apply a workspace settings document: Applies a settings document, each setting through the endpoint that owns it. Admin only"),
instructions: Cow::Borrowed("Send only the top-level keys to change, each whole as getWorkspaceSettingsDocument returns it: a key the document omits is left as it is, and a key set to null clears it. Keep `__SENSITIVE_AND_UNCHANGED__` for a secret you were not given. Call with dry_run true first, show the user the changes and apply only once they agree."),
path: Cow::Borrowed("/w/{workspace}/workspaces/settings_document"),
method: Cow::Borrowed("POST"),
path_params_schema: None,
query_params_schema: None,
body_schema: Some(serde_json::json!({
"type": "object",
"properties": {
"document": {
"type": "object",
"description": "The settings to apply, in the layout of the document. A secret given as `__SENSITIVE_AND_UNCHANGED__` keeps its stored value; one prefixed with `$encrypted:` is decrypted with the workspace key."
},
"dry_run": {
"description": "Only compute the changes, without applying them.",
"type": "boolean"
}
},
"required": [
"document"
],
"minProperties": 1
})),
query_field_renames: None,
body_field_renames: None,
},
EndpointTool {
name: Cow::Borrowed("listDataMetrics"),
description: Cow::Borrowed("list declared measures and dimensions on DuckLake tables: Call this before writing any aggregate query over a DuckLake table. A declared measure is the canonical definition of that number, and reproducing it yourself will silently disagree with it (a `revenue` measure typically excludes refunds or test rows). Filter by `table` for one table's declarations, or by `path_prefix` (e.g. `f/analytics`) for everything declared under a folder; omit both to browse the whole catalog. Results are keyset-paged: a full page may mean more remain, so continue with the `cursor_*` params rather than assuming a measure does not exist. Use each returned `expr` verbatim, and when a measure has a `filter` write it as `expr FILTER (WHERE filter)` so measures with different predicates can share one GROUP BY. If a number you need has no declared measure, write your own aggregate as usual. Results are limited to declarations whose producing script the caller can read"),
@@ -559,6 +599,10 @@ pub fn all_tools() -> Vec<EndpointTool> {
"include_draft_only": {
"type": "boolean",
"description": "When true, append per-user draft resources whose path has\nno deployed resource. Synthesized rows carry\n`draft_only: true`.\n"
},
"starred_only": {
"type": "boolean",
"description": "Only the `ai_agent` resources the caller starred"
}
},
"required": []
@@ -1296,7 +1340,7 @@ is, a different one moves it there and archives the old path"),
"items": {
"type": "string"
},
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n"
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n"
}
}
}
@@ -1413,7 +1457,7 @@ is, a different one moves it there and archives the old path"),
"items": {
"type": "string"
},
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write).\n"
"description": "Raw apps: author-declared scopes for the frontend SDK token. Takes effect only when `sandbox` is also true — an unsandboxed bundle runs with the viewer's own session, so no token is advertised or minted for it and this list stays inert. On a sandboxed app a non-empty list lets viewers mint (after consenting) a short-lived token carrying their own identity restricted to these scopes, handed to the app bundle so `windmill-client` calls run as the viewer. Must be a subset of the server's curated allowlist (jobs:run, jobs:read, users:read, resources:read, variables:read, flow_conversations:read, flow_conversations:write). Every such token can call whoami without consent, whether `users:read` is declared or not.\n"
}
}
},
+128 -41
View File
@@ -7,15 +7,17 @@
*/
//! Unified, keyset-paginated listing of a workspace's runnables (scripts,
//! flows, apps) merged into one globally-ordered stream. The homepage uses it
//! so a chosen order (recently updated / oldest / name) is correct and complete
//! across all three kinds at any workspace size, instead of client-sorting a
//! flows, apps, and the `ai_agent` resources listed as agents) merged into one
//! globally-ordered stream. The homepage uses it so a chosen order (recently
//! updated / oldest / name) is correct and complete across every kind at any
//! workspace size, instead of client-sorting a
//! per-kind capped window.
//!
//! Efficiency: each kind is a UNION ALL branch ordered by an index on
//! `(workspace_id, archived, <sort key>)` (created_at / edited_at, or the lowered
//! summary-or-path expression for name orders); Postgres merges the ordered
//! branches and stops at the page limit. Pagination is keyset — a
//! branches and stops at the page limit. Agents, which are few, are instead
//! reached through the partial `idx_resource_agent_owner_prefix` and sorted whole. Pagination is keyset — a
//! `(sort_key, path, kind, tiebreak)` cursor, where `tiebreak` (a script's hash,
//! 0 for flow/app) is a stable final key that keeps the order total even when rows
//! tie on (sort_key, path, kind) — so deep pages don't re-scan. Visibility is
@@ -51,7 +53,7 @@ struct ListRunnablesQuery {
order_by: Option<String>,
/// Descending when true (default true).
order_desc: Option<bool>,
/// Comma-separated subset of `script,flow,app`; omitted means all.
/// Comma-separated subset of `script,flow,app,agent`; omitted means all.
kinds: Option<String>,
show_archived: Option<bool>,
/// Include library scripts (no runnable main). Ignored for flows/apps.
@@ -80,7 +82,7 @@ struct ListRunnablesQuery {
#[derive(Serialize, sqlx::FromRow)]
struct RunnableItem {
#[serde(rename = "type")]
kind: String, // 'script' | 'flow' | 'app'
kind: String, // 'script' | 'flow' | 'app' | 'agent'
path: String,
#[serde(skip_serializing_if = "Option::is_none")]
summary: Option<String>,
@@ -130,6 +132,11 @@ struct RunnableItem {
id: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
version: Option<i64>,
// agent-only: the `memory` setting, which decides whether the agent opens as a chat.
#[serde(skip_serializing_if = "Option::is_none")]
agent_memory: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
ws_specific: Option<bool>,
// sort keys, echoed into the cursor (not serialized to the client)
#[serde(skip)]
sort_time: chrono::DateTime<chrono::Utc>,
@@ -247,7 +254,7 @@ fn scope_path_predicate(
}
}
/// The three UNION-ALL branch SELECTs, each projecting the shared `RunnableItem`
/// The UNION-ALL branch SELECTs, each projecting the shared `RunnableItem`
/// column set (NULL for columns that don't apply to that kind). `$1`=workspace,
/// `$2`=username (favorites), `$3`=email (drafts). Kind-specific and per-request
/// WHERE fragments are appended by the caller.
@@ -255,6 +262,7 @@ struct Branches {
script: String,
flow: String,
app: String,
agent: String,
}
fn branch_sqls() -> Branches {
@@ -281,6 +289,7 @@ fn branch_sqls() -> Branches {
o.codebase IS NOT NULL as use_codebase, \
(o.lock_error_logs IS NOT NULL) as has_deploy_errors, NULL::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific, \
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, o.hash as tiebreak \
FROM script o \
LEFT JOIN favorite ON favorite.favorite_kind = 'script' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
@@ -298,6 +307,7 @@ fn branch_sqls() -> Branches {
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, \
(o.value->>'chat_input_enabled')::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific, \
o.edited_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
FROM flow o \
LEFT JOIN favorite ON favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
@@ -315,6 +325,7 @@ fn branch_sqls() -> Branches {
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \
av.raw_app, o.policy->>'execution_mode' as execution_mode, o.id, \
o.versions[array_upper(o.versions, 1)] as version, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific, \
COALESCE(av.created_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
FROM app o \
LEFT JOIN favorite ON favorite.favorite_kind = 'app' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
@@ -323,25 +334,68 @@ fn branch_sqls() -> Branches {
draft_users = draft_users("d.typ IN ('app', 'raw_app')")
);
Branches { script, flow, app }
// An agent is an `ai_agent` resource; the subquery renames its description to the
// `summary` the shared search and name sort read.
let agent = format!(
"SELECT 'agent' as kind, o.path, o.summary, o.workspace_id, o.extra_perms, \
favorite.path IS NOT NULL as starred, false as archived, \
draft.email IS NOT NULL as is_draft, NULL::bool as draft_only, NULL::text as draft_path, \
{draft_users}, o.labels, folder_labels(o.workspace_id, o.path) as inherited_labels, \
NULL::bool as ws_error_handler_muted, o.edited_at, \
NULL::bigint as hash, NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, NULL::bool as chat_input_enabled, \
NULL::bool as raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.agent_memory, ws_specific.path IS NOT NULL as ws_specific, \
COALESCE(o.edited_at, 'epoch'::timestamptz) as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.path)) as sort_name, 0::bigint as tiebreak \
FROM (SELECT workspace_id, path, description as summary, extra_perms, labels, edited_at, \
value->'memory' as agent_memory \
FROM resource WHERE resource_type = 'ai_agent') o \
LEFT JOIN favorite ON favorite.favorite_kind = 'agent' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = $2 \
LEFT JOIN draft ON draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'resource' AND draft.email = $3 \
LEFT JOIN ws_specific ON ws_specific.path = o.path AND ws_specific.workspace_id = o.workspace_id AND ws_specific.item_kind = 'resource'",
draft_users = draft_users("d.typ = 'resource'")
);
Branches { script, flow, app, agent }
}
/// The draft rows a kind lists and the table whose rows make a draft not draft-only.
fn draft_kind_source(kind: &str) -> (&'static str, &'static str) {
match kind {
"script" => ("d.typ = 'script'", "script"),
"flow" => ("d.typ = 'flow'", "flow"),
// Typed by the draft itself, as the resource listing filters its draft-only rows.
"agent" => (
"d.typ = 'resource' AND d.value->>'resource_type' = 'ai_agent'",
"resource",
),
_ => ("d.typ IN ('app', 'raw_app')", "app"),
}
}
/// Where a draft records the path its editor has staged. Scripts and resources bind the
/// Path widget to the item's own `path`, so it round-trips through the draft JSON's
/// `path`; flows and apps write a separate `draft_path` only when it differs from the
/// deployed one. See scripts.rs.
fn draft_typed_path(kind: &str) -> &'static str {
if kind == "script" || kind == "agent" {
"path"
} else {
"draft_path"
}
}
/// The draft-only branch for a kind: the caller's drafts at paths carrying no
/// deployed row, projected into the same column set as `branch_sqls` so they
/// sort, search and paginate as ordinary rows. Same `$1`/`$2`/`$3` contract.
fn draft_branch_sql(kind: &str) -> String {
let (typ_pred, deployed) = match kind {
"script" => ("d.typ = 'script'", "script"),
"flow" => ("d.typ = 'flow'", "flow"),
_ => ("d.typ IN ('app', 'raw_app')", "app"),
};
// Scripts bind the Path widget to `script.path`, so the typed path round-trips
// through the draft JSON's own `path`; flows and apps write a separate
// `draft_path` only when it differs from the deployed one. See scripts.rs.
let typed_path = if kind == "script" {
"path"
let (typ_pred, deployed) = draft_kind_source(kind);
let typed_path = draft_typed_path(kind);
// A resource draft carries the description where the other kinds carry a summary.
let summary_field = if kind == "agent" {
"description"
} else {
"draft_path"
"summary"
};
// `auto_kind` is only what the editor stamped into the draft — a `// pipeline`
// annotation is not re-derived from the content here, unlike the per-kind
@@ -351,7 +405,8 @@ fn draft_branch_sql(kind: &str) -> String {
"script" => {
"d.value->>'language' as language, d.value->>'kind' as script_kind, \
d.value->>'auto_kind' as auto_kind, false as raw_app, \
NULL::bool as chat_input_enabled"
NULL::bool as chat_input_enabled, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific"
}
// Type-guarded rather than a bare `::bool` cast: draft JSON is stored
// unvalidated, so a malformed value must yield NULL, not abort the list,
@@ -360,11 +415,20 @@ fn draft_branch_sql(kind: &str) -> String {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
false as raw_app, \
CASE WHEN json_typeof(d.value->'value'->'chat_input_enabled') = 'boolean' \
THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled"
THEN (d.value->'value'->>'chat_input_enabled')::bool END as chat_input_enabled, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific"
}
"agent" => {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
false as raw_app, NULL::bool as chat_input_enabled, \
(d.value->'args'->'memory')::jsonb as agent_memory, \
CASE WHEN json_typeof(d.value->'wsSpecific') = 'boolean' \
THEN (d.value->>'wsSpecific')::bool END as ws_specific"
}
_ => {
"NULL::text as language, NULL::text as script_kind, NULL::text as auto_kind, \
(d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled"
(d.typ = 'raw_app') as raw_app, NULL::bool as chat_input_enabled, \
NULL::jsonb as agent_memory, NULL::bool as ws_specific"
}
};
format!(
@@ -378,11 +442,12 @@ fn draft_branch_sql(kind: &str) -> String {
NULL::bigint as hash, o.language, o.script_kind, o.auto_kind, \
NULL::bool as use_codebase, NULL::bool as has_deploy_errors, o.chat_input_enabled, \
o.raw_app, NULL::text as execution_mode, NULL::bigint as id, NULL::bigint as version, \
o.agent_memory, o.ws_specific, \
o.created_at as sort_time, lower(COALESCE(NULLIF(o.summary, ''), o.draft_path, o.path)) as sort_name, 0::bigint as tiebreak \
FROM ( \
SELECT DISTINCT ON (d.path) d.workspace_id, d.path, d.created_at, \
d.email IS NULL as legacy, \
COALESCE(d.value->>'summary', '') as summary, \
COALESCE(d.value->>'{summary_field}', '') as summary, \
NULLIF(NULLIF(d.value->>'{typed_path}', ''), d.path) as draft_path, \
{kind_cols} \
FROM draft d \
@@ -397,6 +462,8 @@ fn draft_branch_sql(kind: &str) -> String {
)
}
const ALL_KINDS: [&str; 4] = ["script", "flow", "app", "agent"];
async fn list_runnables(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
@@ -416,16 +483,16 @@ async fn list_runnables(
};
let mut kinds: Vec<&str> = match q.kinds.as_deref() {
None | Some("") => vec!["script", "flow", "app"],
None | Some("") => ALL_KINDS.to_vec(),
Some(csv) => csv
.split(',')
.map(|s| s.trim())
.filter(|s| ["script", "flow", "app"].contains(s))
.filter(|s| ALL_KINDS.contains(s))
.collect(),
};
// Apps carry no `archived` column and are never listed as archived.
// Apps and agents carry no `archived` column and are never listed as archived.
if show_archived {
kinds.retain(|k| *k != "app");
kinds.retain(|k| *k != "app" && *k != "agent");
}
let branches = branch_sqls();
@@ -533,6 +600,11 @@ async fn list_runnables(
} else {
None
};
let agent_scope = if kinds.contains(&"agent") {
scope_path_predicate(&authed, "resources", "o", 3, &mut binds)
} else {
None
};
// Per-kind archived predicate (scripts/flows have the column; apps don't and
// are excluded from the archived view).
@@ -570,6 +642,10 @@ async fn list_runnables(
if let Some(s) = &app_scope {
app_extras.push(s.clone());
}
let mut agent_extras: Vec<String> = vec![];
if let Some(s) = &agent_scope {
agent_extras.push(s.clone());
}
// Draft-only rows are the caller's own work in progress: never archived, so they
// have no place in the archived view, and carrying no labels of their own they are
@@ -589,6 +665,7 @@ async fn list_runnables(
let scope = match kind {
"script" => &script_scope,
"flow" => &flow_scope,
"agent" => &agent_scope,
_ => &app_scope,
};
// The lib filter reads the same projected `auto_kind`, so a draft-only library
@@ -649,6 +726,7 @@ async fn list_runnables(
"script" => (&branches.script, &script_extras),
"flow" => (&branches.flow, &flow_extras),
"app" => (&branches.app, &app_extras),
"agent" => (&branches.agent, &agent_extras),
_ => return None,
};
Some(build_branch(
@@ -708,7 +786,7 @@ async fn list_runnables(
// No draft branch here: a draft-only path has no favorite row (the UI won't let
// you star one), so it would scan the caller's whole draft slice per kind to
// return nothing. The main stream below takes them unfiltered instead.
let starred_branches: Vec<String> = ["script", "flow", "app"]
let starred_branches: Vec<String> = ALL_KINDS
.iter()
.filter_map(|k| branch_for(k, Some(true), None, None))
.collect();
@@ -728,10 +806,10 @@ async fn list_runnables(
// Main paged stream: non-starred rows (starred were pinned on the first page above).
let main_fav = Some(false);
let ns_branches: Vec<String> =
["script", "flow", "app"]
ALL_KINDS
.iter()
.filter_map(|k| branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page)))
.chain(["script", "flow", "app"].iter().filter_map(|k| {
.chain(ALL_KINDS.iter().filter_map(|k| {
draft_branch_for(k, main_fav, keyset_sql.as_deref(), Some(per_page))
}))
.collect();
@@ -762,7 +840,7 @@ async fn list_runnables(
#[derive(Deserialize)]
struct CountRunnablesQuery {
/// Comma-separated subset of `script,flow,app`; omitted means all.
/// Comma-separated subset of `script,flow,app,agent`; omitted means all.
kinds: Option<String>,
/// Include library scripts (no runnable main). Ignored for flows/apps.
include_without_main: Option<bool>,
@@ -789,7 +867,7 @@ struct OwnerCount {
/// `owner/<rest>` is >= `owner || '/'` and, since '/' (0x2F) is immediately
/// followed by '0' (0x30), < `owner || '0'`. `~>=~` / `~<~` are the
/// text_pattern_ops operators, which is what lets `idx_<kind>_owner_prefix`
/// answer the count with an index-only scan — the default opclass sorts by the
/// (`idx_resource_agent_owner_prefix` for agents) answer the count with an index-only scan — the default opclass sorts by the
/// database collation and cannot serve a byte-prefix range.
fn owner_prefix_range(alias: &str) -> String {
format!("{alias}.path ~>=~ (o.owner || '/') AND {alias}.path ~<~ (o.owner || '0')")
@@ -818,13 +896,13 @@ async fn count_runnables_by_owner(
Query(q): Query<CountRunnablesQuery>,
) -> JsonResult<RunnableCountsResponse> {
let kinds: Vec<&str> = match q.kinds.as_deref() {
None | Some("") => vec!["script", "flow", "app"],
None | Some("") => ALL_KINDS.to_vec(),
// Deduplicated: every kind becomes its own count subquery, so a repeated
// entry would both double that kind's count and multiply the scans.
Some(csv) => {
let mut ks: Vec<&str> = vec![];
for k in csv.split(',').map(|s| s.trim()) {
if ["script", "flow", "app"].contains(&k) && !ks.contains(&k) {
if ALL_KINDS.contains(&k) && !ks.contains(&k) {
ks.push(k);
}
}
@@ -866,6 +944,13 @@ async fn count_runnables_by_owner(
w.push(s);
}
}
"agent" => {
w.push(format!("{alias}.resource_type = 'ai_agent'"));
if let Some(s) = scope_path_predicate(&authed, "resources", alias, base, binds)
{
w.push(s);
}
}
_ => {
if let Some(s) = scope_path_predicate(&authed, "apps", alias, base, binds) {
w.push(s);
@@ -877,6 +962,7 @@ async fn count_runnables_by_owner(
let table_of = |kind: &str| match kind {
"script" => "script",
"flow" => "flow",
"agent" => "resource",
_ => "app",
};
@@ -1048,16 +1134,17 @@ async fn add_draft_counts(
let branches: Vec<String> = kinds
.iter()
.map(|kind| {
let (typ_pred, deployed, domain) = match *kind {
"script" => ("d.typ = 'script'", "script", "scripts"),
"flow" => ("d.typ = 'flow'", "flow", "flows"),
_ => ("d.typ IN ('app', 'raw_app')", "app", "apps"),
let (typ_pred, deployed) = draft_kind_source(kind);
let domain = match *kind {
"script" => "scripts",
"flow" => "flows",
"agent" => "resources",
_ => "apps",
};
// The owner a draft counts under is where it says it will live, not the
// `u/<caller>/draft_<uuid>` it is parked at — same path `/list` groups and
// filters on. Scripts round-trip the typed path through the draft JSON's
// own `path`; flows and apps write `draft_path`. See scripts.rs.
let typed_path = if *kind == "script" { "path" } else { "draft_path" };
// filters on.
let typed_path = draft_typed_path(kind);
let effective_path =
format!("COALESCE(NULLIF(d.value->>'{typed_path}', ''), d.path) as path");
let mut w = vec![
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -61,6 +61,11 @@ pub fn workspaced_service() -> Router {
)
.route("/tarball", get(crate::workspaces_export::tarball_workspace))
.route("/edit_copilot_config", post(edit_copilot_config))
.route(
"/settings_document",
get(crate::workspace_settings_document::get_settings_document)
.post(crate::workspace_settings_document::apply_settings_document),
)
.route("/get_copilot_info", get(get_copilot_info))
.route("/critical_alerts", get(get_critical_alerts))
.route(
@@ -85,7 +90,7 @@ pub fn workspaced_service() -> Router {
router
}
async fn edit_copilot_config(
pub(crate) async fn edit_copilot_config(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
@@ -190,7 +195,7 @@ async fn edit_copilot_config(
}
#[derive(Serialize)]
struct EditCopilotConfigResponse {
pub(crate) struct EditCopilotConfigResponse {
effective_ai_config: AIConfig,
has_instance_ai_config: bool,
uses_instance_ai_config: bool,
@@ -321,7 +326,7 @@ pub struct MuteCriticalAlertRequest {
}
#[cfg(feature = "enterprise")]
async fn mute_critical_alerts(
pub(crate) async fn mute_critical_alerts(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
authed: ApiAuthed,
+21 -116
View File
@@ -359,6 +359,9 @@ pub(crate) struct ArchiveQueryParams {
include_users: Option<bool>,
include_groups: Option<bool>,
include_settings: Option<bool>,
/// Opt-in, with `settings_version=v2`: state the data tables' roles in settings.json. Their
/// tenants name this workspace's users and groups, and their roles this instance's catalog.
include_datatable_permissions: Option<bool>,
include_key: Option<bool>,
include_workspace_dependencies: Option<bool>,
/// Default `false`: data table migrations ship in the tarball unless the repo
@@ -513,49 +516,6 @@ struct SimplifiedGroup {
admins: Vec<String>,
}
// V2 format: New grouped format
#[derive(Serialize)]
struct SimplifiedSettings {
#[serde(skip_serializing_if = "Option::is_none")]
auto_invite: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
webhook: Option<String>,
// Always serialize (including as `null`) so that `wmill sync pull` emits
// these fields in settings.yaml unconditionally. Makes round-trip
// bijective: YAML is the source of truth, absence/null = "clear remote",
// mirroring every other workspace setting.
error_handler: Option<Value>,
success_handler: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
ai_config: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
large_file_storage: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
git_sync: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
default_app: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
default_scripts: Option<Value>,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
mute_critical_alerts: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
operator_settings: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
datatable: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
slack_team_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
slack_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
slack_command_script: Option<String>,
// Always serialize (see note above on error_handler / success_handler).
slack_oauth_client_id: Option<String>,
slack_oauth_client_secret: Option<String>,
}
// V1 format: Legacy flat format for backward compatibility (matches main branch exactly)
#[derive(Serialize)]
struct SimplifiedSettingsLegacy {
@@ -602,7 +562,6 @@ struct SettingsRow {
auto_invite: Option<Value>,
webhook: Option<String>,
error_handler: Option<Value>,
success_handler: Option<Value>,
ai_config: Option<serde_json::Value>,
large_file_storage: Option<Value>,
git_sync: Option<Value>,
@@ -616,8 +575,6 @@ struct SettingsRow {
slack_team_id: Option<String>,
slack_name: Option<String>,
slack_command_script: Option<String>,
slack_oauth_client_id: Option<String>,
slack_oauth_client_secret: Option<String>,
}
pub(crate) async fn tarball_workspace(
@@ -638,6 +595,7 @@ pub(crate) async fn tarball_workspace(
include_users,
include_groups,
include_settings,
include_datatable_permissions,
include_key,
include_workspace_dependencies,
skip_datatable_migrations,
@@ -1593,7 +1551,6 @@ pub(crate) async fn tarball_workspace(
auto_invite,
webhook,
error_handler,
success_handler,
ai_config,
large_file_storage,
git_sync,
@@ -1606,9 +1563,7 @@ pub(crate) async fn tarball_workspace(
datatable,
slack_team_id,
slack_name,
slack_command_script,
slack_oauth_client_id,
slack_oauth_client_secret
slack_command_script
FROM workspace_settings
LEFT JOIN workspace ON workspace.id = workspace_settings.workspace_id
WHERE workspace_id = $1"#,
@@ -1617,73 +1572,23 @@ pub(crate) async fn tarball_workspace(
.fetch_one(&mut *tx)
.await?;
// Use v2 format only if explicitly requested, otherwise use v1 (legacy) for backward compatibility
// Server-owned state (the HMAC webhook secret + hook id/error, the
// synced-sha / last-pull status, the admin automatic pulls run as, and what
// the credential check observed) must never leave the server: keep it out of
// export archives and synced repos, and don't let a re-imported workspace
// inherit another install's hook/sync state or pull identity.
fn redact_git_sync_for_export(git_sync: Option<Value>) -> Option<Value> {
let mut git_sync = git_sync?;
if let Some(repos) = git_sync
.get_mut("repositories")
.and_then(|r| r.as_array_mut())
{
for repo in repos {
if let Some(auto_pull) =
repo.get_mut("auto_pull").and_then(|a| a.as_object_mut())
{
for field in [
"webhook_secret",
"webhook_id",
"webhook_error",
"last_synced_sha",
"last_pull_status",
"enabled_by",
] {
auto_pull.remove(field);
}
}
// What this install observed about its own credential: a token
// id and expiry, and a `checked_at` that moves on its own.
// None of it describes the workspace, and in a git-synced
// `wmill.yaml` it would churn the file for no reason.
if let Some(repo) = repo.as_object_mut() {
repo.remove("credential");
}
}
}
Some(git_sync)
}
let settings_str = if settings_version.as_deref() == Some("v2") {
let settings = SimplifiedSettings {
auto_invite: row.auto_invite,
webhook: row.webhook,
error_handler: row.error_handler,
success_handler: row.success_handler,
ai_config: row.ai_config,
large_file_storage: row.large_file_storage,
git_sync: redact_git_sync_for_export(row.git_sync),
default_app: row.default_app,
default_scripts: row.default_scripts,
name: row.name.clone().unwrap_or_default(),
mute_critical_alerts: row.mute_critical_alerts,
color: row.color.clone(),
operator_settings: row.operator_settings.clone(),
datatable: windmill_common::workspaces::strip_datatable_permissions(
row.datatable.clone(),
),
slack_team_id: row.slack_team_id.clone(),
slack_name: row.slack_name.clone(),
slack_command_script: row.slack_command_script.clone(),
slack_oauth_client_id: row.slack_oauth_client_id.clone(),
slack_oauth_client_secret: row.slack_oauth_client_secret.clone(),
let secrets = if export_plain_secrets {
crate::workspace_settings_document::SecretMode::Plain
} else {
crate::workspace_settings_document::SecretMode::Encrypted
};
serde_json::to_value(settings)
.map(|v| serde_json::to_string_pretty(&v).ok())
.ok()
.flatten()
let mut doc = crate::workspace_settings_document::load_settings_document(
&mut *tx, &db, &w_id, secrets,
)
.await?;
// A repo syncs its rules through protection-rules.yaml, so settings.yaml leaving
// them out keeps one writer for them.
doc.remove("protection_rules");
if !include_datatable_permissions.unwrap_or(false) {
doc.remove("datatable_permissions");
}
serde_json::to_string_pretty(&doc).ok()
} else {
// V1 (legacy) format: convert JSONB to flat fields (matches main branch exactly)
let (auto_invite_enabled, auto_invite_as, auto_invite_mode) =
@@ -1730,7 +1635,7 @@ pub(crate) async fn tarball_workspace(
error_handler_muted_on_cancel,
ai_config: row.ai_config,
large_file_storage: row.large_file_storage,
git_sync: redact_git_sync_for_export(row.git_sync),
git_sync: crate::workspace_settings_document::redact_git_sync(row.git_sync),
default_app: row.default_app,
default_scripts: row.default_scripts,
name: row.name.unwrap_or_default(),

Some files were not shown because too many files have changed in this diff Show More