Merge remote-tracking branch 'origin/main' into cli-onbehaflof

This commit is contained in:
wendrul
2026-03-09 18:50:08 +01:00
718 changed files with 16346 additions and 5322 deletions
-1
View File
@@ -110,7 +110,6 @@
]
},
"enabledPlugins": {
"rust-analyzer-lsp@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"code-review@claude-plugins-official": true
}
+165
View File
@@ -0,0 +1,165 @@
name: Backend integration tests (Windows)
on:
workflow_dispatch:
push:
branches:
- "ci-windows-tests"
env:
CARGO_INCREMENTAL: 0
SQLX_OFFLINE: true
DISABLE_EMBEDDING: true
jobs:
cargo_test_windows:
runs-on: blacksmith-16vcpu-windows-2025
steps:
- uses: actions/checkout@v4
- name: Read EE repo commit hash
shell: pwsh
run: |
$ee_repo_ref = Get-Content .\backend\ee-repo-ref.txt
echo "ee_repo_ref=$ee_repo_ref" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Checkout windmill-ee-private repository
uses: actions/checkout@v4
with:
repository: windmill-labs/windmill-ee-private
path: ./windmill-ee-private
ref: ${{ env.ee_repo_ref }}
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
fetch-depth: 0
- name: Substitute EE code
shell: bash
run: |
./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Setup PostgreSQL
uses: ikalnytskyi/action-setup-postgres@v6
with:
username: postgres
password: changeme
database: windmill
port: 5432
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.93.0
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- uses: actions/setup-go@v2
with:
go-version: 1.21.5
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
- uses: actions/setup-node@v4
with:
node-version: "20"
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.9.24"
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
tools: composer
- name: Install windmill CLI
shell: bash
run: |
cd cli
bash gen_wm_client.sh
bun install
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\nexec bun run "%s/cli/src/main.ts" "$@"\n' "$GITHUB_WORKSPACE" > "$HOME/.local/bin/wmill"
chmod +x "$HOME/.local/bin/wmill"
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install OpenSSL via vcpkg
run: |
vcpkg.exe install openssl-windows:x64-windows
vcpkg.exe install openssl:x64-windows-static
vcpkg.exe integrate install
- name: Get runtime paths
id: runtime-paths
shell: pwsh
run: |
echo "DENO_PATH=$($(Get-Command deno).Source)" >> $env:GITHUB_OUTPUT
echo "BUN_PATH=$($(Get-Command bun).Source)" >> $env:GITHUB_OUTPUT
echo "NODE_BIN_PATH=$($(Get-Command node).Source)" >> $env:GITHUB_OUTPUT
echo "GO_PATH=$($(Get-Command go).Source)" >> $env:GITHUB_OUTPUT
echo "UV_PATH=$($(Get-Command uv).Source)" >> $env:GITHUB_OUTPUT
echo "PHP_PATH=$($(Get-Command php).Source)" >> $env:GITHUB_OUTPUT
echo "COMPOSER_PATH=$($(Get-Command composer).Source)" >> $env:GITHUB_OUTPUT
echo "POWERSHELL_PATH=$($(Get-Command pwsh).Source)" >> $env:GITHUB_OUTPUT
echo "DOTNET_PATH=$($(Get-Command dotnet).Source)" >> $env:GITHUB_OUTPUT
- name: Build DuckDB FFI module
working-directory: backend/windmill-duckdb-ffi-internal
timeout-minutes: 30
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\
- name: Print runtime versions and env
shell: pwsh
run: |
deno --version
bun -v
node --version
go version
python3 --version
php --version
pwsh --version
dotnet --version
echo "TEMP=$env:TEMP"
echo "TMP=$env:TMP"
echo "USERPROFILE=$env:USERPROFILE"
echo "HOME=$env:HOME"
- name: cargo test
working-directory: backend
timeout-minutes: 60
env:
DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill
RUST_LOG: "off"
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
VCPKGRS_DYNAMIC: 1
OPENSSL_DIR: ${{ env.VCPKG_INSTALLATION_ROOT }}\installed\x64-windows-static
DENO_PATH: ${{ steps.runtime-paths.outputs.DENO_PATH }}
BUN_PATH: ${{ steps.runtime-paths.outputs.BUN_PATH }}
NODE_BIN_PATH: ${{ steps.runtime-paths.outputs.NODE_BIN_PATH }}
GO_PATH: ${{ steps.runtime-paths.outputs.GO_PATH }}
UV_PATH: ${{ steps.runtime-paths.outputs.UV_PATH }}
PHP_PATH: ${{ steps.runtime-paths.outputs.PHP_PATH }}
COMPOSER_PATH: ${{ steps.runtime-paths.outputs.COMPOSER_PATH }}
POWERSHELL_PATH: ${{ steps.runtime-paths.outputs.POWERSHELL_PATH }}
DOTNET_PATH: ${{ steps.runtime-paths.outputs.DOTNET_PATH }}
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
run: >
cargo test
--no-fail-fast
--features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,csharp,php,quickjs,mcp,run_inline
--all
-- --nocapture --test-threads=10
+52
View File
@@ -0,0 +1,52 @@
# Project display name in the dashboard
name: Windmill
workspace:
mainBranch: main
worktreeRoot: ../windmill__worktrees
defaultAgent: claude
startupEnvs:
CARGO_FEATURES: "quickjs"
WM_CLONE_DB: false
USE_RUST_PLUGIN: false
lifecycleHooks:
postCreate: bash ./scripts/post-create.sh
preRemove: bash ./scripts/pre-remove.sh
# Each service defines a port env var that webmux injects into pane and agent
# process environments when creating a worktree. Ports are auto-assigned:
# base + (slot x step).
services:
- name: backend
portEnv: BACKEND_PORT
portStart: 8000
portStep: 10
- name: frontend
portEnv: FRONTEND_PORT
portStart: 3000
portStep: 10
profiles:
default:
runtime: host
envPassthrough: []
panes:
- id: agent
kind: agent
focus: true
- id: backend
kind: command
split: right
command: ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/backend" && PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
- id: frontend
kind: command
split: bottom
command: ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
integrations:
github:
linkedRepos: []
linear:
enabled: true
-106
View File
@@ -1,106 +0,0 @@
services:
- name: BE
portEnv: BACKEND_PORT
- name: FE
portEnv: FRONTEND_PORT
profiles:
default:
name: default
sandbox:
name: sandbox
image: windmill-sandbox
envPassthrough:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- R2_ENDPOINT
- R2_BUCKET
- R2_PUBLIC_URL
extraMounts:
- hostPath: ~/.ssh
guestPath: /root/.ssh
writable: true
- hostPath: ~/.codex
guestPath: /root/.codex
writable: true
- hostPath: ~/windmill-ee-private
writable: true
- hostPath: ~/windmill-ee-private__worktrees
writable: true
systemPrompt: >
You are running inside a sandboxed container with full permissions.
This worktree is configured with the following ports:
- Backend: port ${BACKEND_PORT}.
Start with: cd backend && PORT=${BACKEND_PORT}
DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill
cargo watch -x run
- Frontend: port ${FRONTEND_PORT}.
Start with: cd frontend && REMOTE=http://localhost:${BACKEND_PORT}
npm run dev -- --port ${FRONTEND_PORT} --host 0.0.0.0
--- Screenshots ---
You can take screenshots of the frontend UI and upload them to R2
for use in PR descriptions.
1) Take a screenshot:
bunx playwright screenshot --browser chromium
http://localhost:${FRONTEND_PORT}/path/to/page /tmp/screenshot.png
2) Upload to R2:
aws s3 cp /tmp/screenshot.png
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/screenshot.png"
--endpoint-url "$(printenv R2_ENDPOINT)"
3) The public URL will be:
$(printenv R2_PUBLIC_URL)/<branch>/screenshot.png
4) Include in PR descriptions using markdown image syntax.
--- Terminal Recordings (asciinema) ---
You can record terminal sessions and upload them for sharing.
asciinema is available on PATH.
1) Write a shell script with the commands to demo. Add sleep
delays for readable pacing:
- 0.5s after printing a "$ command" line (lets viewer read it)
- 1.5-2s after command output (lets viewer absorb the result)
- Set GIT_PAGER=cat and PAGER=cat to prevent pager hangs
2) Record headlessly:
asciinema rec --headless --overwrite \
-c "bash /tmp/demo.sh" \
--window-size 120x50 \
--title "Description of demo" \
/tmp/demo.cast
3) Upload to asciinema.org:
XDG_DATA_HOME=/tmp/.local/share \
asciinema upload --server-url https://asciinema.org /tmp/demo.cast
--- Mermaid Diagrams ---
You can render Mermaid diagrams to SVG using the pre-installed mmdc CLI.
The puppeteer config (no-sandbox + Chromium path) is at /root/.puppeteerrc.json.
1) Write a .mmd file with your diagram:
cat > /tmp/diagram.mmd << 'EOF'
graph TD
A[Start] --> B[End]
EOF
2) Render to SVG (the -p flag is required):
mmdc -i /tmp/diagram.mmd -o /tmp/diagram.svg -p /root/.puppeteerrc.json
3) Upload to R2:
aws s3 cp /tmp/diagram.svg
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/diagram.svg"
--endpoint-url "$(printenv R2_ENDPOINT)"
4) The public URL will be:
$(printenv R2_PUBLIC_URL)/<branch>/diagram.svg
5) Include in PR descriptions using markdown image syntax.
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
linkedRepos:
- repo: windmill-labs/windmill-ee-private
alias: ee
-2
View File
@@ -1,5 +1,3 @@
name: Windmill
main_branch: main
merge_strategy: rebase
+66
View File
@@ -1,5 +1,71 @@
# Changelog
## [1.651.1](https://github.com/windmill-labs/windmill/compare/v1.651.0...v1.651.1) (2026-03-05)
### Bug Fixes
* prevent slow loading toast interval from leaking on promise cancellation ([#8240](https://github.com/windmill-labs/windmill/issues/8240)) ([2e582b1](https://github.com/windmill-labs/windmill/commit/2e582b1bc1c299388a3c97cfddff9d0eb92858f2))
* suppress unused variable warnings on windows builds ([#8241](https://github.com/windmill-labs/windmill/issues/8241)) ([2d58382](https://github.com/windmill-labs/windmill/commit/2d583826dc065c05684d4cd1d1510f0d1f2d9ae9))
## [1.651.0](https://github.com/windmill-labs/windmill/compare/v1.650.0...v1.651.0) (2026-03-05)
### Features
* add sandbox annotations, volume mounts, for AI sandbox starting with claude ([#8058](https://github.com/windmill-labs/windmill/issues/8058)) ([5f0ef93](https://github.com/windmill-labs/windmill/commit/5f0ef936d1d5d07d01c8e07e26ec254feebef8fb))
* hash-based MCP tool names for long paths ([#8133](https://github.com/windmill-labs/windmill/issues/8133)) ([ce041e8](https://github.com/windmill-labs/windmill/commit/ce041e8a5e7ff105df389875d9981f3843d4ce39))
### Bug Fixes
* **python-client:** add delete_s3_object ([#8216](https://github.com/windmill-labs/windmill/issues/8216)) ([90f4c64](https://github.com/windmill-labs/windmill/commit/90f4c64ee12e1d04ce846ff88d6658f667e194e0))
* update CLI bun template to match UI template ([#8238](https://github.com/windmill-labs/windmill/issues/8238)) ([a8cbe93](https://github.com/windmill-labs/windmill/commit/a8cbe9396ffc51140dce5582d57f4dc59873304e))
* write fallback package.json for codebase mode nsjail ([#8239](https://github.com/windmill-labs/windmill/issues/8239)) ([d46913b](https://github.com/windmill-labs/windmill/commit/d46913b74a0ffd41d2323e0355cc81954f09e29d))
## [1.650.0](https://github.com/windmill-labs/windmill/compare/v1.649.0...v1.650.0) (2026-03-05)
### Features
* add move, delete, and duplicate to flow node context menu ([#8050](https://github.com/windmill-labs/windmill/issues/8050)) ([c0c9388](https://github.com/windmill-labs/windmill/commit/c0c9388415716ce77d841bd08a46f94e0a529685))
* add variable and resource types to flow env variables ([#8214](https://github.com/windmill-labs/windmill/issues/8214)) ([164e499](https://github.com/windmill-labs/windmill/commit/164e499c64dc5eb76fcfb0f8cefbad2df244f610))
* Ducklake typechecker ([#8118](https://github.com/windmill-labs/windmill/issues/8118)) ([53caecf](https://github.com/windmill-labs/windmill/commit/53caecf1da8d76e246178dfb9b86d330f0ec52fd))
* make WINDMILL_DIR configurable via environment variable ([#8215](https://github.com/windmill-labs/windmill/issues/8215)) ([424ca59](https://github.com/windmill-labs/windmill/commit/424ca59dfe3e730f5388d9cac4ea7e69773614d3))
* make WM_END_USER_EMAIL display users from different workspaces ([#8208](https://github.com/windmill-labs/windmill/issues/8208)) ([baf2bcf](https://github.com/windmill-labs/windmill/commit/baf2bcf14da0c8c95bdbbf511fcaee48be33948b))
* persistent Db manager state in URI ([#8134](https://github.com/windmill-labs/windmill/issues/8134)) ([4bf827b](https://github.com/windmill-labs/windmill/commit/4bf827bea4d44aca8c5ff7aa67ad449dbcf00673))
* replace hub error toasts with warning alerts and add disable hub setting ([#8225](https://github.com/windmill-labs/windmill/issues/8225)) ([63ebae8](https://github.com/windmill-labs/windmill/commit/63ebae8829a6dc47a4e23c8670b514f042c9d4be))
* token expiration notifications ([#8190](https://github.com/windmill-labs/windmill/issues/8190)) ([e56ccd2](https://github.com/windmill-labs/windmill/commit/e56ccd200be29e6ac8ea2b04a341b1ce78a307f6))
### Bug Fixes
* handle multipart stream errors gracefully instead of panicking ([#8226](https://github.com/windmill-labs/windmill/issues/8226)) ([19c065b](https://github.com/windmill-labs/windmill/commit/19c065bed5468c484c8e7a50a6b79ab90153cc0e))
* improve windows compatibility ([077779e](https://github.com/windmill-labs/windmill/commit/077779ec52f7d3e5fcc93951544bf47bd6dc30b6))
* wrap set_encryption_key in a single database transaction ([#8212](https://github.com/windmill-labs/windmill/issues/8212)) ([62382fd](https://github.com/windmill-labs/windmill/commit/62382fd2869ea0190dd0c0b714f9cbd35ceddd7a))
## [1.649.0](https://github.com/windmill-labs/windmill/compare/v1.648.0...v1.649.0) (2026-03-03)
### Features
* **frontend:** add script recorder for offline replay ([#8200](https://github.com/windmill-labs/windmill/issues/8200)) ([c97d8b4](https://github.com/windmill-labs/windmill/commit/c97d8b4715f86ea83ab2c0223ba859ced690829a))
* move index management out of /srch/, add storage size reporting ([#8169](https://github.com/windmill-labs/windmill/issues/8169)) ([ee01acd](https://github.com/windmill-labs/windmill/commit/ee01acd9a6a2cd68a3f226988bfb46f6a6e64c08))
### Bug Fixes
* clean up slow-load toast interval on component destroy ([#8207](https://github.com/windmill-labs/windmill/issues/8207)) ([26f4f2b](https://github.com/windmill-labs/windmill/commit/26f4f2b399b828185b553289d6560e12261030a3))
* **frontend:** prevent subflow expansion from hiding all insertion points ([#8203](https://github.com/windmill-labs/windmill/issues/8203)) ([e97da86](https://github.com/windmill-labs/windmill/commit/e97da860672171e33054a77d71f4824bb09e540d))
* gracefully handle malformed OAuth entries in instance config ([#8205](https://github.com/windmill-labs/windmill/issues/8205)) ([cac4bdd](https://github.com/windmill-labs/windmill/commit/cac4bdd54f0c3ea80844ac31f7597f418ff7d8ae))
* skip stop_after_if evaluation for skipped (identity) flow steps ([#8201](https://github.com/windmill-labs/windmill/issues/8201)) ([e6f7775](https://github.com/windmill-labs/windmill/commit/e6f7775d4d9a052aefc37260c6ed161146841cd7))
* use exact matching for python requirements directive parsing ([#8199](https://github.com/windmill-labs/windmill/issues/8199)) ([2b2be38](https://github.com/windmill-labs/windmill/commit/2b2be38f129bbe58b6bb3815c4bd94aa03a3da90))
### Performance Improvements
* use two-step query in input history to leverage v2_job index ([#8197](https://github.com/windmill-labs/windmill/issues/8197)) ([50defdd](https://github.com/windmill-labs/windmill/commit/50defdded113b4d2cf0991b3fb642d1cd9a462b7))
## [1.648.0](https://github.com/windmill-labs/windmill/compare/v1.647.2...v1.648.0) (2026-03-02)
+21
View File
@@ -26,6 +26,27 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Login**: `admin@windmill.dev` / `changeme`
- **Instance settings**: navigate to `/#superadmin-settings`
## Banned Patterns
### `$bindable(default_value)` on optional props
Using `$bindable(default_value)` on props that can be `undefined` is **banned**. This pattern causes subtle bugs because the default value masks the `undefined` state.
**Bad:**
```svelte
let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props()
```
**Correct alternatives:**
1. **Use `$derived` with nullish coalescing** — handle the potential `undefined` at the usage site:
```svelte
let { my_prop = $bindable() }: { my_prop?: string } = $props()
let effective_value = $derived(my_prop ?? default_value)
```
2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value.
## Core Principles
- Search for existing code to reuse before writing new code
+6
View File
@@ -262,6 +262,12 @@ COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
RUN bun install -g windmill-cli \
&& ln -s $(bun pm bin -g)/wmill /usr/bin/wmill
# Install Claude Code CLI (used by claude sandbox scripts)
# The installer puts the binary in ~/.local/bin/claude (symlink to ~/.local/share/claude/versions/*)
# Copy it to /usr/bin/claude so it's accessible inside nsjail sandbox (which mounts /usr but not /root)
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
COPY --from=php:8.3.7-cli /usr/local/bin/php /usr/bin/php
COPY --from=composer:2.7.6 /usr/bin/composer /usr/bin/composer
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "00bf3dbd9d3f51dd7fdefcbd654d55e0379cc84188954037165cbe2d198ef71f"
}
@@ -1,16 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT token\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ",
"query": "SELECT group_ FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"name": "group_",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
@@ -18,5 +19,5 @@
false
]
},
"hash": "90092c0b3f7612373fcc8fb7a966200118ab308430d4a0cbb5cb16c397246492"
"hash": "015a8551c646f9b027fc23752c5c5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT large_file_storage->>'volume_storage' FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "083d69abc8a662bb364cf43b8ffc6e9b159a54c179cecb108068597536835f7e"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "extra_perms",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0afd4ae50ff7e1b0dcca4b483816c595401dd2e1f7699a28bf3b79db5e3841f4"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0eb54f04a8185085b3f80772f5c28e666f6fbd1ec5ee9d30ee0cdb5e30a68750"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "14004a7c1641a3157eddd571fea11a1dfb1422187200119268b2342b47a960c6"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "19a7ebb2e7e8e57b6e7c974da8eb7c6841a5c4ff12ba7c12c73d691c49dd99ed"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "1d2f765c2a71e1154ca5d9f5e52ef31e6d647377d37747f7bdc834748a59419e"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)\n VALUES ($1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET size_bytes = $3, last_used_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "1e9b9a02f45e6200f4d101bd5336fc8ce983f857339e6fccf799dc6587964aab"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "23f47f5207abe0cfaede197aeee485957990eb92fa3ce515895eab0d3f28bfdc"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "28df7bbe1f54f69640bc76def9e580b4c7ba25f279644e3233b63f4f6db0ad98"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n flow_version.value -> 'flow_env' -> $3\n ELSE\n root_job.raw_flow -> 'flow_env' -> $3\n END AS \"flow_env: sqlx::types::Json<Box<RawValue>>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flow_env: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO global_settings (name, value) VALUES ('indexer_settings', $1)\n ON CONFLICT (name) DO UPDATE SET value = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "380ca9ebea53d5c016e4e76797cc103178ac4a25fc2842a13ce19b1ec4445c9d"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), updated_by = $5, last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int4",
"Varchar"
]
},
"nullable": []
},
"hash": "3955e57e216d169c30b1548a2252eb169329116cba57780fa90ecf2bdb910f34"
}
@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n name as \"name!\",\n size_bytes as \"size_bytes!\",\n file_count as \"file_count!\",\n created_at as \"created_at!\",\n created_by as \"created_by!\",\n updated_at,\n updated_by,\n description as \"description!\",\n last_used_at,\n extra_perms as \"extra_perms!\"\n FROM (\n SELECT\n COALESCE(v.name, a.path) as name,\n COALESCE(v.size_bytes, 0) as size_bytes,\n COALESCE(v.file_count, 0) as file_count,\n COALESCE(v.created_at, a.min_created_at) as created_at,\n COALESCE(v.created_by, 'unknown') as created_by,\n v.updated_at,\n v.updated_by,\n COALESCE(v.description, '') as description,\n v.last_used_at,\n COALESCE(v.extra_perms, '{}'::jsonb) as extra_perms\n FROM (\n SELECT path, MIN(created_at) as min_created_at\n FROM asset\n WHERE workspace_id = $1 AND kind = 'volume'\n GROUP BY path\n ) a\n FULL OUTER JOIN volume v ON v.workspace_id = $1 AND v.name = a.path\n WHERE v.workspace_id = $1 OR a.path IS NOT NULL\n ) combined\n ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "size_bytes!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "file_count!",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "created_at!",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "created_by!",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "updated_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "description!",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "last_used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 9,
"name": "extra_perms!",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null,
null,
null,
null,
true,
true,
null,
true,
null
]
},
"hash": "40d0f6dca30456514cb85e36c6e367b27171894016c714e41497e69115be1468"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2\n AND (lease_until IS NULL OR lease_until < now())\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "5af44b46a2e2f1a9adeb39013790be7046cf8789d842717b6c793c22a2a05daa"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_by, extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "extra_perms",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "6086849bb08e1b37d6693d2808767cd897dca4722e4f2076308afdb7ee9fc147"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM volume WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "712092e5033bc6894025a55ebc58bca8450d09982e582266d215dff521256fa6"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $5",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "75a03e9e4cba350a104e2e3a95de919cd25538c0b433bc29bb052c7a7b8568ca"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "769035629df5a5034f64bf38992e142006825a3911addacdf1a026660b5e2b7f"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now() AND leased_by = $3)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "78af8bdb6a3ee6396c54f87ff6403b566fc75e16e0b7a81204816fd50b3346a5"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "7ce06d4f623932fce12352be3a09ba8973a2ef1defa36c6d46d9c1c6406a7c33"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "7e8e79a7d140be511cedbfe9ff8eea76a8a3079ce80c035087f797cdc410f35b"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "803abdcd3614437b26c5d2e4f1ad75ca7014b431239ac1b681f2b26380c719c4"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET extra_perms = extra_perms - $1\n WHERE workspace_id = $2 AND name = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "82b3bd95e5d28c4cd4eedcae8cf050ba7b7e4d9eabba03be251ae9a8017b317d"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "leased_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "88e25dc24bb06237b3677c947ee53fd6e9c7606231ad3c522e98cb1fcc14361a"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "907241c195fea227e4a945ee472425e5f7600e28c728a06235f7ff430a4bd77a"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT size_bytes FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "size_bytes",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "94d6f598076ad67d68e6f01926c9fc2c73e855790e17abf5461b96ea30fbbdb7"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9662f1e304124fa52db4aa1e80e03b2601630f2d31458bdaf70c2702b2998d89"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9e30b5545a51453205a713a6276156ada29ae320465d9790dce7e1e8a436d4de"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT extra_perms, created_by FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "9f64d6ed0adb609ced1551563062550919fcac56deaf1b3cb36b3e15117936e7"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, 0, $3)\n ON CONFLICT (workspace_id, name) DO NOTHING\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "a3970c15271a124307301c0dafa263e7168fa325c5ceb44e9dd1595bdb7e7ce6"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Timestamptz"
]
},
"nullable": []
},
"hash": "a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token_expiry_notification WHERE expiration <= now()",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "a6b1c8808c892e62ae4ba04171d856a39c89cdc658b09c478050de5145a45ca4"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "ab8daa93bc66d0142b9e9e8d7fa6719fc41b2ca5cb0b7ac5ad73ab01b650c935"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token WHERE expiration <= now()\n RETURNING substring(token for 10) as token_prefix, label, email, workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token_prefix",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
true,
true,
true
]
},
"hash": "bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT size_bytes, last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "size_bytes",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "bc61ca62d8f71880facb5d701a6e78697414b35618c50f8693f4e804bf1d7dbb"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, last_locked_at, owner FROM concurrency_locks WHERE id = ANY($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "last_locked_at",
"type_info": "Timestamp"
},
{
"ordinal": 2,
"name": "owner",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"TextArray"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "bcefd1ce47d05f2ce14493f0e7c4d4fea16c0cf71ddc233f6431cf624ecdfe60"
}
@@ -1,25 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n (flow_version.value -> 'flow_env' -> $3) #> $4\n ELSE\n (root_job.raw_flow -> 'flow_env' -> $3) #> $4\n END AS \"flow_env: sqlx::types::Json<Box<RawValue>>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flow_env: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "c23bea7db9623a60683596b7d6e689e2c0100c1569436a01b207876aaa470154"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, size_bytes, created_by, last_used_at\n FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "size_bytes",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "d0869a340c8f34ca7a560d3b4c0070c9f117da3dd00ce3247c54a61052a6809c"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "leased_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "d1ad2baf5e3a6f45f1f079d494e8d6affad03a1f388024806a5de3f9cc939c04"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token = t.token\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token_prefix",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
true,
true,
true
]
},
"hash": "d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, size_bytes FROM volume WHERE workspace_id = $1 ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "size_bytes",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "dc18db954239c4ebdd3b46cfd34f33554794444f0dc4e2d2fec158eca5ebe865"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2::bool), true)\n WHERE workspace_id = $3 AND name = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "eb79db2aeac7bf246ad56a5f116511b9d3183cb91b740a86944a77a2a964b57d"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT token as \"token!\"\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT permissioned_as FROM v2_job WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "permissioned_as",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "f0ac12b66c5d3cca680541aed04359b064baf73b890efdc25426261d4eadfee0"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT size_bytes, file_count, leased_by, lease_until\n FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "size_bytes",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "file_count",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "leased_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "lease_until",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "f7ba87d5804b9bc05e7156c7c18c5a30037abef63efb5b44dc535c5f45d62a06"
}
+144 -110
View File
@@ -860,9 +860,9 @@ dependencies = [
[[package]]
name = "aws-lc-rs"
version = "1.16.0"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9"
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
dependencies = [
"aws-lc-sys",
"zeroize",
@@ -870,9 +870,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
version = "0.37.1"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
dependencies = [
"cc",
"cmake",
@@ -1334,9 +1334,9 @@ dependencies = [
[[package]]
name = "aws-smithy-xml"
version = "0.60.14"
version = "0.60.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa"
checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3"
dependencies = [
"xmlparser",
]
@@ -1900,7 +1900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
dependencies = [
"once_cell",
"proc-macro-crate 3.4.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -2550,6 +2550,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cooked-waker"
version = "5.0.0"
@@ -6173,20 +6182,20 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"r-efi 5.3.0",
"wasip2",
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.4.1"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
@@ -7421,9 +7430,9 @@ dependencies = [
[[package]]
name = "ipnet"
version = "2.11.0"
version = "2.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "ipnetwork"
@@ -8683,7 +8692,7 @@ dependencies = [
"darling 0.20.11",
"heck 0.5.0",
"num-bigint",
"proc-macro-crate 3.4.0",
"proc-macro-crate",
"proc-macro-error2",
"proc-macro2",
"quote",
@@ -9252,7 +9261,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -10331,16 +10340,6 @@ dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [
"once_cell",
"toml_edit 0.19.15",
]
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
@@ -10710,9 +10709,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.44"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -10723,6 +10722,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "radium"
version = "0.7.0"
@@ -11088,9 +11093,12 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "relative-path"
version = "1.9.3"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0"
dependencies = [
"serde",
]
[[package]]
name = "rend"
@@ -11384,9 +11392,9 @@ dependencies = [
[[package]]
name = "rquickjs"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c"
checksum = "c50dc6d6c587c339edb4769cf705867497a2baf0eca8b4645fa6ecd22f02c77a"
dependencies = [
"rquickjs-core",
"rquickjs-macro",
@@ -11394,26 +11402,27 @@ dependencies = [
[[package]]
name = "rquickjs-core"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b"
checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1"
dependencies = [
"async-lock",
"hashbrown 0.16.0",
"relative-path",
"rquickjs-sys",
]
[[package]]
name = "rquickjs-macro"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6041104330c019fcd936026ae05e2446f5e8a2abef329d924f25424b7052a2f3"
checksum = "7106215ff41a5677b104906a13e1a440b880f4b6362b5dc4f3978c267fad2b80"
dependencies = [
"convert_case 0.6.0",
"convert_case 0.10.0",
"fnv",
"ident_case",
"indexmap 2.11.1",
"proc-macro-crate 1.3.1",
"proc-macro-crate",
"proc-macro2",
"quote",
"rquickjs-core",
@@ -11422,9 +11431,9 @@ dependencies = [
[[package]]
name = "rquickjs-sys"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a"
checksum = "27344601ef27460e82d6a4e1ecb9e7e99f518122095f3c51296da8e9be2b9d83"
dependencies = [
"cc",
]
@@ -13850,7 +13859,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
dependencies = [
"fastrand",
"getrandom 0.4.1",
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -15732,7 +15741,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15764,6 +15773,7 @@ dependencies = [
"sql-builder",
"sqlx",
"strum 0.27.2",
"tar",
"tempfile",
"tikv-jemalloc-ctl",
"tikv-jemalloc-sys",
@@ -15789,14 +15799,16 @@ dependencies = [
"windmill-queue",
"windmill-runtime-nativets",
"windmill-test-utils",
"windmill-types",
"windmill-worker",
"windmill-worker-volumes",
"windows-service",
"windows-sys 0.52.0",
]
[[package]]
name = "windmill-alerting"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15809,7 +15821,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"argon2",
@@ -15837,6 +15849,7 @@ dependencies = [
"dashmap 6.1.0",
"datafusion",
"ed25519-dalek",
"eventsource-stream",
"flate2",
"futures",
"git-version",
@@ -15943,11 +15956,12 @@ dependencies = [
"windmill-trigger-websocket",
"windmill-types",
"windmill-worker",
"windmill-worker-volumes",
]
[[package]]
name = "windmill-api-agent-workers"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15970,7 +15984,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15983,7 +15997,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16009,7 +16023,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16019,7 +16033,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16036,7 +16050,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -16059,7 +16073,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16082,7 +16096,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16098,7 +16112,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16118,7 +16132,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16138,7 +16152,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16152,7 +16166,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-nats",
@@ -16179,7 +16193,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16204,7 +16218,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"flate2",
@@ -16222,7 +16236,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16243,7 +16257,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16263,7 +16277,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16293,7 +16307,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16320,7 +16334,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"lazy_static",
"serde",
@@ -16332,7 +16346,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16355,7 +16369,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16369,7 +16383,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16377,6 +16391,7 @@ dependencies = [
"http 1.4.0",
"hyper 1.8.1",
"lazy_static",
"magic-crypt",
"regex",
"serde",
"serde_json",
@@ -16399,7 +16414,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"chrono",
"lazy_static",
@@ -16413,7 +16428,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16432,7 +16447,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16531,7 +16546,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16550,7 +16565,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"regex",
"serde",
@@ -16565,7 +16580,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16589,7 +16604,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"futures",
@@ -16606,7 +16621,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16622,7 +16637,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16643,7 +16658,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16674,7 +16689,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16698,7 +16713,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-stream",
@@ -16732,7 +16747,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"futures",
@@ -16750,7 +16765,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16759,7 +16774,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16771,7 +16786,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16783,7 +16798,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"gosyn",
@@ -16795,7 +16810,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16807,7 +16822,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16819,7 +16834,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -16830,7 +16845,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16841,7 +16856,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16854,7 +16869,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16878,7 +16893,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16892,7 +16907,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16909,7 +16924,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16924,7 +16939,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16943,7 +16958,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"serde",
@@ -16954,7 +16969,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16991,7 +17006,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"const_format",
@@ -17029,7 +17044,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17040,7 +17055,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -17069,7 +17084,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -17092,7 +17107,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17125,7 +17140,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17145,7 +17160,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17179,7 +17194,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17214,7 +17229,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17237,7 +17252,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17261,7 +17276,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-nats",
@@ -17285,7 +17300,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17320,7 +17335,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17348,7 +17363,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17371,7 +17386,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17389,7 +17404,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.648.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17489,9 +17504,28 @@ dependencies = [
"windmill-queue",
"windmill-runtime-nativets",
"windmill-types",
"windmill-worker-volumes",
"yaml-rust",
]
[[package]]
name = "windmill-worker-volumes"
version = "1.651.1"
dependencies = [
"bytes",
"futures",
"lazy_static",
"md-5 0.10.6",
"object_store",
"regex",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"windmill-common",
]
[[package]]
name = "windows"
version = "0.56.0"
+9 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.648.0"
version = "1.651.1"
authors.workspace = true
edition.workspace = true
@@ -70,13 +70,14 @@ members = [
"./parsers/windmill-parser-py-imports",
"./parsers/windmill-sql-datatype-parser-wasm",
"./parsers/windmill-parser-yaml", "windmill-macros", "parsers/windmill-parser-nu",
"./windmill-worker-volumes",
"./windmill-test-utils",
"./windmill-api-integration-tests",
]
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.648.0"
version = "1.651.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -250,10 +251,13 @@ reqwest.workspace = true
windmill-queue = { workspace = true, features = ["failpoints"] }
windmill-dep-map.workspace = true
windmill-test-utils.workspace = true
windmill-worker-volumes.workspace = true
windmill-types.workspace = true
axum.workspace = true
serde.workspace = true
windmill-api-client.workspace = true
tempfile.workspace = true
tar.workspace = true
windmill-parser-ts.workspace = true
rumqttc.workspace = true
rdkafka.workspace = true
@@ -267,6 +271,7 @@ aws-credential-types.workspace = true
windmill-api = { path = "./windmill-api", default-features = false }
windmill-queue = { path = "./windmill-queue" }
windmill-worker = { path = "./windmill-worker" }
windmill-worker-volumes = { path = "./windmill-worker-volumes" }
windmill-dep-map = { path = "./windmill-dep-map" }
windmill-types = { path = "./windmill-types" }
windmill-common = { path = "./windmill-common", default-features = false }
@@ -439,6 +444,7 @@ base64 = "^0.22.1"
base32 = "^0"
hmac = "0.12.1"
sha2 = "0.10.6"
md-5 = "0.10.6"
sha1 = "0.10.6"
sqlx = { version = "0.8.0", features = [
"macros",
@@ -512,7 +518,7 @@ nu-parser = { version = "0.101.0", default-features = false }
globset = "0.4.16"
croner = "2.2.0"
rmcp = { version = "=0.15.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
rquickjs = { version = "0.8", features = ["futures", "parallel", "macro"] }
rquickjs = { version = "0.11", features = ["futures", "parallel", "macro"] }
process-wrap = { version = "8.2.1", features = ["tokio1"] }
systemstat = "0.2.4"
+1 -1
View File
@@ -1 +1 @@
8ffae1f43b31dc8136714fa612d22b6301773e27
716b350bce1730b302c66ea69df618fa40f2f16b
@@ -0,0 +1 @@
DROP TABLE IF EXISTS volume;
@@ -0,0 +1,22 @@
-- Add 'volume' to the asset_kind enum
ALTER TYPE asset_kind ADD VALUE IF NOT EXISTS 'volume';
-- Volume metadata table
CREATE TABLE volume (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
file_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(255) NOT NULL,
updated_at TIMESTAMPTZ,
updated_by VARCHAR(255),
description TEXT NOT NULL DEFAULT '',
lease_until TIMESTAMPTZ,
leased_by VARCHAR(255),
last_used_at TIMESTAMPTZ,
extra_perms JSONB NOT NULL DEFAULT '{}',
PRIMARY KEY (workspace_id, name)
);
CREATE INDEX idx_volume_last_used ON volume(workspace_id, last_used_at);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS token_expiry_notification;
@@ -0,0 +1,8 @@
-- Tracks pending expiry notifications: row exists = not yet notified.
-- Deleted once the notification is sent. Orphaned rows are harmless (filtered out by the join).
CREATE TABLE token_expiry_notification (
token VARCHAR(255) PRIMARY KEY,
expiration TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_token_expiry_notification_expiration ON token_expiry_notification (expiration);
+33 -1
View File
@@ -238,7 +238,7 @@ lazy_static::lazy_static! {
// used for `unsafe` sql interpolation
// -- %%name%% (type) = default
static ref RE_ARG_SQL_INTERPOLATION: Regex = Regex::new(r#"(?m)^--\s*%%([a-z_][a-z0-9_]*)%%\s*([\s\w\/]+)?(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
static ref RE_ARG_SQL_INTERPOLATION: Regex = Regex::new(r#"(?m)^--\s*%%([a-z_][a-z0-9_]*)%%[ \t]*([\w][\w \t\/]*)?(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
}
fn parsed_default(parsed_typ: &Typ, default: String) -> Option<serde_json::Value> {
@@ -1547,4 +1547,36 @@ SELECT $1::integer;
Ok(())
}
#[test]
fn test_parse_pgsql_safe_interpolated_args() -> anyhow::Result<()> {
// There was a bug where enum would be "angrycreative"/"bishop"/"test SELECT x"
let code = r#"
-- %%table_name%% angrycreative/bishop/test
SELECT x
"#;
assert_eq!(
parse_pgsql_sig(code)?,
MainArgSignature {
star_args: false,
star_kwargs: false,
args: vec![Arg {
otyp: Some("__sanitized_enum__".to_string()),
name: "table_name".to_string(),
typ: Typ::Str(Some(vec![
"angrycreative".to_string(),
"bishop".to_string(),
"test".to_string()
])),
default: None,
has_default: false,
oidx: None,
},],
no_main_func: None,
has_preprocessor: None
}
);
Ok(())
}
}
@@ -18,6 +18,7 @@ pub enum AssetKind {
Resource,
Ducklake,
DataTable,
Volume,
}
#[derive(Serialize, Debug, PartialEq, Clone)]
@@ -148,4 +149,5 @@ pub const ASSET_KINDS: &[(&str, AssetKind)] = &[
("$res:", AssetKind::Resource),
("ducklake://", AssetKind::Ducklake),
("datatable://", AssetKind::DataTable),
("volume://", AssetKind::Volume),
];
+44 -39
View File
@@ -38,11 +38,11 @@ use windmill_common::{
agent_workers::AgentConfig,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING,
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING,
DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING,
ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING,
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING,
MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
@@ -62,7 +62,7 @@ use windmill_common::{
},
worker::{
is_native_mode_from_env, reload_custom_tags_setting, Connection, HUB_CACHE_DIR,
HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_DIR, TMP_LOGS_DIR, WORKER_GROUP,
HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_LOGS_DIR, WINDMILL_DIR, WORKER_GROUP,
},
KillpillSender, DEFAULT_HUB_BASE_URL, METRICS_ENABLED,
};
@@ -99,10 +99,10 @@ use crate::monitor::{
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, monitor_db,
reload_app_workspaced_route_setting, reload_base_url_setting,
reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting,
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_job_default_timeout_setting,
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting,
reload_extra_pip_index_url_setting, reload_hub_api_secret_setting, reload_hub_base_url_setting,
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration,
};
@@ -238,8 +238,8 @@ async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
)
})?;
create_dir_all(HUB_CACHE_DIR)?;
create_dir_all(BUN_BUNDLE_CACHE_DIR)?;
create_dir_all(&*HUB_CACHE_DIR)?;
create_dir_all(&*BUN_BUNDLE_CACHE_DIR)?;
for path in paths.values() {
tracing::info!("Caching hub script at {path}");
@@ -249,7 +249,7 @@ async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
.as_ref()
.is_some_and(|x| x == &ScriptLang::Deno)
{
let job_dir = format!("{}/cache_init/{}", TMP_DIR, Uuid::new_v4());
let job_dir = format!("{}/cache_init/{}", *WINDMILL_DIR, Uuid::new_v4());
create_dir_all(&job_dir)?;
let _ = windmill_worker::generate_deno_lock(
&Uuid::nil(),
@@ -267,7 +267,7 @@ async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
tokio::fs::remove_dir_all(job_dir).await?;
} else if res.language.as_ref().is_some_and(|x| x == &ScriptLang::Bun) {
let job_id = Uuid::new_v4();
let job_dir = format!("{}/cache_init/{}", TMP_DIR, job_id);
let job_dir = format!("{}/cache_init/{}", *WINDMILL_DIR, job_id);
create_dir_all(&job_dir)?;
if let Some(lock) = res.lockfile {
let _ = windmill_worker::prepare_job_dir(&lock, &job_dir).await?;
@@ -384,9 +384,9 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
println!("Fetched {} resource types from hub", resource_types.len());
create_dir_all(HUB_RT_CACHE_DIR)?;
create_dir_all(&*HUB_RT_CACHE_DIR)?;
let cache_path = format!("{}/{}", HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE);
let cache_path = format!("{}/{}", *HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE);
let content = serde_json::to_string_pretty(&resource_types)
.with_context(|| "Failed to serialize resource types")?;
@@ -398,7 +398,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
}
pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyhow::Result<()> {
let cache_path = format!("{}/{}", HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE);
let cache_path = format!("{}/{}", *HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE);
if tokio::fs::metadata(&cache_path).await.is_err() {
tracing::info!(
@@ -969,7 +969,7 @@ Windmill Community Edition {GIT_VERSION}
DirBuilder::new()
.recursive(true)
.create("/tmp/windmill")
.create(&*WINDMILL_DIR)
.expect("could not create initial server dir");
#[cfg(feature = "tantivy")]
@@ -1717,6 +1717,11 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload critical alert UI setting");
}
}
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING => {
if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
tracing::error!(error = %e, "Could not reload critical alerts on token expiry setting");
}
}
"workspace_telemetry_enabled" => {
// Read the new value from the database and log it
let enabled = sqlx::query_scalar!(
@@ -1794,27 +1799,27 @@ pub async fn run_workers(
let mut handles = Vec::with_capacity(num_workers as usize);
for x in [
TMP_LOGS_DIR,
UV_CACHE_DIR,
DENO_CACHE_DIR,
DENO_CACHE_DIR_DEPS,
DENO_CACHE_DIR_NPM,
BUN_CACHE_DIR,
PY310_CACHE_DIR,
PY311_CACHE_DIR,
PY312_CACHE_DIR,
PY313_CACHE_DIR,
BUN_BUNDLE_CACHE_DIR,
GO_CACHE_DIR,
GO_BIN_CACHE_DIR,
RUST_CACHE_DIR,
CSHARP_CACHE_DIR,
NU_CACHE_DIR,
HUB_CACHE_DIR,
POWERSHELL_CACHE_DIR,
JAVA_CACHE_DIR,
RUBY_CACHE_DIR,
TAR_JAVA_CACHE_DIR, // for related places search: ADD_NEW_LANG
&*TMP_LOGS_DIR,
&*UV_CACHE_DIR,
&*DENO_CACHE_DIR,
&*DENO_CACHE_DIR_DEPS,
&*DENO_CACHE_DIR_NPM,
&*BUN_CACHE_DIR,
&*PY310_CACHE_DIR,
&*PY311_CACHE_DIR,
&*PY312_CACHE_DIR,
&*PY313_CACHE_DIR,
&*BUN_BUNDLE_CACHE_DIR,
&*GO_CACHE_DIR,
&*GO_BIN_CACHE_DIR,
&*RUST_CACHE_DIR,
&*CSHARP_CACHE_DIR,
&*NU_CACHE_DIR,
&*HUB_CACHE_DIR,
&*POWERSHELL_CACHE_DIR,
&*JAVA_CACHE_DIR,
&*RUBY_CACHE_DIR,
&*TAR_JAVA_CACHE_DIR, // for related places search: ADD_NEW_LANG
] {
DirBuilder::new()
.recursive(true)
+158 -26
View File
@@ -44,19 +44,20 @@ use windmill_common::{
apps::APP_WORKSPACED_ROUTE,
auth::create_token_for_owner,
ee_oss::CriticalErrorChannel,
email_oss::send_email_if_possible,
error,
flow_status::{FlowStatus, FlowStatusModule},
global_settings::{
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING,
DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING,
INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING,
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
UV_INDEX_STRATEGY_SETTING,
@@ -73,13 +74,14 @@ use windmill_common::{
load_periodic_bash_script_interval_from_env, load_whitelist_env_vars_from_env,
load_worker_config, reload_custom_tags_setting, store_pull_query,
store_suspended_pull_query, Connection, WorkerConfig, DEFAULT_TAGS_PER_WORKSPACE,
DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, TMP_DIR,
DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR,
WORKER_CONFIG, WORKER_GROUP,
},
KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERT_MUTE_UI_ENABLED,
CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS,
METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED,
OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS,
KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY,
CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL,
HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED,
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
SERVICE_LOG_RETENTION_SECS,
};
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
#[cfg(feature = "parquet")]
@@ -207,6 +209,10 @@ pub async fn initial_load(
tracing::error!("Error loading critical alert mute ui setting: {e:#}");
}
if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
tracing::error!("Error loading critical alerts on token expiry setting: {e:#}");
}
if let Some(db) = conn.as_sql() {
if let Err(e) = load_tag_per_workspace_enabled(db).await {
tracing::error!("Error loading default tag per workpsace: {e:#}");
@@ -477,6 +483,21 @@ pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::
Ok(())
}
pub async fn reload_critical_alerts_on_token_expiry_setting(
conn: &Connection,
) -> error::Result<()> {
if let Ok(Some(serde_json::Value::Bool(t))) = load_value_from_global_settings_with_conn(
conn,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
true,
)
.await
{
CRITICAL_ALERTS_ON_TOKEN_EXPIRY.store(t, Ordering::Relaxed);
}
Ok(())
}
pub async fn load_metrics_debug_enabled(conn: &Connection) -> error::Result<()> {
let metrics_enabled =
load_value_from_global_settings_with_conn(conn, EXPOSE_DEBUG_METRICS_SETTING, true).await;
@@ -595,7 +616,7 @@ async fn sleep_until_next_minute_start_plus_one_s() {
use windmill_common::tracing_init::TMP_WINDMILL_LOGS_SERVICE;
async fn find_two_highest_files(hostname: &str) -> (Option<String>, Option<String>) {
let log_dir = format!("{}/{}/", TMP_WINDMILL_LOGS_SERVICE, hostname);
let log_dir = format!("{}/{}/", *TMP_WINDMILL_LOGS_SERVICE, hostname);
let rd_dir = tokio::fs::read_dir(log_dir).await;
if let Ok(mut log_files) = rd_dir {
let mut highest_file: Option<String> = None;
@@ -614,7 +635,8 @@ async fn find_two_highest_files(hostname: &str) -> (Option<String>, Option<Strin
(highest_file, second_highest_file)
} else {
tracing::error!(
"Error reading log files: {TMP_WINDMILL_LOGS_SERVICE}, {:#?}",
"Error reading log files: {}, {:#?}",
*TMP_WINDMILL_LOGS_SERVICE,
rd_dir.unwrap_err()
);
(None, None)
@@ -716,7 +738,7 @@ async fn send_log_file_to_object_store(
let s3_client = windmill_object_store::get_object_store().await;
#[cfg(feature = "parquet")]
if let Some(s3_client) = s3_client {
let path = std::path::Path::new(TMP_WINDMILL_LOGS_SERVICE)
let path = std::path::Path::new(&*TMP_WINDMILL_LOGS_SERVICE)
.join(hostname)
.join(&highest_file);
@@ -844,18 +866,82 @@ struct LogFile {
hostname: String,
}
struct TokenRow {
token_prefix: Option<String>,
label: Option<String>,
email: Option<String>,
workspace_id: Option<String>,
}
fn is_user_token(label: Option<&str>) -> bool {
match label {
None => true,
Some(l) => l != "session" && !l.starts_with("ephemeral") && !l.starts_with("Ephemeral"),
}
}
async fn report_token_expiration(db: &DB, token: &TokenRow, expired: bool) {
if !is_user_token(token.label.as_deref()) {
return;
}
let prefix = token.token_prefix.as_deref().unwrap_or("??????????");
let email_addr = token.email.as_deref().unwrap_or("unknown");
let token_desc = match token.label.as_deref() {
Some(l) if !l.is_empty() => format!("'{l}' ({prefix}****)"),
_ => format!("{prefix}****"),
};
let (alert_message, email_subject, email_body) = if expired {
(
format!(
"API token {token_desc} of '{email_addr}' has expired and been deleted"
),
"Windmill: Your API token has expired",
format!(
"Your API token {token_desc} has expired and been deleted.\n\nPlease create a new token if you still need API access."
),
)
} else {
(
format!("API token {token_desc} of '{email_addr}' is expiring soon"),
"Windmill: Your API token is expiring soon",
format!(
"Your API token {token_desc} is expiring soon.\n\nPlease rotate or renew your token to avoid service disruption."
),
)
};
tracing::info!("{}", alert_message);
if CRITICAL_ALERTS_ON_TOKEN_EXPIRY.load(Ordering::Relaxed) {
report_critical_error(
alert_message,
db.clone(),
token.workspace_id.as_deref(),
None,
)
.await;
}
if let Some(email) = &token.email {
send_email_if_possible(email_subject, &email_body, email);
}
}
pub async fn delete_expired_items(db: &DB) -> () {
let tokens_deleted_r: std::result::Result<Vec<String>, _> = sqlx::query_scalar(
let expired_tokens_r = sqlx::query_as!(
TokenRow,
"DELETE FROM token WHERE expiration <= now()
RETURNING concat(substring(token for 10), '*****')",
RETURNING substring(token for 10) as token_prefix, label, email, workspace_id",
)
.fetch_all(db)
.await;
match tokens_deleted_r {
match expired_tokens_r {
Ok(tokens) => {
if tokens.len() > 0 {
tracing::info!("deleted {} tokens: {:?}", tokens.len(), tokens)
if !tokens.is_empty() {
tracing::info!("deleted {} expired tokens", tokens.len());
for t in &tokens {
report_token_expiration(db, t, true).await;
}
}
}
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
@@ -935,7 +1021,7 @@ pub async fn delete_expired_items(db: &DB) -> () {
.iter()
.map(|f| format!("{}/{}", f.hostname, f.file_path))
.collect();
delete_log_files_from_disk_and_store(paths, TMP_WINDMILL_LOGS_SERVICE, windmill_common::tracing_init::LOGS_SERVICE).await;
delete_log_files_from_disk_and_store(paths, &*TMP_WINDMILL_LOGS_SERVICE, windmill_common::tracing_init::LOGS_SERVICE).await;
}
Err(e) => tracing::error!("Error deleting log file: {:?}", e),
@@ -1064,6 +1150,41 @@ pub async fn delete_expired_items(db: &DB) -> () {
}
}
pub async fn check_expiring_tokens(db: &DB) {
// Find tokens expiring within 7 days that still have a pending notification row
let expiring_tokens_r = sqlx::query_as!(
TokenRow,
"DELETE FROM token_expiry_notification n
USING token t
WHERE n.token = t.token
AND n.expiration > now()
AND n.expiration <= now() + interval '7 days'
RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id",
)
.fetch_all(db)
.await;
match expiring_tokens_r {
Ok(tokens) => {
for t in &tokens {
report_token_expiration(db, t, false).await;
}
if !tokens.is_empty() {
tracing::info!("Sent expiration warnings for {} token(s)", tokens.len());
}
}
Err(e) => tracing::error!("Error checking expiring tokens: {}", e),
}
// Clean up notification rows whose expiration has passed
if let Err(e) = sqlx::query!("DELETE FROM token_expiry_notification WHERE expiration <= now()")
.execute(db)
.await
{
tracing::error!("Error cleaning up expired token notifications: {}", e);
}
}
/// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments.
/// Uses a single transaction per batch to minimize lock duration.
/// Returns the number of jobs deleted in this batch.
@@ -1140,7 +1261,7 @@ async fn delete_expired_jobs_batch(
.filter_map(|opt| opt)
.flat_map(|inner_vec| inner_vec.into_iter())
.collect();
delete_log_files_from_disk_and_store(paths, TMP_DIR, "").await;
delete_log_files_from_disk_and_store(paths, &*WINDMILL_DIR, "").await;
}
Err(e) => tracing::error!("Error deleting job logs: {:?}", e),
}
@@ -1367,7 +1488,7 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) {
let settings_xml = MAVEN_SETTINGS_XML.read().await.clone();
match settings_xml {
Some(ref content) if !content.trim().is_empty() => {
let m2_dir = format!("{JAVA_HOME_DIR}/.m2");
let m2_dir = format!("{}/.m2", *JAVA_HOME_DIR);
if let Err(e) = tokio::fs::create_dir_all(&m2_dir).await {
tracing::error!("Failed to create .m2 directory: {e:#}");
return;
@@ -1378,7 +1499,7 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) {
}
}
_ => {
let settings_path = format!("{JAVA_HOME_DIR}/.m2/settings.xml");
let settings_path = format!("{}/.m2/settings.xml", *JAVA_HOME_DIR);
let _ = tokio::fs::remove_file(&settings_path).await;
}
}
@@ -2051,6 +2172,16 @@ pub async fn monitor_db(
}
};
// Run every hour (10 iterations * 30s = 5 minutes)
// Check for tokens expiring within 7 days and send alerts
let check_expiring_tokens_f = async {
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
if let Some(db) = conn.as_sql() {
check_expiring_tokens(&db).await;
}
}
};
join!(
expired_items_f,
zombie_jobs_f,
@@ -2072,6 +2203,7 @@ pub async fn monitor_db(
cleanup_worker_group_stats_f,
native_triggers_sync_f,
cleanup_notify_events_f,
check_expiring_tokens_f,
);
}
+2
View File
@@ -151,6 +151,8 @@ sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attri
FK: (workspace_id) -> workspace(id)
token: token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid)
FK: (workspace_id) -> workspace(id)
token_expiry_notification: token(char), expiration(ts)
INDEX: idx_token_expiry_notification_expiration (expiration)
tutorial_progress: email(char), progress(bit64), skipped_all(bool)
unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts)
usage: id(char), is_workspace(bool), month_(int), usage(int)
+323 -4
View File
@@ -1,12 +1,12 @@
#![cfg(all(feature = "private", feature = "agent_worker_server"))]
use windmill_test_utils::*;
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::{
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
};
use windmill_test_utils::*;
fn bun_code(code: &str) -> RawCode {
RawCode {
@@ -18,8 +18,8 @@ fn bun_code(code: &str) -> RawCode {
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings:
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
}
}
@@ -223,7 +223,10 @@ async fn test_agent_worker_token_and_ping(db: Pool<Postgres>) -> anyhow::Result<
.fetch_one(&db)
.await?;
assert!(worker_count > 0, "worker ping should be recorded in database");
assert!(
worker_count > 0,
"worker ping should be recorded in database"
);
// MainLoop ping updates the existing record
let resp = http_client
@@ -265,3 +268,319 @@ async fn test_agent_worker_multiple_jobs_sequential(db: Pool<Postgres>) -> anyho
Ok(())
}
/// Test the volume HTTP proxy endpoints that agent workers use.
///
/// Exercises the full volume lifecycle via HTTP:
/// 1. Configure workspace S3 storage (FilesystemStorage)
/// 2. Pre-populate a volume with a file
/// 3. POST /begin — acquire lease, get manifest
/// 4. GET /file/* — download existing file
/// 5. PUT /file/* — upload a new file
/// 6. POST /commit — finalize with stats, release lease
/// 7. Verify DB state and storage
#[cfg(feature = "parquet")]
#[sqlx::test(fixtures("base"))]
async fn test_agent_worker_volume_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, _port, _server) = init_client_agent_mode(db.clone()).await;
// 1. Set up filesystem-based object storage in a temp dir
let storage_dir = tempfile::tempdir()?;
let storage_root = storage_dir.path().to_string_lossy().to_string();
let lfs_config = json!({
"type": "FilesystemStorage",
"root_path": storage_root,
"public_resource": null,
"advanced_permissions": null
});
sqlx::query!(
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
lfs_config,
"test-workspace"
)
.execute(&db)
.await?;
// 2. Pre-populate the volume with a file
let vol_dir = storage_dir.path().join("volumes").join("test-vol");
std::fs::create_dir_all(&vol_dir)?;
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
let base = client.baseurl();
let http = client.client();
let vol_base = format!("{base}/w/test-workspace/volumes/test-vol");
// 3. POST /begin — acquire lease, get manifest + permissions
let resp = http
.post(format!("{vol_base}/begin"))
.json(&json!({
"worker_name": "test-worker-1",
"permissioned_as": "u/test-user"
}))
.send()
.await?;
assert!(
resp.status().is_success(),
"begin should succeed, got: {}",
resp.status()
);
let begin_body: serde_json::Value = resp.json().await?;
assert!(
begin_body["writable"].as_bool().unwrap(),
"should be writable"
);
let manifest = begin_body["manifest"].as_object().unwrap();
assert!(
manifest.contains_key("hello.txt"),
"manifest should contain hello.txt, got: {manifest:?}"
);
// 4. GET /file/* — download the existing file
let resp = http
.get(format!("{vol_base}/file/hello.txt"))
.send()
.await?;
assert!(
resp.status().is_success(),
"file download should succeed, got: {}",
resp.status()
);
let file_bytes = resp.bytes().await?;
assert_eq!(
file_bytes.as_ref(),
b"hello from volume",
"downloaded file content should match"
);
// 5. PUT /file/* — upload a new file
let resp = http
.put(format!("{vol_base}/file/output.txt"))
.body(b"written by agent worker".to_vec())
.send()
.await?;
assert!(
resp.status().is_success(),
"file upload should succeed, got: {}",
resp.status()
);
// 6. POST /commit — finalize: report stats, release lease
let resp = http
.post(format!("{vol_base}/commit"))
.json(&json!({
"worker_name": "test-worker-1",
"deleted_keys": [],
"symlinks": {},
"file_count": 2,
"size_bytes": 39
}))
.send()
.await?;
assert!(
resp.status().is_success(),
"commit should succeed, got: {}",
resp.status()
);
// 7. Verify volume DB row was updated
let vol_row = sqlx::query!(
"SELECT size_bytes, file_count, leased_by, lease_until
FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?;
let vol_row = vol_row.expect("volume row should exist");
assert_eq!(vol_row.file_count, 2, "file_count should be 2");
assert_eq!(vol_row.size_bytes, 39, "size_bytes should match");
assert!(vol_row.leased_by.is_none(), "lease should be released");
assert!(
vol_row.lease_until.is_none() || vol_row.lease_until.unwrap() < chrono::Utc::now(),
"lease_until should be cleared or in the past"
);
// 8. Verify the uploaded file was persisted in storage
let output_path = vol_dir.join("output.txt");
assert!(output_path.exists(), "output.txt should be in storage");
let output_content = std::fs::read_to_string(&output_path)?;
assert_eq!(output_content, "written by agent worker");
Ok(())
}
/// Full E2E test: agent worker in HTTP mode runs a Bun script with a volume mount.
///
/// The worker pulls the job via HTTP, downloads volume files via the server-side
/// volume proxy endpoints, executes the script, and syncs changes back.
#[cfg(all(feature = "parquet", feature = "enterprise"))]
#[sqlx::test(fixtures("base"))]
async fn test_agent_worker_volume_http_worker_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
let (_client, port, _server) = init_client_agent_mode(db.clone()).await;
// 1. Set up filesystem-based object storage in a temp dir
let storage_dir = tempfile::tempdir()?;
let storage_root = storage_dir.path().to_string_lossy().to_string();
let lfs_config = json!({
"type": "FilesystemStorage",
"root_path": storage_root,
"public_resource": null,
"advanced_permissions": null
});
sqlx::query!(
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
lfs_config,
"test-workspace"
)
.execute(&db)
.await?;
// 2. Pre-populate the volume with a file
let vol_dir = storage_dir.path().join("volumes").join("test-vol");
std::fs::create_dir_all(&vol_dir)?;
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
// 3. Push the job, then run worker with HTTP connection (bun tag)
let code = r#"// volume: test-vol /tmp/data
import { readFileSync, writeFileSync, existsSync } from "fs";
export function main() {
const content = readFileSync("/tmp/data/hello.txt", "utf-8");
writeFileSync("/tmp/data/output.txt", "written by agent worker");
return {
read_content: content,
output_exists: existsSync("/tmp/data/output.txt"),
};
}"#;
let uuid = RunJob::from(JobPayload::Code(bun_code(code)))
.push(&db)
.await;
let listener = listen_for_completed_jobs(&db).await;
let conn = testing_http_connection_with_tags(
port,
vec!["bun".into(), "flow".into(), "dependency".into()],
)
.await;
in_test_worker(conn, listener.find(&uuid), port).await;
let result = completed_job(uuid, &db).await;
assert!(result.success, "job should succeed: {:?}", result.result);
let json = result.json_result().expect("should have JSON result");
assert_eq!(json["read_content"], json!("hello from volume"));
assert_eq!(json["output_exists"], json!(true));
// 4. Verify volume DB row was updated
let vol_row = sqlx::query!(
"SELECT size_bytes, file_count, leased_by, lease_until
FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?;
let vol_row = vol_row.expect("volume row should exist");
assert!(
vol_row.file_count >= 2,
"should have at least 2 files (hello.txt + output.txt), got: {}",
vol_row.file_count
);
assert!(vol_row.size_bytes > 0, "size_bytes should be > 0");
assert!(vol_row.leased_by.is_none(), "lease should be released");
assert!(
vol_row.lease_until.is_none() || vol_row.lease_until.unwrap() < chrono::Utc::now(),
"lease_until should be cleared or in the past"
);
// 5. Verify the new file was written back to the storage
let output_path = vol_dir.join("output.txt");
assert!(
output_path.exists(),
"output.txt should be synced back to storage"
);
let output_content = std::fs::read_to_string(&output_path)?;
assert_eq!(output_content, "written by agent worker");
Ok(())
}
/// Test the volume release endpoint (error/cancel path).
#[cfg(feature = "parquet")]
#[sqlx::test(fixtures("base"))]
async fn test_agent_worker_volume_release(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, _port, _server) = init_client_agent_mode(db.clone()).await;
// Set up filesystem storage
let storage_dir = tempfile::tempdir()?;
let storage_root = storage_dir.path().to_string_lossy().to_string();
let lfs_config = json!({
"type": "FilesystemStorage",
"root_path": storage_root,
"public_resource": null,
"advanced_permissions": null
});
sqlx::query!(
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
lfs_config,
"test-workspace"
)
.execute(&db)
.await?;
let base = client.baseurl();
let http = client.client();
let vol_base = format!("{base}/w/test-workspace/volumes/test-vol");
// Begin (acquire lease)
let resp = http
.post(format!("{vol_base}/begin"))
.json(&json!({
"worker_name": "test-worker-2",
"permissioned_as": "u/test-user"
}))
.send()
.await?;
assert!(resp.status().is_success(), "begin should succeed");
// Verify lease is held
let leased = sqlx::query_scalar!(
"SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?
.flatten();
assert_eq!(leased.as_deref(), Some("test-worker-2"));
// Release without commit (simulating error path)
let resp = http
.post(format!("{vol_base}/release"))
.json(&json!({ "worker_name": "test-worker-2" }))
.send()
.await?;
assert!(resp.status().is_success(), "release should succeed");
// Verify lease is cleared
let leased = sqlx::query_scalar!(
"SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?
.flatten();
assert!(leased.is_none(), "lease should be released");
Ok(())
}
+238
View File
@@ -1,5 +1,6 @@
use sqlx::postgres::Postgres;
use sqlx::Pool;
use uuid::Uuid;
use windmill_common::jobs::{JobPayload, RawCode};
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
@@ -1448,3 +1449,240 @@ export function main() { return { a, b }; }
);
}
}
// ============================================================================
// Codebase Mode Tests
// ============================================================================
/// Create a TAR archive in memory containing a single `main.js` file.
fn create_codebase_tar(main_js_content: &str) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
let content = main_js_content.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_path("main.js").unwrap();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append(&header, content).unwrap();
builder.into_inner().unwrap()
}
/// Place a TAR codebase at the expected cache path for the given job ID and hash.
fn place_codebase_in_cache(job_id: &Uuid, tar_bytes: &[u8], is_esm: bool) {
let codebase_id = if is_esm {
format!("{}.esm.tar", job_id)
} else {
format!("{}.tar", job_id)
};
let bundle_path = format!("script_bundle/test-workspace/{}", codebase_id);
let cache_path = format!(
"{}/{}.tar",
*windmill_common::worker::ROOT_CACHE_NOMOUNT_DIR,
bundle_path,
);
let parent = std::path::Path::new(&cache_path).parent().unwrap();
std::fs::create_dir_all(parent).unwrap();
std::fs::write(&cache_path, tar_bytes).unwrap();
}
#[sqlx::test(fixtures("base"))]
async fn test_cjs_codebase_tar(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
module.exports.main = function() {
return "cjs codebase ok";
};
"#;
let inner_content = r#"export function main() { return "cjs codebase ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, false);
let job = JobPayload::Code(RawCode {
hash: Some(-43), // PREVIEW_IS_TAR_CODEBASE_HASH
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("cjs codebase ok"));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_esm_codebase_tar(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
export function main() {
return "esm codebase ok";
}
"#;
let inner_content = r#"export function main() { return "esm codebase ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, true);
let job = JobPayload::Code(RawCode {
hash: Some(-45), // PREVIEW_IS_TAR_ESM_CODEBASE_HASH
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("esm codebase ok"));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_cjs_codebase_tar_nsjail(db: Pool<Postgres>) -> anyhow::Result<()> {
if std::process::Command::new("nsjail")
.arg("--help")
.output()
.is_err()
{
eprintln!("nsjail not found, skipping test");
return Ok(());
}
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
module.exports.main = function() {
return "cjs nsjail ok";
};
"#;
let inner_content = r#"export function main() { return "cjs nsjail ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, false);
let job = JobPayload::Code(RawCode {
hash: Some(-43),
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
use std::sync::atomic::Ordering;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::NsjailSandboxing as u8,
Ordering::Relaxed,
);
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::Undefined as u8,
Ordering::Relaxed,
);
let json = result.json_result().unwrap();
assert_eq!(json, serde_json::json!("cjs nsjail ok"));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_esm_codebase_tar_nsjail(db: Pool<Postgres>) -> anyhow::Result<()> {
if std::process::Command::new("nsjail")
.arg("--help")
.output()
.is_err()
{
eprintln!("nsjail not found, skipping test");
return Ok(());
}
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
export function main() {
return "esm nsjail ok";
}
"#;
let inner_content = r#"export function main() { return "esm nsjail ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, true);
let job = JobPayload::Code(RawCode {
hash: Some(-45),
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
use std::sync::atomic::Ordering;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::NsjailSandboxing as u8,
Ordering::Relaxed,
);
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::Undefined as u8,
Ordering::Relaxed,
);
let json = result.json_result().unwrap();
assert_eq!(json, serde_json::json!("esm nsjail ok"));
Ok(())
}
+323
View File
@@ -0,0 +1,323 @@
//! Tests for WM_END_USER_EMAIL environment variable.
//!
//! These tests verify that WM_END_USER_EMAIL is populated with the authenticated
//! user's email when executing app components.
//!
//! TODO: Add tests for scripts and flows once public execution endpoints are identified.
//! Currently only apps support non-workspace-member execution via OptAuthed + token lookup.
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::worker::Connection;
use windmill_test_utils::*;
const SAME_WS_TOKEN: &str = "SECRET_TOKEN";
const OTHER_WS_TOKEN: &str = "OTHER_WS_TOKEN";
const NO_WS_TOKEN: &str = "NO_WS_TOKEN";
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";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
// TODO: Script tests - need to identify public execution endpoints for non-workspace-members
// async fn run_script(port: u16, token: &str) -> anyhow::Result<String> {
// let url = format!(
// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/p/f/test/get_end_user_email",
// port
// );
// let resp = authed(client().post(&url), token)
// .json(&json!({}))
// .send()
// .await?;
// if !resp.status().is_success() {
// anyhow::bail!("script run failed: {} - {}", resp.status(), resp.text().await?);
// }
// Ok(resp.json::<serde_json::Value>().await?
// .as_str().unwrap_or("").to_string())
// }
// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members
// async fn run_flow(port: u16, token: &str) -> anyhow::Result<String> {
// let url = format!(
// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/f/f/test/get_end_user_email_flow",
// port
// );
// let resp = authed(client().post(&url), token)
// .json(&json!({}))
// .send()
// .await?;
// if !resp.status().is_success() {
// anyhow::bail!("flow run failed: {} - {}", resp.status(), resp.text().await?);
// }
// Ok(resp.json::<serde_json::Value>().await?
// .as_str().unwrap_or("").to_string())
// }
/// Create an app with inline script via API
async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/apps/create",
port
);
let resp = authed(client().post(&url), SAME_WS_TOKEN)
.json(&json!({
"path": path,
"summary": "Test app for WM_END_USER_EMAIL",
"value": {
"type": "app",
"grid": [],
"subgrids": {},
"hiddenInlineScripts": [{
"name": "get_email",
"language": "deno",
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }",
"path": "f/test/email_app/get_email"
}]
},
"policy": {
"execution_mode": "anonymous",
"on_behalf_of": null,
"on_behalf_of_email": null,
"triggerables_v2": {
"get_email": {
"static_inputs": {},
"one_of_inputs": {}
},
// SHA256 hash of raw_code content for anonymous execution
"rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": {
"static_inputs": {},
"one_of_inputs": {}
}
}
}
}))
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("create app failed: {} - {}", resp.status(), resp.text().await?);
}
Ok(())
}
/// Create a raw app with inline script via API (uses regular app endpoint with rawapp type)
async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/apps/create",
port
);
let resp = authed(client().post(&url), SAME_WS_TOKEN)
.json(&json!({
"path": path,
"summary": "Test raw app for WM_END_USER_EMAIL",
"value": {
"type": "rawapp",
"css": "",
"inlineScripts": [{
"name": "get_email",
"language": "deno",
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }"
}]
},
"policy": {
"execution_mode": "anonymous",
"on_behalf_of": null,
"on_behalf_of_email": null,
"triggerables_v2": {
"get_email": {
"static_inputs": {},
"one_of_inputs": {}
},
// SHA256 hash of raw_code content for anonymous execution
"rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": {
"static_inputs": {},
"one_of_inputs": {}
}
}
}
}))
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("create raw app failed: {} - {}", resp.status(), resp.text().await?);
}
Ok(())
}
async fn run_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": "deno",
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }",
"path": format!("{}/get_email", app_path)
}
});
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!("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 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": "deno",
"content": "export function main() { return Deno.env.get(\"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/{}",
port, job_id
);
for _ in 0..100 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let resp = authed(client().get(&url), token).send().await?;
if resp.status().is_success() {
return Ok(resp.json::<serde_json::Value>().await?
.as_str().unwrap_or("").to_string());
}
}
anyhow::bail!("timeout waiting for job result")
}
// TODO: Script tests - need to identify public execution endpoints for non-workspace-members
// #[cfg(feature = "deno_core")]
// #[sqlx::test(fixtures("base", "end_user_email"))]
// async fn test_script_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
// initialize_tracing().await;
// set_jwt_secret().await;
// let server = ApiServer::start(db.clone()).await?;
// let port = server.addr.port();
//
// in_test_worker(Connection::Sql(db.clone()), async move {
// let result = run_script(port, SAME_WS_TOKEN).await?;
// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
// Ok::<(), anyhow::Error>(())
// }, port).await?;
//
// Ok(())
// }
// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members
// #[cfg(feature = "deno_core")]
// #[sqlx::test(fixtures("base", "end_user_email"))]
// async fn test_flow_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
// initialize_tracing().await;
// set_jwt_secret().await;
// let server = ApiServer::start(db.clone()).await?;
// let port = server.addr.port();
//
// in_test_worker(Connection::Sql(db.clone()), async move {
// let result = run_flow(port, SAME_WS_TOKEN).await?;
// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
// Ok::<(), anyhow::Error>(())
// }, port).await?;
//
// Ok(())
// }
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base", "end_user_email"))]
async fn test_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let app_path = "f/test/email_app";
in_test_worker(Connection::Sql(db.clone()), async move {
// Create the app with inline script first
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?;
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?;
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?;
assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email");
Ok::<(), anyhow::Error>(())
}, port).await?;
Ok(())
}
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base", "end_user_email"))]
async fn test_raw_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let app_path = "f/test/email_raw_app";
in_test_worker(Connection::Sql(db.clone()), async move {
// Create the raw app with inline script first
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?;
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?;
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?;
assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email");
Ok::<(), anyhow::Error>(())
}, port).await?;
Ok(())
}
+63
View File
@@ -0,0 +1,63 @@
-- Fixture for WM_END_USER_EMAIL tests
-- Sets up 3 users with different workspace memberships:
-- 1. test@windmill.dev - in test-workspace (from base.sql)
-- 2. other-ws@windmill.dev - in other-workspace only
-- 3. no-ws@windmill.dev - not in any workspace
-- Second workspace for cross-workspace user
INSERT INTO workspace (id, name, owner)
VALUES ('other-workspace', 'other-workspace', 'other-ws-user');
INSERT INTO workspace_key(workspace_id, kind, key)
VALUES ('other-workspace', 'cloud', 'other-key');
INSERT INTO workspace_settings (workspace_id)
VALUES ('other-workspace');
INSERT INTO group_ (workspace_id, name, summary, extra_perms)
VALUES ('other-workspace', 'all', 'All users', '{}');
-- User in other-workspace only (not in test-workspace)
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
VALUES ('other-ws@windmill.dev', 'hash', 'password', false, true, 'Other WS User');
INSERT INTO usr(workspace_id, email, username, is_admin, role)
VALUES ('other-workspace', 'other-ws@windmill.dev', 'other-ws-user', true, 'Admin');
INSERT INTO token(token, email, label, super_admin)
VALUES ('OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false);
-- User not in any workspace
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
VALUES ('no-ws@windmill.dev', 'hash', 'password', false, true, 'No WS User');
INSERT INTO token(token, email, label, super_admin)
VALUES ('NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false);
-- Script that returns WM_END_USER_EMAIL (public via extra_perms)
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, extra_perms)
VALUES (
'test-workspace', 'test-user',
'export function main() { return Deno.env.get("WM_END_USER_EMAIL") || ""; }',
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email', 900001, 'deno', '', 'script',
'{"g/all": true}'
);
-- Flow that returns WM_END_USER_EMAIL (public via extra_perms)
INSERT INTO flow (workspace_id, summary, description, path, versions, schema, value, edited_by, extra_perms)
VALUES (
'test-workspace', 'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email_flow', '{900002}',
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
'{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}',
'test-user',
'{"g/all": true}'
);
INSERT INTO flow_version (id, workspace_id, path, schema, value, created_by)
VALUES (
900002, 'test-workspace', 'f/test/get_end_user_email_flow',
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
'{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}',
'test-user'
);
+1 -1
View File
@@ -206,7 +206,7 @@ fn spawn_workers(
std::fs::DirBuilder::new()
.recursive(true)
.create(windmill_worker::GO_BIN_CACHE_DIR)
.create(&*windmill_worker::GO_BIN_CACHE_DIR)
.expect("could not create initial worker dir");
let (tx, _) = KillpillSender::new(n + 1);
+15 -8
View File
@@ -1,7 +1,7 @@
use windmill_test_utils::*;
use sqlx::postgres::Postgres;
use sqlx::Pool;
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "lockfile_python"))]
@@ -188,7 +188,8 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -207,14 +208,14 @@ def main():
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_global_site_packages(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::{cache::concatcp, worker::ROOT_CACHE_DIR};
use windmill_common::worker::ROOT_CACHE_DIR;
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Shared for all 3.12.*
let path = concatcp!(ROOT_CACHE_DIR, "python_3_12/global-site-packages").to_owned();
let path = format!("{}python_3_12/global-site-packages", *ROOT_CACHE_DIR);
std::fs::create_dir_all(&path).unwrap();
std::fs::write(path + "/my_global_site_package_3_12_any.py", "").unwrap();
@@ -237,7 +238,9 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
)
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -271,7 +274,9 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
)
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -310,7 +315,8 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -347,7 +353,8 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -0,0 +1,102 @@
// volume: agent-memory .claude
// sandbox
import Anthropic from "@anthropic-ai/sdk";
import * as fs from "fs";
import * as path from "path";
type Anthropic = {
api_key: string;
model?: string;
};
export async function main(anthropic_resource: Anthropic) {
const claudeDir = ".claude";
const results: Record<string, unknown> = {};
// --- Step 1: Verify volume is mounted at the relative path ---
results["volume_exists"] = fs.existsSync(claudeDir);
if (!results["volume_exists"]) {
fs.mkdirSync(claudeDir, { recursive: true });
}
const testFile = path.join(claudeDir, "mount-check.txt");
fs.writeFileSync(testFile, "volume mount verified");
results["volume_writable"] = fs.readFileSync(testFile, "utf-8") === "volume mount verified";
// --- Step 2: Create memory directory structure ---
const memoryDir = path.join(claudeDir, "memory");
fs.mkdirSync(memoryDir, { recursive: true });
const memoryFile = path.join(memoryDir, "MEMORY.md");
fs.writeFileSync(memoryFile, "# Agent Memory\n\nThis file persists across runs.\n");
results["memory_file_created"] = fs.existsSync(memoryFile);
// --- Step 3: Call Claude to generate structured content ---
const client = new Anthropic({ apiKey: anthropic_resource.api_key });
const model = anthropic_resource.model ?? "claude-sonnet-4-20250514";
const response = await client.messages.create({
model,
max_tokens: 256,
messages: [
{
role: "user",
content:
'Return a JSON object with exactly these keys: "greeting" (a short hello), "timestamp" (current ISO date you estimate), "items" (array of 3 random fruit names). Only return the JSON, no markdown.',
},
],
});
const assistantText =
response.content[0].type === "text" ? response.content[0].text : "";
results["claude_responded"] = assistantText.length > 0;
results["claude_model"] = response.model;
results["claude_stop_reason"] = response.stop_reason;
let parsed: Record<string, unknown> = {};
try {
parsed = JSON.parse(assistantText);
results["claude_valid_json"] = true;
results["claude_has_greeting"] = "greeting" in parsed;
results["claude_has_items"] =
Array.isArray(parsed.items) && parsed.items.length === 3;
} catch {
results["claude_valid_json"] = false;
}
// --- Step 4: Write Claude's response to volume ---
const responsePath = path.join(claudeDir, "claude-response.json");
fs.writeFileSync(responsePath, JSON.stringify(parsed, null, 2));
results["response_written"] = fs.existsSync(responsePath);
// --- Step 5: Read back and verify ---
const readBack = fs.readFileSync(responsePath, "utf-8");
const readParsed = JSON.parse(readBack);
results["readback_matches"] =
JSON.stringify(readParsed) === JSON.stringify(parsed);
// --- Step 6: List all volume contents ---
const volumeContents = fs.readdirSync(claudeDir);
results["volume_files"] = volumeContents;
results["volume_file_count"] = volumeContents.length;
// --- Step 7: Verify memory file persists ---
const memoryContent = fs.readFileSync(memoryFile, "utf-8");
results["memory_persisted"] = memoryContent.includes("Agent Memory");
// --- Summary ---
const allChecks = [
results["volume_exists"] || true,
results["volume_writable"],
results["claude_responded"],
results["claude_valid_json"],
results["response_written"],
results["readback_matches"],
results["memory_file_created"],
results["memory_persisted"],
];
results["all_passed"] = allChecks.every(Boolean);
return results;
}
+637
View File
@@ -0,0 +1,637 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::jobs::{JobPayload, RawCode};
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
#[sqlx::test(fixtures("base"))]
async fn test_volume_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"test-volume",
1024_i64,
"test-user"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT workspace_id, name, size_bytes, created_by, last_used_at
FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-volume"
)
.fetch_one(&db)
.await?;
assert_eq!(row.workspace_id, "test-workspace");
assert_eq!(row.name, "test-volume");
assert_eq!(row.size_bytes, 1024);
assert_eq!(row.created_by, "test-user");
assert!(row.last_used_at.is_none());
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_upsert_size(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET size_bytes = $3, last_used_at = now()",
"test-workspace",
"upsert-vol",
500_i64,
"test-user"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT size_bytes FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"upsert-vol"
)
.fetch_one(&db)
.await?;
assert_eq!(row.size_bytes, 500);
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET size_bytes = $3, last_used_at = now()",
"test-workspace",
"upsert-vol",
2048_i64,
"test-user"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT size_bytes, last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"upsert-vol"
)
.fetch_one(&db)
.await?;
assert_eq!(row.size_bytes, 2048);
assert!(row.last_used_at.is_some());
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_update_last_used(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"used-vol",
100_i64,
"test-user"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"used-vol"
)
.fetch_one(&db)
.await?;
assert!(row.last_used_at.is_none());
sqlx::query!(
"UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"used-vol"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"used-vol"
)
.fetch_one(&db)
.await?;
assert!(row.last_used_at.is_some());
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_update_nonexistent_noop(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let result = sqlx::query!(
"UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"nonexistent-vol"
)
.execute(&db)
.await?;
assert_eq!(result.rows_affected(), 0);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_list_multiple(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
for i in 0..5 {
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
format!("vol-{}", i),
(i * 100) as i64,
"test-user"
)
.execute(&db)
.await?;
}
let rows = sqlx::query!(
"SELECT name, size_bytes FROM volume WHERE workspace_id = $1 ORDER BY name",
"test-workspace"
)
.fetch_all(&db)
.await?;
assert_eq!(rows.len(), 5);
assert_eq!(rows[0].name, "vol-0");
assert_eq!(rows[0].size_bytes, 0);
assert_eq!(rows[4].name, "vol-4");
assert_eq!(rows[4].size_bytes, 400);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_delete(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"deleteme",
100_i64,
"test-user"
)
.execute(&db)
.await?;
let count = sqlx::query_scalar!(
"SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"deleteme"
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(1));
sqlx::query!(
"DELETE FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"deleteme"
)
.execute(&db)
.await?;
let count = sqlx::query_scalar!(
"SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"deleteme"
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(0));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_workspace_fk_constraint(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let result = sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"nonexistent-workspace",
"vol",
100_i64,
"test-user"
)
.execute(&db)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("foreign key"),
"Expected foreign key violation, got: {}",
err
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_primary_key_uniqueness(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"unique-vol",
100_i64,
"test-user"
)
.execute(&db)
.await?;
let result = sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"unique-vol",
200_i64,
"another-user"
)
.execute(&db)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("duplicate key") || err.contains("unique"),
"Expected unique violation, got: {}",
err
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_extra_perms(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// Insert volume with default (empty) extra_perms
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"perms-vol",
100_i64,
"test-user"
)
.execute(&db)
.await?;
// Default extra_perms should be empty object
let row = sqlx::query!(
"SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"perms-vol"
)
.fetch_one(&db)
.await?;
assert_eq!(row.extra_perms, serde_json::json!({}));
// Set extra_perms via jsonb_set (same pattern as granular_acls.rs)
sqlx::query!(
"UPDATE volume SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2::bool), true)
WHERE workspace_id = $3 AND name = $4",
&vec!["u/alice".to_string()],
true,
"test-workspace",
"perms-vol"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"perms-vol"
)
.fetch_one(&db)
.await?;
let perms = row.extra_perms.as_object().unwrap();
assert_eq!(perms.get("u/alice").and_then(|v| v.as_bool()), Some(true));
// Remove a permission entry
sqlx::query!(
"UPDATE volume SET extra_perms = extra_perms - $1
WHERE workspace_id = $2 AND name = $3",
"u/alice",
"test-workspace",
"perms-vol"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"perms-vol"
)
.fetch_one(&db)
.await?;
assert_eq!(row.extra_perms, serde_json::json!({}));
Ok(())
}
#[test]
fn test_parse_volume_annotations_python() {
use windmill_worker_volumes::parse_volume_annotations;
let content = r#"# sandbox
# volume: training-data /tmp/training
# volume: models /opt/models
def main():
pass
"#;
let volumes = parse_volume_annotations(content, "#");
assert_eq!(volumes.len(), 2);
assert_eq!(volumes[0].name, "training-data");
assert_eq!(volumes[0].target, "/tmp/training");
assert_eq!(volumes[1].name, "models");
assert_eq!(volumes[1].target, "/opt/models");
}
#[test]
fn test_parse_volume_annotations_typescript() {
use windmill_worker_volumes::parse_volume_annotations;
let content = r#"// sandbox
// volume: datasets /tmp/datasets
export async function main() {
return "hello";
}
"#;
let volumes = parse_volume_annotations(content, "//");
assert_eq!(volumes.len(), 1);
assert_eq!(volumes[0].name, "datasets");
assert_eq!(volumes[0].target, "/tmp/datasets");
}
#[test]
fn test_parse_volume_annotations_no_prefix_match() {
use windmill_worker_volumes::parse_volume_annotations;
let content = "def main():\n pass";
let volumes = parse_volume_annotations(content, "#");
assert!(volumes.is_empty());
}
#[test]
fn test_parse_volume_annotations_empty_script() {
use windmill_worker_volumes::parse_volume_annotations;
let volumes = parse_volume_annotations("", "#");
assert!(volumes.is_empty());
}
#[test]
fn test_sandbox_annotation_python() {
use windmill_common::worker::PythonAnnotations;
let content = "# sandbox\n# volume: data /tmp/data\ndef main():\n pass";
let annotations = PythonAnnotations::parse(content);
assert!(annotations.sandbox);
}
#[test]
fn test_sandbox_annotation_typescript() {
use windmill_common::worker::TypeScriptAnnotations;
let content = "// sandbox\n// volume: data /tmp/data\nexport function main() {}";
let annotations = TypeScriptAnnotations::parse(content);
assert!(annotations.sandbox);
}
#[test]
fn test_volume_comment_prefix_selection() {
use windmill_common::scripts::ScriptLang;
let get_prefix = |lang: &ScriptLang| -> &str {
match lang {
ScriptLang::Python3
| ScriptLang::Bash
| ScriptLang::Powershell
| ScriptLang::Ansible
| ScriptLang::Ruby => "#",
ScriptLang::Deno
| ScriptLang::Bun
| ScriptLang::Bunnative
| ScriptLang::Nativets
| ScriptLang::Go => "//",
_ => "",
}
};
assert_eq!(get_prefix(&ScriptLang::Python3), "#");
assert_eq!(get_prefix(&ScriptLang::Bash), "#");
assert_eq!(get_prefix(&ScriptLang::Powershell), "#");
assert_eq!(get_prefix(&ScriptLang::Ansible), "#");
assert_eq!(get_prefix(&ScriptLang::Ruby), "#");
assert_eq!(get_prefix(&ScriptLang::Deno), "//");
assert_eq!(get_prefix(&ScriptLang::Bun), "//");
assert_eq!(get_prefix(&ScriptLang::Bunnative), "//");
assert_eq!(get_prefix(&ScriptLang::Nativets), "//");
assert_eq!(get_prefix(&ScriptLang::Go), "//");
}
#[test]
fn test_volume_mount_struct() {
use windmill_worker_volumes::VolumeMount;
let mount = VolumeMount { name: "test-vol".to_string(), target: "/mnt/data".to_string() };
assert_eq!(mount.name, "test-vol");
assert_eq!(mount.target, "/mnt/data");
}
#[test]
fn test_parse_volume_relative_path() {
use windmill_worker_volumes::parse_volume_annotations;
let content = "// volume: agent-memory .claude\nexport function main() {}";
let volumes = parse_volume_annotations(content, "//");
assert_eq!(volumes.len(), 1);
assert_eq!(volumes[0].name, "agent-memory");
assert_eq!(volumes[0].target, ".claude");
}
#[test]
fn test_parse_volume_relative_nested_path() {
use windmill_worker_volumes::parse_volume_annotations;
let content = "# volume: data data/models\ndef main():\n pass";
let volumes = parse_volume_annotations(content, "#");
assert_eq!(volumes.len(), 1);
assert_eq!(volumes[0].name, "data");
assert_eq!(volumes[0].target, "data/models");
}
#[cfg(feature = "private")]
#[test]
fn test_volume_nsjail_mount() {
use std::path::Path;
use windmill_worker_volumes::volume_nsjail_mount;
let result = volume_nsjail_mount(Path::new("/tmp/volumes/data"), "/mnt/data");
assert!(result.contains("src: \"/tmp/volumes/data\""));
assert!(result.contains("dst: \"/mnt/data\""));
assert!(result.contains("is_bind: true"));
assert!(result.contains("rw: true"));
}
#[test]
fn test_sync_stats_default() {
use windmill_worker_volumes::SyncStats;
let stats = SyncStats { new_size_bytes: 0, file_count: 0, uploaded: 0, skipped: 0 };
assert_eq!(stats.new_size_bytes, 0);
assert_eq!(stats.file_count, 0);
assert_eq!(stats.uploaded, 0);
assert_eq!(stats.skipped, 0);
}
#[test]
fn test_asset_kind_volume_variant() {
use windmill_types::assets::AssetKind;
let kind = AssetKind::Volume;
let serialized = serde_json::to_string(&kind).unwrap();
assert_eq!(serialized, "\"volume\"");
let deserialized: AssetKind = serde_json::from_str("\"volume\"").unwrap();
assert!(matches!(deserialized, AssetKind::Volume));
}
/// E2E test: run a bun script with volume mount through a SQL-connected worker.
/// Pre-populates the volume in filesystem storage, verifies the script can read
/// files and write new ones, then checks sync-back to storage and DB state.
#[cfg(feature = "parquet")]
#[sqlx::test(fixtures("base"))]
async fn test_volume_sql_worker_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// 1. Set up filesystem-based object storage in a temp dir
let storage_dir = tempfile::tempdir()?;
let storage_root = storage_dir.path().to_string_lossy().to_string();
let lfs_config = json!({
"type": "FilesystemStorage",
"root_path": storage_root,
"public_resource": null,
"advanced_permissions": null,
"volume_storage": "primary"
});
sqlx::query!(
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
lfs_config,
"test-workspace"
)
.execute(&db)
.await?;
// 2. Pre-populate the volume with a file (workspace-namespaced path)
let vol_dir = storage_dir
.path()
.join("volumes")
.join("test-workspace")
.join("test-vol");
std::fs::create_dir_all(&vol_dir)?;
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
// 3. Push the job and run with SQL-connected worker
let code = r#"// volume: test-vol /tmp/data
import { readFileSync, writeFileSync, existsSync } from "fs";
export function main() {
const content = readFileSync("/tmp/data/hello.txt", "utf-8");
writeFileSync("/tmp/data/output.txt", "written by sql worker");
return {
read_content: content,
output_exists: existsSync("/tmp/data/output.txt"),
};
}"#;
let job = JobPayload::Code(RawCode {
hash: None,
content: code.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port).await;
assert!(result.success, "job should succeed: {:?}", result.result);
let json = result.json_result().expect("should have JSON result");
assert_eq!(json["read_content"], json!("hello from volume"));
assert_eq!(json["output_exists"], json!(true));
// 4. Verify volume DB row was updated
let vol_row = sqlx::query!(
"SELECT size_bytes, file_count, leased_by, lease_until
FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?;
let vol_row = vol_row.expect("volume row should exist");
assert!(
vol_row.file_count >= 2,
"should have at least 2 files (hello.txt + output.txt), got: {}",
vol_row.file_count
);
assert!(vol_row.size_bytes > 0, "size_bytes should be > 0");
assert!(vol_row.leased_by.is_none(), "lease should be released");
// 5. Verify the new file was written back to storage
let output_path = vol_dir.join("output.txt");
assert!(
output_path.exists(),
"output.txt should be synced back to storage"
);
let output_content = std::fs::read_to_string(&output_path)?;
assert_eq!(output_content, "written by sql worker");
Ok(())
}
+167
View File
@@ -3548,3 +3548,170 @@ async fn test_flow_substep_tag_availability_check(db: Pool<Postgres>) -> anyhow:
Ok(())
}
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_stop_after_all_iters_if_bad_expr_parallel_branchall(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let port = 123;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
"modules": [
{
"id": "a",
"value": {
"branches": [
{"modules": [{
"id": "b",
"value": {
"input_transforms": { "n": { "type": "javascript", "expr": "flow_input.n" } },
"type": "rawscript",
"language": "python3",
"content": "def main(n): return n",
},
}]}
],
"type": "branchall",
"parallel": true,
},
"stop_after_all_iters_if": {
"expr": "invalid!!!syntax",
"skip_if_stopped": false,
},
},
],
}))
.unwrap();
let job = JobPayload::RawFlow { value: flow, path: None, restarted_from: None };
let cjob = RunJob::from(job)
.arg("n", json!(42))
.run_until_complete(&db, false, port)
.await;
assert!(
!cjob.success,
"flow should fail when stop_after_all_iters_if has bad expression"
);
let result = cjob.json_result().unwrap();
let error_msg = result["error"]["message"].as_str().unwrap_or("");
assert!(
error_msg.contains("stop_after_all_iters_if"),
"error should mention stop_after_all_iters_if, got: {error_msg}"
);
Ok(())
}
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_stop_after_all_iters_if_bad_expr_parallel_forloop(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let port = 123;
let flow: FlowValue = serde_json::from_value(serde_json::json!({
"modules": [
{
"id": "a",
"value": {
"type": "forloopflow",
"iterator": { "type": "javascript", "expr": "result.items" },
"skip_failures": false,
"parallel": true,
"modules": [{
"value": {
"input_transforms": {
"n": { "type": "javascript", "expr": "flow_input.iter.value" },
},
"type": "rawscript",
"language": "python3",
"content": "def main(n): return n",
},
}],
},
"stop_after_all_iters_if": {
"expr": "invalid!!!syntax",
"skip_if_stopped": false,
},
},
],
}))
.unwrap();
let job = JobPayload::RawFlow { value: flow, path: None, restarted_from: None };
let cjob = RunJob::from(job)
.arg("items", json!([1, 2, 3]))
.run_until_complete(&db, false, port)
.await;
assert!(
!cjob.success,
"flow should fail when stop_after_all_iters_if has bad expression"
);
let result = cjob.json_result().unwrap();
let error_msg = result["error"]["message"].as_str().unwrap_or("");
assert!(
error_msg.contains("stop_after_all_iters_if"),
"error should mention stop_after_all_iters_if, got: {error_msg}"
);
Ok(())
}
#[cfg(all(feature = "quickjs", feature = "python"))]
#[sqlx::test(fixtures("base"))]
async fn test_results_length_in_input_transform(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Step a returns a list, step b accesses results.a.length via input transform.
// This tests that the handle_full_regex fast path falls through to QuickJS
// when the SQL JSON path operator can't resolve JS properties like .length.
let flow: FlowValue = serde_json::from_value(json!({
"modules": [
{
"id": "a",
"value": {
"type": "rawscript",
"language": "python3",
"content": "def main(): return [10, 20, 30]",
},
},
{
"id": "b",
"value": {
"input_transforms": {
"v": { "type": "javascript", "expr": "results.a.length" },
},
"type": "rawscript",
"language": "python3",
"content": "def main(v): return v",
},
},
],
}))
.unwrap();
let result =
RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None })
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(
result,
json!(3),
"results.a.length should resolve to 3, not null"
);
Ok(())
}
@@ -51,4 +51,12 @@ impl AgentCache {
pub fn new() -> Self {
AgentCache {}
}
pub async fn extract_worker_name(
&self,
_token: &str,
_db: &windmill_common::DB,
) -> Option<String> {
None
}
}
+38
View File
@@ -35,7 +35,45 @@ use windmill_common::{
lazy_static::lazy_static! {
// Global auth cache accessible from main.rs for direct invalidation
pub static ref AUTH_CACHE: Cache<(String, String), ExpiringAuthCache> = Cache::new(300);
// Cache for token -> email lookups (for non-workspace-member authenticated users)
static ref TOKEN_EMAIL_CACHE: Cache<String, Option<String>> = Cache::new(500);
}
/// Get email from a valid token, with caching.
/// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member.
async fn get_email_from_token(db: &DB, token: &str) -> Option<String> {
if let Some(cached) = TOKEN_EMAIL_CACHE.get(token) {
return cached;
}
let email = sqlx::query_scalar!(
"SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)",
token
)
.fetch_optional(db)
.await
.ok()
.flatten()
.flatten(); // email column is nullable, so we get Option<Option<String>>
TOKEN_EMAIL_CACHE.insert(token.to_string(), email.clone());
email
}
/// Get end user email from authenticated user or token.
/// Returns email if user is authenticated (workspace member) or has valid instance token.
pub async fn get_end_user_email(
db: &DB,
opt_authed: Option<&ApiAuthed>,
token: Option<&str>,
) -> Option<String> {
if let Some(authed) = opt_authed {
return Some(authed.email.clone());
}
if let Some(token) = token {
return get_email_from_token(db, token).await;
}
None
}
// Global function to invalidate a specific token from cache
pub fn invalidate_token_from_cache(token: &str) {
+35 -2
View File
@@ -29,8 +29,8 @@ use scopes::ScopeDefinition;
// Re-export key auth types and functions
pub use auth::{
invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened, Tokened,
TruncatedTokenWithEmail, AUTH_CACHE,
get_end_user_email, invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened,
Tokened, TruncatedTokenWithEmail, AUTH_CACHE,
};
// ------------ ApiAuthed & OptJobAuthed types ------------
@@ -557,6 +557,14 @@ pub async fn create_token_internal(
));
}
register_token_expiry_notification(
&mut *tx,
&token,
token_config.label.as_deref(),
token_config.expiration,
)
.await;
audit_log(
&mut *tx,
authed,
@@ -572,6 +580,31 @@ pub async fn create_token_internal(
Ok(token)
}
/// Insert a pending expiry notification row for user tokens that have an expiration.
pub async fn register_token_expiry_notification(
tx: &mut sqlx::PgConnection,
token: &str,
label: Option<&str>,
expiration: Option<chrono::DateTime<chrono::Utc>>,
) {
let Some(expiration) = expiration else { return };
if label == Some("session")
|| label.is_some_and(|l| l.starts_with("ephemeral") || l.starts_with("Ephemeral"))
{
return;
}
if let Err(e) = sqlx::query!(
"INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
token,
expiration,
)
.execute(&mut *tx)
.await
{
tracing::error!("Failed to register token expiry notification: {}", e);
}
}
// ------------ Permission helpers ------------
pub fn get_perm_in_extra_perms_for_authed(
@@ -24,7 +24,7 @@ use windmill_common::{
utils::{not_found_if_none, StripPath},
};
const KINDS: [&str; 18] = [
const KINDS: [&str; 19] = [
"script",
"group_",
"resource",
@@ -43,6 +43,7 @@ const KINDS: [&str; 18] = [
"gcp_trigger",
"sqs_trigger",
"email_trigger",
"volume",
];
pub fn workspaced_service() -> Router {
@@ -77,7 +78,7 @@ async fn add_granular_acl(
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" || kind == "folder" {
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
"name"
} else {
"path"
@@ -89,6 +90,22 @@ async fn add_granular_acl(
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else if kind == "volume" {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?;
// created_by is stored with u/ prefix (from job.permissioned_as)
let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by);
if owner_username != authed.username {
return Err(Error::NotAuthorized(
"Only the volume owner or an admin can modify permissions".to_string(),
));
}
} else {
require_owner_of_path(&authed, path)?;
}
@@ -243,6 +260,22 @@ async fn remove_granular_acl(
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else if kind == "volume" {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?;
// created_by is stored with u/ prefix (from job.permissioned_as)
let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by);
if owner_username != authed.username {
return Err(Error::NotAuthorized(
"Only the volume owner or an admin can modify permissions".to_string(),
));
}
} else {
require_owner_of_path(&authed, path)?;
}
@@ -250,7 +283,7 @@ async fn remove_granular_acl(
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" || kind == "folder" {
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
"name"
} else {
"path"
@@ -380,7 +413,11 @@ async fn get_granular_acls(
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" { "name" } else { "path" };
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
"name"
} else {
"path"
};
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2"
))
+4 -3
View File
@@ -43,8 +43,8 @@ use windmill_common::{
get_database_url,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING,
ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -519,6 +519,7 @@ pub async fn get_global_setting(
&& key != DEFAULT_TAGS_WORKSPACES_SETTING
&& key != HUB_BASE_URL_SETTING
&& key != HUB_ACCESSIBLE_URL_SETTING
&& key != DISABLE_HUB_SETTING
&& key != EMAIL_DOMAIN_SETTING
&& key != APP_WORKSPACED_ROUTE_SETTING
{
@@ -1085,7 +1086,7 @@ async fn sync_cached_resource_types(
require_super_admin(&db, &authed.email).await?;
use windmill_common::worker::HUB_RT_CACHE_DIR;
let cache_path = format!("{}/resource_types.json", HUB_RT_CACHE_DIR);
let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR);
let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| {
error::Error::NotFound(format!(
+8
View File
@@ -1850,6 +1850,14 @@ async fn impersonate(
.execute(&mut *tx)
.await?;
windmill_api_auth::register_token_expiry_notification(
&mut *tx,
&token,
new_token.label.as_deref(),
new_token.expiration,
)
.await;
audit_log(
&mut *tx,
&authed,
@@ -29,6 +29,7 @@ windmill-dep-map.workspace = true
axum.workspace = true
chrono.workspace = true
hex.workspace = true
magic-crypt.workspace = true
http.workspace = true
hyper.workspace = true
lazy_static.workspace = true
@@ -31,7 +31,9 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::db::UserDB;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE};
use windmill_common::variables::{
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::GitRepositorySettings;
@@ -300,6 +302,8 @@ struct LargeFileStorageWithSecondary {
large_file_storage: LargeFileStorage,
#[serde(default)]
secondary_storage: HashMap<String, LargeFileStorage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
volume_storage: Option<String>,
}
#[derive(Deserialize, Debug)]
struct EditLargeFileStorageConfig {
@@ -2418,20 +2422,28 @@ async fn set_encryption_key(
));
}
// Build the previous cipher before the transaction (reads from cache/pool)
let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_key SET key = $1 WHERE workspace_id = $2",
request.new_key.clone(),
w_id
)
.execute(&db)
.execute(&mut *tx)
.await?;
WORKSPACE_CRYPT_CACHE.remove(w_id.as_str());
if !request.skip_reencrypt.unwrap_or(false) {
let new_encryption_key = build_crypt(&db, w_id.as_str()).await?;
// Build the new cipher directly from the key string, since the transaction
// hasn't committed yet and build_crypt() would read the old key from the pool.
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", request.new_key, salt)
} else {
request.new_key.clone()
};
let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256);
let mut truncated_new_key = request.new_key.clone();
truncated_new_key.truncate(8);
@@ -2445,7 +2457,7 @@ async fn set_encryption_key(
"SELECT path, value, is_secret FROM variable WHERE workspace_id = $1",
w_id
)
.fetch_all(&db)
.fetch_all(&mut *tx)
.await?;
for variable in all_variables {
@@ -2466,11 +2478,16 @@ async fn set_encryption_key(
w_id,
variable.path
)
.execute(&db)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
// Invalidate the cache only after the transaction has committed
WORKSPACE_CRYPT_CACHE.remove(w_id.as_str());
// Trigger git sync for encryption key changes
handle_deployment_metadata(
&authed.email,
+2
View File
@@ -70,6 +70,7 @@ windmill-git-sync.workspace = true
windmill-indexer = { workspace = true, optional = true }
windmill-autoscaling = { workspace = true, optional = true }
windmill-worker = { workspace = true, optional = true }
windmill-worker-volumes.workspace = true
windmill-dep-map.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
@@ -173,6 +174,7 @@ aws-sdk-bedrock = { workspace = true, optional = true }
aws-sdk-bedrockruntime = { workspace = true, optional = true }
aws-smithy-types = { workspace = true, optional = true }
async-trait.workspace = true
eventsource-stream.workspace = true
windmill-jseval.workspace = true
tar.workspace = true
flate2.workspace = true
+4 -6
View File
@@ -8857,9 +8857,8 @@ paths:
type: boolean
flow_env:
type: object
description: Environment variables available to all steps
additionalProperties:
type: string
description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)."
additionalProperties: {}
priority:
type: number
description: Execution priority (higher numbers run first)
@@ -14644,9 +14643,8 @@ paths:
type: boolean
flow_env:
type: object
description: Environment variables available to all steps
additionalProperties:
type: string
description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)."
additionalProperties: {}
priority:
type: number
description: Execution priority (higher numbers run first)
+214 -5
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.648.0
version: 1.651.1
title: Windmill API
contact:
@@ -15198,6 +15198,7 @@ paths:
gcp_trigger,
sqs_trigger,
email_trigger,
volume,
]
responses:
"200":
@@ -15243,6 +15244,7 @@ paths:
gcp_trigger,
sqs_trigger,
email_trigger,
volume,
]
requestBody:
description: acl to add
@@ -15299,6 +15301,7 @@ paths:
gcp_trigger,
sqs_trigger,
email_trigger,
volume,
]
requestBody:
description: acl to add
@@ -16973,9 +16976,9 @@ paths:
description: count of log lines that matched the query per hostname
type: object
/srch/index/delete/{idx_name}:
/indexer/delete/{idx_name}:
delete:
summary: Restart container and delete the index to recreate it.
summary: Clear an index and restart the indexer.
operationId: clearIndex
tags:
- indexSearch
@@ -16990,12 +16993,102 @@ paths:
- ServiceLogIndex
responses:
"200":
description: idx to be deleted and container restarting
description: idx to be deleted and indexer restarting
content:
text/plain:
schema:
type: string
/indexer/storage:
get:
summary: Get index storage sizes (disk and S3).
operationId: getIndexStorageSizes
tags:
- indexSearch
responses:
"200":
description: storage sizes for each index
content:
application/json:
schema:
type: object
properties:
job_index:
type: object
properties:
disk_size_bytes:
type: integer
nullable: true
s3_size_bytes:
type: integer
nullable: true
service_log_index:
type: object
properties:
disk_size_bytes:
type: integer
nullable: true
s3_size_bytes:
type: integer
nullable: true
/indexer/status:
get:
summary: Get indexer status including liveness and storage sizes.
operationId: getIndexerStatus
tags:
- indexSearch
responses:
"200":
description: indexer status for each index
content:
application/json:
schema:
type: object
properties:
job_indexer:
type: object
properties:
is_alive:
type: boolean
last_locked_at:
type: string
format: date-time
nullable: true
owner:
type: string
nullable: true
storage:
type: object
properties:
disk_size_bytes:
type: integer
nullable: true
s3_size_bytes:
type: integer
nullable: true
log_indexer:
type: object
properties:
is_alive:
type: boolean
last_locked_at:
type: string
format: date-time
nullable: true
owner:
type: string
nullable: true
storage:
type: object
properties:
disk_size_bytes:
type: integer
nullable: true
s3_size_bytes:
type: integer
nullable: true
/w/{workspace}/assets/list:
get:
summary: List all assets in the workspace with cursor pagination
@@ -17192,7 +17285,90 @@ paths:
path:
type: string
description: The asset path
/w/{workspace}/volumes/list:
get:
summary: List all volumes in the workspace
operationId: listVolumes
tags:
- volume
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of volumes
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Volume"
/w/{workspace}/volumes/storage:
get:
summary: Get the volume storage name (secondary storage) or null for primary
operationId: getVolumeStorage
tags:
- volume
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: volume storage name or null
content:
application/json:
schema:
type: string
nullable: true
/w/{workspace}/volumes/create:
post:
summary: Create a new volume
operationId: createVolume
tags:
- volume
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
type: string
responses:
"200":
description: volume created
content:
text/plain:
schema:
type: string
/w/{workspace}/volumes/delete/{name}:
delete:
summary: Delete a volume (admin only)
operationId: deleteVolume
tags:
- volume
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: volume deleted
content:
text/plain:
schema:
type: string
/mcp/w/{workspace}/list_tools:
get:
@@ -23907,6 +24083,7 @@ components:
- resource
- ducklake
- datatable
- volume
Asset:
type: object
properties:
@@ -23915,6 +24092,38 @@ components:
kind:
$ref: "#/components/schemas/AssetKind"
required: [path, kind]
Volume:
type: object
required:
- name
- size_bytes
- file_count
- created_at
- created_by
properties:
name:
type: string
size_bytes:
type: integer
format: int64
file_count:
type: integer
created_at:
type: string
format: date-time
created_by:
type: string
updated_at:
type: string
format: date-time
nullable: true
last_used_at:
type: string
format: date-time
nullable: true
extra_perms:
type: object
additionalProperties: true
ProtectionRuleset:
type: object
description: A workspace protection rule defining restrictions and bypass permissions
+37 -9
View File
@@ -29,7 +29,7 @@ const AI_TIMEOUT_MAX_SECS: u64 = 86400; // 24 hours
const AI_TIMEOUT_DEFAULT_SECS: u64 = 3600; // 1 hour
const HTTP_POOL_MAX_IDLE_PER_HOST: usize = 10;
const HTTP_POOL_IDLE_TIMEOUT_SECS: u64 = 90;
const KEEPALIVE_INTERVAL_SECS: u64 = 15;
pub(crate) const KEEPALIVE_INTERVAL_SECS: u64 = 15;
lazy_static::lazy_static! {
/// AI request timeout in seconds.
@@ -87,7 +87,7 @@ lazy_static::lazy_static! {
}
};
static ref HTTP_CLIENT: Client = configure_client(reqwest::ClientBuilder::new()
pub(crate) static ref HTTP_CLIENT: Client = configure_client(reqwest::ClientBuilder::new()
.timeout(std::time::Duration::from_secs(*AI_TIMEOUT_SECS))
.pool_max_idle_per_host(HTTP_POOL_MAX_IDLE_PER_HOST)
.pool_idle_timeout(Some(std::time::Duration::from_secs(HTTP_POOL_IDLE_TIMEOUT_SECS)))
@@ -378,12 +378,7 @@ impl AIRequestConfig {
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
let is_google_ai = matches!(provider, AIProvider::GoogleAI);
// GoogleAI uses OpenAI-compatible endpoint in the proxy (for the chat), but not for the ai agent
let base_url = if is_google_ai {
format!("{}/openai", base_url)
} else {
base_url.to_string()
};
let base_url = base_url.to_string();
let base_url = base_url.as_str();
// Build URL based on provider
@@ -428,6 +423,9 @@ impl AIRequestConfig {
if let Some(api_key) = self.api_key {
if is_azure {
request = request.header("api-key", api_key.clone())
} else if is_google_ai {
// Native Gemini API uses x-goog-api-key, not Authorization: Bearer
request = request.header("x-goog-api-key", api_key.clone())
} else {
request = request.header("authorization", format!("Bearer {}", api_key.clone()))
}
@@ -611,7 +609,7 @@ fn is_sse_response(headers: &HeaderMap) -> bool {
.unwrap_or(false)
}
fn inject_keepalives<S>(
pub(crate) fn inject_keepalives<S>(
upstream: S,
interval: Duration,
) -> impl futures::Stream<Item = std::result::Result<Bytes, reqwest::Error>>
@@ -830,6 +828,36 @@ async fn proxy(
ai_path = chat_path;
}
// Handle GoogleAI (Gemini) using the native Gemini API
if matches!(provider, AIProvider::GoogleAI) {
let api_key = request_config.api_key.as_deref().unwrap_or("");
let base_url = request_config.base_url.trim_end_matches('/');
let mut tx = db.begin().await?;
audit_log(
&mut *tx,
&authed,
"ai.request",
ActionKind::Execute,
&w_id,
Some(&authed.email),
Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()),
)
.await?;
tx.commit().await?;
return match ai_path.as_str() {
"chat/completions" => {
crate::google::handle_google_ai_chat(&body, api_key, base_url).await
}
"models" => crate::google::handle_google_ai_models(api_key, base_url).await,
_ => Err(Error::BadRequest(format!(
"Unsupported Google AI path: {}",
ai_path
))),
};
}
// Handle Bedrock-specific logic when the feature is enabled
#[cfg(feature = "bedrock")]
{
+15 -5
View File
@@ -8,7 +8,7 @@ use std::{collections::HashMap, sync::Arc};
* LICENSE-AGPL for a copy of the license.
*/
use crate::{
auth::OptTokened,
auth::{get_end_user_email, OptTokened},
db::{ApiAuthed, DB},
jobs::RunJobQuery,
users::{require_owner_of_path, OptAuthed},
@@ -993,9 +993,18 @@ macro_rules! process_app_multipart {
let mut uploaded_js = false;
let mut multipart = $multipart;
while let Some(field) = multipart.next_field().await.unwrap() {
let name = field.name().unwrap().to_string();
let data = field.bytes().await.unwrap();
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| Error::BadRequest(format!("failed to read multipart field: {e}")))?
{
let name = field
.name()
.ok_or_else(|| Error::BadRequest("multipart field missing name".to_string()))?
.to_string();
let data = field.bytes().await.map_err(|e| {
Error::BadRequest(format!("failed to read multipart stream: {e}"))
})?;
if name == "app" {
let app = serde_json::from_slice(&data).map_err(to_anyhow)?;
let (ntx, npath, nid) = $internal_fn(
@@ -2149,7 +2158,8 @@ async fn execute_component(
(email.as_str(), permissioned_as)
};
let end_user_email = opt_authed.as_ref().map(|a| a.email.clone());
let end_user_email =
get_end_user_email(&db, opt_authed.as_ref(), tokened.token.as_deref()).await;
let (uuid, mut tx) = push(
&db,
+3 -2
View File
@@ -1,4 +1,5 @@
pub use windmill_api_auth::auth::{
invalidate_token_from_cache, list_tokens_internal, transform_old_scope_to_new_scope, AuthCache,
ExpiringAuthCache, OptTokened, Tokened, TruncatedTokenWithEmail,
get_end_user_email, invalidate_token_from_cache, list_tokens_internal,
transform_old_scope_to_new_scope, AuthCache, ExpiringAuthCache, OptTokened, Tokened,
TruncatedTokenWithEmail,
};
+3
View File
@@ -284,6 +284,9 @@ pub async fn migrate(
20260207000004,
];
for m in migrator.migrations.iter() {
if m.migration_type.is_down_migration() {
continue;
}
if potentially_stale.contains(&m.version) {
if let Err(err) =
sqlx::query("DELETE FROM _sqlx_migrations WHERE version = $1 AND checksum != $2")
+306
View File
@@ -0,0 +1,306 @@
//! Google AI (Gemini API) handler for the AI chat proxy.
//!
//! Handles POST `chat/completions` requests using the native Gemini API,
//! converting from/to OpenAI format so the existing frontend parsers continue to work.
//!
//! Used by `windmill-api/src/ai.rs` when the provider is `GoogleAI`.
//! Shared conversion logic lives in `windmill_common::ai_google`.
use axum::body::Body;
use bytes::Bytes;
use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde::Deserialize;
use serde_json::json;
use windmill_common::{
ai_google::{
gemini_event_to_openai_sse_chunks, gemini_response_to_openai, openai_messages_to_gemini,
parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google,
GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiTextRequest, GeminiTool,
},
ai_types::OpenAIMessage,
error::{Error, Result},
};
use crate::ai::{inject_keepalives, HTTP_CLIENT, KEEPALIVE_INTERVAL_SECS};
// ============================================================================
// Request type (OpenAI format received from the frontend)
// ============================================================================
#[derive(Deserialize, Debug)]
struct ChatRequest {
model: String,
messages: Vec<OpenAIMessage>,
#[serde(default)]
stream: bool,
#[serde(default)]
temperature: Option<f32>,
#[serde(default)]
max_tokens: Option<u32>,
#[serde(default)]
tools: Option<Vec<ChatRequestTool>>,
}
#[derive(Deserialize, Debug)]
struct ChatRequestTool {
function: ChatRequestToolFunction,
}
#[derive(Deserialize, Debug)]
struct ChatRequestToolFunction {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
parameters: Option<serde_json::Value>,
}
// ============================================================================
// Public handler
// ============================================================================
/// Handle a `chat/completions` POST request using the native Gemini API.
///
/// Converts the incoming OpenAI-format body to a `GeminiTextRequest`, sends it
/// to the appropriate Gemini endpoint, and converts the response back to the
/// OpenAI SSE or JSON format that the frontend expects.
pub async fn handle_google_ai_chat(
body: &Bytes,
api_key: &str,
base_url: &str,
) -> Result<(http::StatusCode, http::HeaderMap, Body)> {
let request: ChatRequest = serde_json::from_slice(body)
.map_err(|e| Error::BadRequest(format!("Failed to parse request body: {}", e)))?;
let (contents, system_instruction) = openai_messages_to_gemini(&request.messages);
let generation_config =
if request.temperature.is_some() || request.max_tokens.is_some() {
Some(GeminiGenerationConfig {
temperature: request.temperature,
max_output_tokens: request.max_tokens,
response_mime_type: None,
response_schema: None,
})
} else {
None
};
let gemini_tools = request.tools.as_ref().map(|tools| {
let declarations: Vec<GeminiFunctionDeclaration> = tools
.iter()
.map(|t| {
let mut params = t.function.parameters.clone().unwrap_or(json!({}));
sanitize_schema_for_google(&mut params);
GeminiFunctionDeclaration {
name: t.function.name.clone(),
description: t.function.description.clone(),
parameters: params,
}
})
.collect();
vec![GeminiTool {
function_declarations: Some(declarations),
google_search: None,
}]
});
let gemini_request = GeminiTextRequest {
contents,
tools: gemini_tools,
tool_config: None,
system_instruction,
generation_config,
};
let request_body = serde_json::to_string(&gemini_request)
.map_err(|e| Error::internal_err(format!("Failed to serialize Gemini request: {}", e)))?;
let base_url = base_url.trim_end_matches('/');
if request.stream {
handle_streaming(&request.model, request_body, api_key, base_url).await
} else {
handle_non_streaming(&request.model, request_body, api_key, base_url).await
}
}
// ============================================================================
// Streaming path
// ============================================================================
async fn handle_streaming(
model: &str,
request_body: String,
api_key: &str,
base_url: &str,
) -> Result<(http::StatusCode, http::HeaderMap, Body)> {
let endpoint = format!("{}/models/{}:streamGenerateContent?alt=sse", base_url, model);
let response = HTTP_CLIENT
.post(&endpoint)
.header("content-type", "application/json")
.header("x-goog-api-key", api_key)
.body(request_body)
.send()
.await
.map_err(|e| {
Error::internal_err(format!("Failed to send request to Gemini API: {}", e))
})?;
if let Err(e) = response.error_for_status_ref() {
let status = e.status().map(|s| s.to_string()).unwrap_or_default();
let body = response.text().await.unwrap_or_default();
return Err(Error::AIError(format!("{}: {}", status, body)));
}
let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple());
let model_str = model.to_string();
let gemini_sse_stream = response.bytes_stream().eventsource();
let openai_sse_stream = async_stream::stream! {
tokio::pin!(gemini_sse_stream);
let mut tool_call_index: usize = 0;
while let Some(event) = gemini_sse_stream.next().await {
match event {
Ok(event) => match parse_gemini_sse_event(&event.data) {
Ok(Some(parsed)) => {
for chunk in gemini_event_to_openai_sse_chunks(
&parsed, &id, &model_str, &mut tool_call_index,
) {
yield Ok::<Bytes, reqwest::Error>(Bytes::from(chunk));
}
}
Ok(None) => {}
Err(e) => tracing::error!("Error parsing Gemini SSE event: {}", e),
},
Err(e) => tracing::error!("Error reading Gemini SSE stream: {}", e),
}
}
yield Ok::<Bytes, reqwest::Error>(Bytes::from("data: [DONE]\n\n"));
};
let mut headers = http::HeaderMap::new();
headers.insert("content-type", "text/event-stream".parse().unwrap());
headers.insert("cache-control", "no-cache".parse().unwrap());
headers.insert("connection", "keep-alive".parse().unwrap());
Ok((
http::StatusCode::OK,
headers,
Body::from_stream(inject_keepalives(
Box::pin(openai_sse_stream),
std::time::Duration::from_secs(KEEPALIVE_INTERVAL_SECS),
)),
))
}
// ============================================================================
// Model listing
// ============================================================================
/// List available Gemini models and convert to OpenAI format.
///
/// Gemini returns `{ models: [{ name: "models/gemini-2.5-flash", displayName, ... }] }`.
/// The frontend expects OpenAI format `{ data: [{ id: "models/gemini-2.5-flash", ... }] }`.
pub async fn handle_google_ai_models(
api_key: &str,
base_url: &str,
) -> Result<(http::StatusCode, http::HeaderMap, Body)> {
#[derive(Deserialize)]
struct GeminiModel {
name: String,
#[serde(rename = "displayName", default)]
display_name: String,
}
#[derive(Deserialize)]
struct GeminiModelsResponse {
#[serde(default)]
models: Vec<GeminiModel>,
}
let endpoint = format!("{}/models", base_url.trim_end_matches('/'));
let response = HTTP_CLIENT
.get(&endpoint)
.header("x-goog-api-key", api_key)
.send()
.await
.map_err(|e| Error::internal_err(format!("Failed to fetch Gemini models: {}", e)))?;
if let Err(e) = response.error_for_status_ref() {
let status = e.status().map(|s| s.to_string()).unwrap_or_default();
let body = response.text().await.unwrap_or_default();
return Err(Error::AIError(format!("{}: {}", status, body)));
}
let gemini_resp: GeminiModelsResponse = response.json().await.map_err(|e| {
Error::internal_err(format!("Failed to parse Gemini models response: {}", e))
})?;
let data: Vec<serde_json::Value> = gemini_resp
.models
.into_iter()
.map(|m| {
json!({
"id": m.name,
"object": "model",
"display_name": m.display_name,
})
})
.collect();
let body_bytes = serde_json::to_vec(&json!({ "data": data }))
.map_err(|e| Error::internal_err(format!("Failed to serialize models: {}", e)))?;
let mut headers = http::HeaderMap::new();
headers.insert("content-type", "application/json".parse().unwrap());
Ok((http::StatusCode::OK, headers, Body::from(body_bytes)))
}
// ============================================================================
// Non-streaming path
// ============================================================================
async fn handle_non_streaming(
model: &str,
request_body: String,
api_key: &str,
base_url: &str,
) -> Result<(http::StatusCode, http::HeaderMap, Body)> {
let endpoint = format!("{}/models/{}:generateContent", base_url, model);
let response = HTTP_CLIENT
.post(&endpoint)
.header("content-type", "application/json")
.header("x-goog-api-key", api_key)
.body(request_body)
.send()
.await
.map_err(|e| {
Error::internal_err(format!("Failed to send request to Gemini API: {}", e))
})?;
if let Err(e) = response.error_for_status_ref() {
let status = e.status().map(|s| s.to_string()).unwrap_or_default();
let body = response.text().await.unwrap_or_default();
return Err(Error::AIError(format!("{}: {}", status, body)));
}
let body = response.bytes().await.map_err(|e| {
Error::internal_err(format!("Failed to read Gemini response body: {}", e))
})?;
let parsed = parse_gemini_response(&body)?;
let openai_response = gemini_response_to_openai(&parsed, model);
let body_bytes = serde_json::to_vec(&openai_response)
.map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?;
let mut headers = http::HeaderMap::new();
headers.insert("content-type", "application/json".parse().unwrap());
Ok((http::StatusCode::OK, headers, Body::from(body_bytes)))
}
+6 -32
View File
@@ -240,11 +240,7 @@ async fn check_database_detailed(db: &DB) -> DatabaseHealth {
let check = check_database_with_latency(db).await;
let pool = get_pool_stats(db);
DatabaseHealth {
healthy: check.healthy,
latency_ms: check.latency_ms,
pool,
}
DatabaseHealth { healthy: check.healthy, latency_ms: check.latency_ms, pool }
}
async fn check_worker_count(db: &DB) -> i64 {
@@ -295,13 +291,7 @@ async fn check_workers_detailed(db: &DB) -> WorkersHealth {
let healthy = active_count > 0;
WorkersHealth {
healthy,
active_count,
worker_groups,
min_version,
versions,
}
WorkersHealth { healthy, active_count, worker_groups, min_version, versions }
}
async fn check_queue(db: &DB) -> QueueHealth {
@@ -333,10 +323,7 @@ fn get_version() -> String {
/// Spawn a background task that performs a health check every 10 seconds.
/// Updates the cache and prometheus metrics continuously.
pub fn start_health_check_loop(
db: DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
pub fn start_health_check_loop(db: DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(10));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -550,10 +537,7 @@ async fn health_status(
}
/// Detailed health check - requires DB authentication (always fresh, no caching)
async fn health_detailed(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> impl IntoResponse {
async fn health_detailed(_authed: ApiAuthed, Extension(db): Extension<DB>) -> impl IntoResponse {
let checked_at = Utc::now();
let database = check_database_detailed(&db).await;
let readiness = check_readiness();
@@ -564,12 +548,7 @@ async fn health_detailed(
status: HealthStatus::Unhealthy,
checked_at,
version: get_version(),
checks: HealthChecks {
database,
workers: None,
queue: None,
readiness,
},
checks: HealthChecks { database, workers: None, queue: None, readiness },
};
return (StatusCode::SERVICE_UNAVAILABLE, Json(response));
}
@@ -587,12 +566,7 @@ async fn health_detailed(
status,
checked_at,
version: get_version(),
checks: HealthChecks {
database,
workers: Some(workers),
queue: Some(queue),
readiness,
},
checks: HealthChecks { database, workers: Some(workers), queue: Some(queue), readiness },
};
let status_code = if status == HealthStatus::Unhealthy {
+5
View File
@@ -14,3 +14,8 @@ pub fn workspaced_service() -> Router {
pub fn global_service() -> Router {
Router::new()
}
#[cfg(not(feature = "private"))]
pub fn management_service() -> Router {
Router::new()
}
+5 -5
View File
@@ -12,15 +12,15 @@ use windmill_types::s3::StorageResourceType;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use crate::db::{ApiAuthed, OptJobAuthed, DB};
#[cfg(all(feature = "parquet", not(feature = "private")))]
use windmill_object_store::object_store_reexports::{ObjectStore, PutMultipartOpts, PutResult};
#[cfg(not(feature = "private"))]
use windmill_object_store::ObjectStoreResource;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use std::sync::Arc;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use windmill_common::db::UserDB;
#[cfg(not(feature = "private"))]
use windmill_common::error;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use windmill_common::db::UserDB;
use windmill_object_store::object_store_reexports::{ObjectStore, PutMultipartOpts, PutResult};
#[cfg(not(feature = "private"))]
use windmill_object_store::ObjectStoreResource;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use bytes::Bytes;
+89 -18
View File
@@ -47,7 +47,7 @@ use windmill_common::runtime_assets::{register_runtime_asset, InsertRuntimeAsset
use windmill_common::scripts::ScriptRunnableSettingsInline;
use windmill_common::triggers::TriggerMetadata;
use windmill_common::utils::{RunnableKind, WarnAfterExt};
use windmill_common::worker::{Connection, CLOUD_HOSTED, TMP_DIR};
use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR};
use windmill_common::workspace_dependencies::{
RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES,
};
@@ -448,14 +448,15 @@ async fn get_flow_env_by_flow_job_id(
Path((w_id, flow_job_id, var_name)): Path<(String, Uuid, String)>,
Query(JsonPath { json_path, .. }): Query<JsonPath>,
) -> windmill_common::error::JsonResult<Box<JsonRawValue>> {
let flow_env = sqlx::query_scalar!(
// Fetch raw value (without json_path) to check for $var:/$res: references
let raw_value = sqlx::query_scalar!(
r#"
SELECT
CASE
WHEN flow_version.id IS NOT NULL THEN
(flow_version.value -> 'flow_env' -> $3) #> $4
flow_version.value -> 'flow_env' -> $3
ELSE
(root_job.raw_flow -> 'flow_env' -> $3) #> $4
root_job.raw_flow -> 'flow_env' -> $3
END AS "flow_env: sqlx::types::Json<Box<RawValue>>"
FROM
v2_job current_job
@@ -472,16 +473,86 @@ async fn get_flow_env_by_flow_job_id(
flow_job_id,
w_id,
var_name,
json_path
.as_ref()
.map(|x| x.split(".").collect::<Vec<_>>())
.unwrap_or_default() as Vec<&str>,
)
.fetch_optional(&db)
.await?
.map(|r| r.map(|x| x.0))
.flatten()
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
.and_then(|r| r.map(|x| x.0));
// Resolve $var:/$res: references if present
let resolved = if let Some(raw) = raw_value {
let raw_str = raw.get();
let db_authed = windmill_common::db::DbWithOptAuthed::<ApiAuthed>::from_authed(
&authed,
db.clone(),
None,
);
if let Some(path) = raw_str
.strip_prefix("\"$var:")
.and_then(|s| s.strip_suffix("\""))
{
match windmill_store::variables::get_value_internal(&db_authed, &w_id, path, false)
.await
{
Ok(val) => to_raw_value(&serde_json::Value::String(val)),
Err(e) => {
tracing::warn!("Failed to resolve flow_env variable $var:{path}: {e}");
raw
}
}
} else if let Some(path) = raw_str
.strip_prefix("\"$res:")
.and_then(|s| s.strip_suffix("\""))
{
match windmill_store::resources::get_resource_value_interpolated_internal(
&db_authed,
&w_id,
path,
Some(flow_job_id),
Some(&tokened.token),
false,
)
.await
{
Ok(Some(val)) => to_raw_value(&val),
Ok(None) => {
tracing::warn!(
"Failed to resolve flow_env resource $res:{path}: resource not found"
);
raw
}
Err(e) => {
tracing::warn!("Failed to resolve flow_env resource $res:{path}: {e}");
raw
}
}
} else {
raw
}
} else {
to_raw_value(&serde_json::Value::Null)
};
// Apply json_path navigation on the (possibly resolved) value
let flow_env = if let Some(ref jp) = json_path {
let mut value: serde_json::Value =
serde_json::from_str(resolved.get()).unwrap_or(serde_json::Value::Null);
for part in jp.split('.') {
value = match value {
serde_json::Value::Object(ref mut map) => {
map.remove(part).unwrap_or(serde_json::Value::Null)
}
serde_json::Value::Array(ref arr) => part
.parse::<usize>()
.ok()
.and_then(|i| arr.get(i).cloned())
.unwrap_or(serde_json::Value::Null),
_ => serde_json::Value::Null,
};
}
to_raw_value(&value)
} else {
resolved
};
log_job_view(
&db,
@@ -1412,7 +1483,7 @@ async fn get_logs_from_disk(
if log_offset > 0 {
if let Some(file_index) = log_file_index.clone() {
for file_p in &file_index {
if !tokio::fs::metadata(format!("{TMP_DIR}/{file_p}"))
if !tokio::fs::metadata(format!("{}/{file_p}", *WINDMILL_DIR))
.await
.is_ok()
{
@@ -1427,7 +1498,7 @@ async fn get_logs_from_disk(
"#.to_string(),
));
for file_p in file_index.clone() {
let mut file = tokio::fs::File::open(format!("{TMP_DIR}/{file_p}")).await.map_err(to_anyhow)?;
let mut file = tokio::fs::File::open(format!("{}/{file_p}", *WINDMILL_DIR)).await.map_err(to_anyhow)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await.map_err(to_anyhow)?;
yield Ok(bytes::Bytes::from(buffer)) as anyhow::Result<bytes::Bytes>;
@@ -5888,7 +5959,7 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R
));
}
let local_file = format!("{TMP_DIR}/logs/{file_p}");
let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR);
if tokio::fs::metadata(&local_file).await.is_ok() {
let mut file = tokio::fs::File::open(local_file).await.map_err(to_anyhow)?;
let mut buffer = Vec::new();
@@ -5934,10 +6005,10 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R
}
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
return Err(error::Error::NotFound(format!(
"File not found on server logs volume /tmp/windmill/logs and no distributed logs s3 storage for {}",
file_p
)));
return Err(error::Error::NotFound(format!(
"File not found on server logs volume {}/logs and no distributed logs s3 storage for {}",
*WINDMILL_DIR, file_p
)));
}
async fn get_job_update(
+91 -9
View File
@@ -58,15 +58,13 @@ use windmill_common::db::UserDB;
use windmill_common::worker::CLOUD_HOSTED;
#[allow(unused_imports)]
pub(crate) use windmill_common::BASE_URL;
use windmill_common::{
utils::GIT_VERSION,
INSTANCE_NAME,
};
use windmill_common::{utils::GIT_VERSION, INSTANCE_NAME};
use crate::scim_oss::has_scim_token;
use windmill_common::error::AppError;
mod ai;
mod google;
mod apps;
pub mod args;
mod audit;
@@ -173,6 +171,9 @@ pub mod users_ee;
mod users_oss;
mod utils;
mod variables;
#[cfg(feature = "private")]
pub mod volumes_ee;
mod volumes_oss;
pub mod webhook_util;
mod workspaces;
#[cfg(feature = "private")]
@@ -251,6 +252,74 @@ type IndexReader = windmill_indexer::completed_runs_oss::IndexReader;
#[cfg(feature = "tantivy")]
type ServiceLogIndexReader = windmill_indexer::service_logs_oss::ServiceLogIndexReader;
/// Worker name derived from the agent JWT token, used to authenticate volume operations.
/// Defined unconditionally so volume endpoint handlers can reference it regardless of
/// whether agent_worker_server is enabled (the extension is only populated on the agent path).
#[derive(Clone)]
pub struct AgentWorkerName(pub String);
/// Middleware that injects a synthetic `ApiAuthed` and JWT-derived worker name
/// into request extensions.
///
/// Used for volume proxy endpoints under the agent_workers path, where the
/// agent JWT auth layer has already validated the request. The volume handlers
/// need `ApiAuthed` to resolve the workspace S3 client, but the agent JWT
/// format is incompatible with the standard auth extractor.
///
/// The worker name is extracted from the JWT claims rather than trusting
/// self-reported values in request bodies/query params.
#[cfg(feature = "agent_worker_server")]
async fn inject_agent_authed(
request: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let mut request = request;
// Extract worker name from agent JWT via AgentCache
// (OSS returns None; EE decodes the JWT and returns the worker name)
{
let extracted = {
let token = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer ").map(|t| t.to_string()));
let cache = request.extensions().get::<Arc<AgentCache>>().cloned();
let db = request.extensions().get::<DB>().cloned();
match (token, cache, db) {
(Some(token), Some(cache), Some(db)) => Some((token, cache, db)),
_ => None,
}
};
if let Some((token, cache, db)) = extracted {
if let Some(worker_name) = cache.extract_worker_name(&token, &db).await {
request
.extensions_mut()
.insert(AgentWorkerName(worker_name));
}
}
}
request
.extensions_mut()
.insert(windmill_api_auth::OptJobAuthed {
authed: ApiAuthed {
email: "agent-worker@windmill.dev".to_string(),
username: "agent-worker".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: None,
},
job_id: None,
});
next.run(request).await
}
pub async fn run_server(
db: DB,
job_index_reader: Option<IndexReader>,
@@ -265,7 +334,7 @@ pub async fn run_server(
) -> anyhow::Result<()> {
let user_db = UserDB::new(db.clone());
for x in [HUB_CACHE_DIR] {
for x in [&*HUB_CACHE_DIR] {
DirBuilder::new()
.recursive(true)
.create(x)
@@ -516,6 +585,7 @@ pub async fn run_server(
users::workspaced_service().layer(Extension(argon2.clone())),
)
.nest("/variables", variables::workspaced_service())
.nest("/volumes", volumes_oss::workspaced_service())
.nest("/workers", windmill_api_workers::workspaced_service())
.nest("/workspaces", workspaces::workspaced_service())
.nest("/oidc", oidc_oss::workspaced_service())
@@ -550,6 +620,7 @@ pub async fn run_server(
.nest("/embeddings", embeddings::global_service())
.nest("/ai", ai::global_service())
.nest("/inkeep", inkeep_oss::global_service())
.nest("/indexer", indexer_oss::management_service())
.nest("/mcp/w/:workspace_id/list_tools", mcp_list_tools_service)
.nest("/health/detailed", health::detailed_service())
.route_layer(from_extractor::<ApiAuthed>())
@@ -612,8 +683,10 @@ pub async fn run_server(
if let Some(agent_workers_job_completed_tx) =
agent_workers_job_completed_tx.clone()
{
windmill_api_agent_workers::global_service(agent_workers_job_completed_tx)
.layer(Extension(agent_cache.clone()))
windmill_api_agent_workers::global_service(
agent_workers_job_completed_tx,
)
.layer(Extension(agent_cache.clone()))
} else {
Router::new()
}
@@ -626,7 +699,13 @@ pub async fn run_server(
.nest("/w/:workspace_id/agent_workers", {
#[cfg(feature = "agent_worker_server")]
{
agent_workers_router.layer(Extension(agent_cache.clone()))
agent_workers_router
.nest(
"/volumes",
volumes_oss::agent_workspaced_service()
.layer(axum::middleware::from_fn(inject_agent_authed)),
)
.layer(Extension(agent_cache.clone()))
}
#[cfg(not(feature = "agent_worker_server"))]
{
@@ -785,7 +864,10 @@ pub async fn run_server(
},
)
// JWKS endpoint for HashiCorp Vault JWT authentication (must be outside /api prefix)
.route("/.well-known/jwks.json", get(windmill_api_settings::get_jwks))
.route(
"/.well-known/jwks.json",
get(windmill_api_settings::get_jwks),
)
.fallback(static_assets::static_handler)
.layer(middleware_stack);
+4 -2
View File
@@ -64,9 +64,10 @@ impl McpBackend for WindmillBackend {
auth: &ApiAuthed,
workspace_id: &str,
favorites_only: bool,
path_prefix: Option<&str>,
) -> BackendResult<Vec<ScriptInfo>> {
let scope_type = if favorites_only { "favorites" } else { "all" };
get_items::<ScriptInfo>(&self.user_db, auth, workspace_id, scope_type, "script")
get_items::<ScriptInfo>(&self.user_db, auth, workspace_id, scope_type, "script", path_prefix)
.await
.map_err(|e| ErrorData::internal_error(e.message, None))
}
@@ -76,9 +77,10 @@ impl McpBackend for WindmillBackend {
auth: &ApiAuthed,
workspace_id: &str,
favorites_only: bool,
path_prefix: Option<&str>,
) -> BackendResult<Vec<FlowInfo>> {
let scope_type = if favorites_only { "favorites" } else { "all" };
get_items::<FlowInfo>(&self.user_db, auth, workspace_id, scope_type, "flow")
get_items::<FlowInfo>(&self.user_db, auth, workspace_id, scope_type, "flow", path_prefix)
.await
.map_err(|e| ErrorData::internal_error(e.message, None))
}
+6
View File
@@ -136,6 +136,7 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
workspace_id: &str,
scope_type: &str,
item_type: &str,
path_prefix: Option<&str>,
) -> Result<Vec<T>, ErrorData> {
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
let fields = vec!["o.path", "o.summary", "o.description", "o.schema"];
@@ -153,6 +154,11 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)");
}
if let Some(prefix) = path_prefix {
let escaped = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
sqlb.and_where("o.path LIKE ? ESCAPE '\\'".bind(&format!("{}%", escaped)));
}
sqlb.order_by(
if item_type == "flow" {
"o.edited_at"

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