mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-15 16:03:02 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "1.833.0"
|
||||
".": "1.835.0"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!`.
|
||||
|
||||
@@ -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" }]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -10,4 +10,4 @@ windmill-api/openapi-*.*
|
||||
*ee.rs
|
||||
generate_mcp_endpoints_tools/venv
|
||||
bacon.toml
|
||||
libwindmill_duckdb_ffi_internal.so
|
||||
windmill_duckdb_runner
|
||||
+22
@@ -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"
|
||||
}
|
||||
+30
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
-25
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
+27
@@ -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"
|
||||
}
|
||||
-46
@@ -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"
|
||||
}
|
||||
-18
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+9
-3
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
-23
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+19
-7
@@ -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"
|
||||
}
|
||||
+58
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+24
@@ -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
@@ -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:**
|
||||
|
||||
Generated
+83
-84
@@ -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
@@ -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 @@
|
||||
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);
|
||||
@@ -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
@@ -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>"]
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>` /
|
||||
|
||||
@@ -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(©)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
copied == ["DateStyle=SQL, DMY", "search_path=app"],
|
||||
"{copied:?}"
|
||||
);
|
||||
anyhow::Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
for name in [©, &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(())
|
||||
}
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(©)
|
||||
.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(©)
|
||||
.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(())
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,7 @@ pub enum FavoriteKind {
|
||||
#[allow(non_camel_case_types)]
|
||||
Raw_App,
|
||||
Asset,
|
||||
Agent,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct Favorite {
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user