Compare commits

..

2 Commits

Author SHA1 Message Date
windmill-internal-app[bot]
9942bb748c chore: update ee-repo-ref to 592848d59ca2304926fb2bd85d000668a7f46a77
This commit updates the EE repository reference after PR #420 was merged in windmill-ee-private.

Previous ee-repo-ref: 931813b75b8260faa13ddc07f36a11607b7e3bf6

New ee-repo-ref: 592848d59ca2304926fb2bd85d000668a7f46a77

Automated by sync-ee-ref workflow.
2026-02-18 08:53:40 +00:00
Ruben Fiszel
430622261f feat(backend): add sandbox SDK for long-lived nsjail sandbox environments
Add interactive, long-lived sandbox management to Windmill with two
deployment modes (embedded on worker, remote on dedicated hosts).

- Database schema: sandbox, sandbox_exec, sandbox_host tables
- windmill-sandbox crate: core nsjail process manager (create, exec via
  nsenter, suspend/resume via SIGSTOP/SIGCONT, file I/O)
- windmill-api-sandbox crate: REST API for sandbox CRUD and operations
- Embedded sandbox HTTP server for worker-local sandbox access
- Monitor integration for cleanup of expired/idle/orphaned sandboxes
- Python SDK: Sandbox class with exec, suspend, resume, terminate, file ops

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:41:00 +00:00
432 changed files with 11148 additions and 26163 deletions

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# Resolve _ee.rs symlinks to actual files so Claude can read them
# This script runs before each user prompt is processed
set -e
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
# Find all _ee.rs symlinks and store their targets
find "$PROJECT_DIR" -name "*_ee.rs" -type l 2>/dev/null | while read -r symlink; do
target=$(readlink -f "$symlink" 2>/dev/null) || continue
# Only process if target file exists
if [[ -f "$target" ]]; then
# Store symlink path and target in manifest
echo "$symlink|$target" >> "$MANIFEST_FILE.tmp"
# Replace symlink with actual file content
rm "$symlink"
cp "$target" "$symlink"
fi
done
# Atomically replace manifest
if [[ -f "$MANIFEST_FILE.tmp" ]]; then
mv "$MANIFEST_FILE.tmp" "$MANIFEST_FILE"
fi
exit 0

View File

@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Restore _ee.rs symlinks after Claude finishes processing
# This script runs when Claude stops
# IMPORTANT: Copies any modifications back to the target before restoring symlinks
set -e
PROJECT_DIR="${CLAUDE_PROJECT_DIR:-/home/farhad/windmill}"
MANIFEST_FILE="$PROJECT_DIR/.claude/hooks/.symlink-manifest"
# Check if manifest exists
if [[ ! -f "$MANIFEST_FILE" ]]; then
exit 0
fi
# Read manifest and restore symlinks
while IFS='|' read -r symlink target; do
if [[ -n "$symlink" && -n "$target" ]]; then
# If the file exists (not a symlink) and target exists, copy changes back
if [[ -f "$symlink" && ! -L "$symlink" && -e "$target" ]]; then
# Copy the potentially modified file back to the target
cp "$symlink" "$target"
fi
# Remove the regular file (which was a copy)
rm -f "$symlink" 2>/dev/null || true
# Recreate the symlink
ln -s "$target" "$symlink" 2>/dev/null || true
fi
done < "$MANIFEST_FILE"
# Clean up manifest
rm -f "$MANIFEST_FILE"
exit 0

View File

@@ -1,8 +1,5 @@
{
"permissions": {
"additionalDirectories": [
"../windmill-ee-private"
],
"allow": [
"Bash(ls:*)",
"Bash(grep:*)",
@@ -66,6 +63,39 @@
},
"enableAllProjectMcpServers": true,
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/resolve-symlinks.sh",
"timeout": 30
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
"timeout": 30
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/restore-symlinks.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",

View File

@@ -44,10 +44,6 @@ RUN /usr/local/bin/python3 -m pip install pip-tools
# Bun
COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
# Install windmill CLI
RUN bun install -g windmill-cli \
&& ln -s $(bun pm bin -g)/wmill /usr/bin/wmill
ARG TARGETPLATFORM
# Deno

View File

@@ -19,7 +19,7 @@ defaults:
jobs:
cargo_test:
runs-on: blacksmith-16vcpu-ubuntu-2404
runs-on: ubicloud-standard-16
services:
postgres:
image: postgres
@@ -70,16 +70,6 @@ jobs:
with:
ruby-version: "3.3"
bundler-cache: false
- name: Install windmill CLI from source
run: |
cd $GITHUB_WORKSPACE/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
working-directory: /
- name: Install PowerShell, mold and clang
run: |
sudo apt-get update && sudo apt-get install -y powershell mold clang libcurl4-openssl-dev
@@ -88,20 +78,6 @@ jobs:
with:
cache: false
toolchain: 1.93.0
- name: Cache cargo target directory
uses: useblacksmith/stickydisk@v1
with:
key: cargo-target
path: ./backend/target
- name: Cache cargo registry
uses: useblacksmith/cache@v1
with:
path: |
~/.cargo/registry
~/.cargo/git
key: cargo-registry-${{ hashFiles('backend/Cargo.lock') }}
restore-keys: |
cargo-registry-
- name: Read EE repo commit hash
run: |
echo "ee_repo_ref=$(cat ./ee-repo-ref.txt)" >> "$GITHUB_ENV"
@@ -189,12 +165,6 @@ jobs:
fi
echo "NPM_TOKEN=${NPM_TOKEN}" >> $GITHUB_ENV
{
echo "TEST_NPMRC<<NPMRC_EOF"
echo "@windmill-test:registry=http://localhost:4873/"
echo "//localhost:4873/:_authToken=${NPM_TOKEN}"
echo "NPMRC_EOF"
} >> $GITHUB_ENV
echo "Got NPM token successfully: ${NPM_TOKEN:0:10}..."
# Configure npm globally with the auth token
@@ -229,7 +199,7 @@ jobs:
fi
echo "Verified: Package requires authentication for @windmill-test/private-pkg"
- name: Cache DuckDB FFI module build
uses: useblacksmith/cache@v1
uses: actions/cache@v3
with:
path: ./backend/windmill-duckdb-ffi-internal/target
key: ${{ runner.os }}-duckdb-ffi-${{ hashFiles('./backend/windmill-duckdb-ffi-internal/src/**/*.rs', './backend/windmill-duckdb-ffi-internal/Cargo.toml', './backend/windmill-duckdb-ffi-internal/Cargo.lock') }}
@@ -245,7 +215,6 @@ jobs:
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
CARGO_INCREMENTAL: 1
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1

View File

@@ -9,7 +9,7 @@ permissions: write-all
jobs:
build_ee:
runs-on: ubicloud-standard-4
runs-on: ubicloud
steps:
- uses: actions/checkout@v4
with:

View File

@@ -9,7 +9,7 @@ permissions: write-all
jobs:
build_ee:
runs-on: ubicloud-standard-4
runs-on: ubicloud
steps:
- uses: actions/checkout@v4
with:

View File

@@ -23,16 +23,16 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Generate Windmill client
working-directory: cli
run: ./gen_wm_client.sh
@@ -69,6 +69,11 @@ jobs:
cache: true
cache-workspaces: backend
- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -85,10 +90,6 @@ jobs:
- name: Symlink Node to /usr/bin/node
run: sudo ln -sf $(which node) /usr/bin/node
- name: Install dependencies
working-directory: cli
run: bun install
- name: Generate Windmill clients
working-directory: cli
run: |
@@ -100,10 +101,12 @@ jobs:
env:
DATABASE_URL: postgres://postgres:changeme@localhost:5432
CI_MINIMAL_FEATURES: "true"
run: bun test --timeout 120000 test/
run: |
deno test --no-check --allow-all test/ \
--ignore=test/cargo_backend_example.test.ts
test-windows:
runs-on: blacksmith-16vcpu-windows-2025
runs-on: windows-latest
steps:
- name: Checkout code
@@ -123,6 +126,11 @@ jobs:
cache: true
cache-workspaces: backend
- name: Setup Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Setup Node.js
uses: actions/setup-node@v4
with:
@@ -142,10 +150,6 @@ jobs:
echo "BUN_PATH=$bunPath" >> $env:GITHUB_OUTPUT
echo "NODE_BIN_PATH=$nodePath" >> $env:GITHUB_OUTPUT
- name: Install dependencies
working-directory: cli
run: bun install
- name: Generate Windmill clients
working-directory: cli
shell: bash
@@ -161,12 +165,9 @@ jobs:
CI_MINIMAL_FEATURES: "true"
BUN_PATH: ${{ steps.runtime-paths.outputs.BUN_PATH }}
NODE_BIN_PATH: ${{ steps.runtime-paths.outputs.NODE_BIN_PATH }}
run: bun test --timeout 120000 test/
- name: Keep runner alive for SSH debug
if: failure()
shell: pwsh
run: Start-Sleep -Seconds 3600
run: |
deno test --no-check --allow-all test/ `
--ignore=test/cargo_backend_example.test.ts
# Combined summary job for branch protection
test-summary:

View File

@@ -6,12 +6,6 @@ on:
- opened
- ready_for_review
- closed
issue_comment:
types:
- created
pull_request_review_comment:
types:
- created
jobs:
notify_discord_when_pr_opened:
@@ -39,38 +33,3 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
secrets:
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }}
notify_discord_on_comment:
if: >
github.event_name == 'issue_comment'
&& github.event.issue.pull_request
&& github.event.comment.user.login != 'cloudflare-workers-and-pages[bot]'
&& github.event.comment.user.login != 'ellipsis-dev[bot]'
uses: ./.github/workflows/shareable-discord-notification.yml
with:
PR_STATUS: "comment"
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
COMMENT_URL: ${{ github.event.comment.html_url }}
DISCORD_CHANNEL_ID: "1372204995868491786"
DISCORD_GUILD_ID: "930051556043276338"
secrets:
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }}
notify_discord_on_review_comment:
if: >
github.event_name == 'pull_request_review_comment'
&& github.event.comment.user.login != 'cloudflare-workers-and-pages[bot]'
&& github.event.comment.user.login != 'ellipsis-dev[bot]'
uses: ./.github/workflows/shareable-discord-notification.yml
with:
PR_STATUS: "comment"
PR_NUMBER: ${{ github.event.pull_request.number }}
COMMENT_BODY: ${{ github.event.comment.body }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
COMMENT_URL: ${{ github.event.comment.html_url }}
DISCORD_CHANNEL_ID: "1372204995868491786"
DISCORD_GUILD_ID: "930051556043276338"
secrets:
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }}

View File

@@ -25,9 +25,9 @@ jobs:
with:
node-version: "20.x"
registry-url: "https://registry.npmjs.org"
- uses: oven-sh/setup-bun@v2
- uses: denoland/setup-deno@v2
with:
bun-version: latest
deno-version: v2.x
- run: cd cli && ./build.sh && cd npm && npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -24,22 +24,9 @@ on:
DISCORD_GUILD_ID:
description: "The Discord guild ID"
type: string
COMMENT_BODY:
description: "The comment body"
type: string
default: ""
COMMENT_AUTHOR:
description: "The comment author"
type: string
default: ""
COMMENT_URL:
description: "The comment URL"
type: string
default: ""
secrets:
DISCORD_WEBHOOK_URL:
description: "Discord Webhook URL"
required: false
DISCORD_BOT_TOKEN:
description: "Discord Bot Token"
@@ -130,54 +117,3 @@ jobs:
curl -X PUT \
-H "Authorization: Bot $BOT_TOKEN" \
"https://discord.com/api/v10/channels/$thread_id/messages/$message_id/reactions/%E2%9C%85/@me"
post_comment:
runs-on: ubuntu-latest
if: ${{ inputs.PR_STATUS == 'comment' }}
steps:
- name: Post comment to Discord thread
env:
BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
CHANNEL_ID: ${{ inputs.DISCORD_CHANNEL_ID }}
GUILD_ID: ${{ inputs.DISCORD_GUILD_ID }}
PR_NUMBER: ${{ inputs.PR_NUMBER }}
COMMENT_BODY: ${{ inputs.COMMENT_BODY }}
COMMENT_AUTHOR: ${{ inputs.COMMENT_AUTHOR }}
COMMENT_URL: ${{ inputs.COMMENT_URL }}
run: |
# 1) Find the thread by PR number
threads=$(curl -s -H "Authorization: Bot $BOT_TOKEN" \
"https://discord.com/api/v10/guilds/${GUILD_ID}/threads/active")
thread_id=$(echo "$threads" | jq -r \
--arg cid "$CHANNEL_ID" \
--arg pref "#${PR_NUMBER}:" \
'.threads[] | select(.parent_id == $cid and (.name | startswith($pref))) | .id')
if [ -z "$thread_id" ]; then
echo "Thread not found for PR #${PR_NUMBER}, skipping"
exit 0
fi
# 2) Truncate comment body to fit Discord's 2000 char limit
# Reserve space for the author line + link (~100 chars)
max_body=1800
if [ ${#COMMENT_BODY} -gt $max_body ]; then
# For bot comments, show the tail (conclusions/code tend to be at the end)
if [[ "$COMMENT_AUTHOR" == *"[bot]"* ]] || [[ "$COMMENT_AUTHOR" == *"-bot"* ]]; then
truncated_body="...${COMMENT_BODY: -$max_body}"
else
truncated_body="${COMMENT_BODY:0:$max_body}..."
fi
else
truncated_body="$COMMENT_BODY"
fi
# 3) Post the comment to the thread
message=$(printf '**%s** [commented](%s):\n%s' "$COMMENT_AUTHOR" "$COMMENT_URL" "$truncated_body")
payload=$(jq -n --arg content "$message" '{content: $content, flags: 4, allowed_mentions: {parse: []}}')
curl -s -X POST \
-H "Authorization: Bot $BOT_TOKEN" \
-H "Content-Type: application/json" \
-d "$payload" \
"https://discord.com/api/v10/channels/${thread_id}/messages"

7
.gitignore vendored
View File

@@ -14,16 +14,9 @@ backend/.minio-data
!.aiderignore
rust-client/Cargo.toml
# Worktree-generated port isolation
.env.local
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
.claude/settings.local.json
# Symlinked cache directories (for git worktrees)
backend/target
frontend/node_modules
typescript-client/node_modules
frontend/.svelte-kit
backend/chrome_profiler.json
.fast-check/

View File

@@ -3,12 +3,10 @@
"svelte": {
"type": "http",
"url": "https://mcp.svelte.dev/mcp"
},
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
},
"playwright": {
"command": "npx",
"args": [
"@playwright/mcp@latest"
]
}
}

View File

@@ -1,75 +0,0 @@
main_branch: main
merge_strategy: rebase
# worktree_dir: .worktrees
worktree_naming: basename
worktree_prefix: ""
# Default: "wm-"
window_prefix: "wm-"
auto_name:
model: "claude-sonnet-4.6"
system_prompt: |
Generate a concise git branch name based on the task description.
Rules:
- Use kebab-case (lowercase with hyphens)
- Keep it short: 1-3 words, max 4 if necessary
- Focus on the core task/feature, not implementation details
- No prefixes like feat/, fix/, chore/
Examples of good branch names:
- "Add dark mode toggle" → dark-mode
- "Fix the search results not showing" → fix-search
- "Refactor the authentication module" → auth-refactor
- "Add CSV export to reports" → export-csv
- "Shell completion is broken" → shell-completion
Output ONLY the branch name, nothing else.
background: true
# Commands to run in new worktree before tmux window opens.
# These block window creation - use for short tasks only.
# Use "<global>" to inherit from global config.
# Set to empty list to disable: `post_create: []`
# post_create:
# - "<global>"
# - mise use
post_create:
- ./scripts/worktree-env
pre_remove:
- ./scripts/worktree-cleanup
panes:
- command: >-
claude --append-system-prompt
"You are running inside a tmux session with other panes running services.\n
Pane layout (current window):\n
- Pane 0: this pane (claude agent)\n
- Pane 1: backend (cargo watch -x run)\n
- Pane 2: frontend (npm run dev)\n\n
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).\n
When restarting backend or frontend, make sure to use the ports listed in .env.local.\n
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check."
focus: true
- 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'
split: horizontal
- command: 'ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm install && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0'
split: vertical
files:
copy:
- backend/.env
- scripts/
sandbox:
enabled: false
toolchain: off
# image, host_commands, and extra_mounts configured in global
# ~/.config/workmux/config.yaml — see README_WORKMUX_DEV.md for required
# extra_mounts (windmill-ee-private access in sandbox)

View File

@@ -1,88 +1,5 @@
# Changelog
## [1.642.0](https://github.com/windmill-labs/windmill/compare/v1.641.0...v1.642.0) (2026-02-22)
### Features
* **cli:** add consistent get/list/new subcommands for all item types ([#8047](https://github.com/windmill-labs/windmill/issues/8047)) ([4fedfdf](https://github.com/windmill-labs/windmill/commit/4fedfdfd11aa8ca7fff6f7aed5ae2b313888f878))
### Bug Fixes
* make WM_FLOW_PATH available in flow step previews ([#8042](https://github.com/windmill-labs/windmill/issues/8042)) ([a91c532](https://github.com/windmill-labs/windmill/commit/a91c532ecadce63cea965c497351fa1a6f39697a))
* preserve debouncing settings for flows with preprocessors ([#8043](https://github.com/windmill-labs/windmill/issues/8043)) ([a00927b](https://github.com/windmill-labs/windmill/commit/a00927b3008a2d953fde1d461723a3c92f375eb4))
## [1.641.0](https://github.com/windmill-labs/windmill/compare/v1.640.0...v1.641.0) (2026-02-21)
### Features
* add .npmrc support for private npm registries ([#8039](https://github.com/windmill-labs/windmill/issues/8039)) ([9eb1531](https://github.com/windmill-labs/windmill/commit/9eb15312f663aa6d700e8ac562d7b5c75c2221f7))
### Bug Fixes
* add created_by ownership check to update/delete saved inputs ([#8038](https://github.com/windmill-labs/windmill/issues/8038)) ([e8a13ed](https://github.com/windmill-labs/windmill/commit/e8a13edde7c0ba2ef80344ab7c7288e7bb2eb6b5))
* run substitute_ee_code.sh after creating EE worktree ([b330f38](https://github.com/windmill-labs/windmill/commit/b330f388894ecd9cc6b64297420ac6f032d32f72))
* tag bunnative dependency jobs as bun instead of nativets ([#8045](https://github.com/windmill-labs/windmill/issues/8045)) ([fd5ebc2](https://github.com/windmill-labs/windmill/commit/fd5ebc2fda589c022074c3bb4dcdb447c7f86cf0))
## [1.640.0](https://github.com/windmill-labs/windmill/compare/v1.639.0...v1.640.0) (2026-02-20)
### Features
* add windmill-ee-private worktree support to workmux ([#8034](https://github.com/windmill-labs/windmill/issues/8034)) ([9f3dd0b](https://github.com/windmill-labs/windmill/commit/9f3dd0bf2b2ba7c622093c54b7b6b5e7ebb26b74))
* **cli:** add --locks-required flag to wmill lint and sync push ([#8026](https://github.com/windmill-labs/windmill/issues/8026)) ([4abe589](https://github.com/windmill-labs/windmill/commit/4abe58939787f375ccfef5b2dbcfbd7e86cff076))
* dedicated nativets ([#8021](https://github.com/windmill-labs/windmill/issues/8021)) ([37c9acb](https://github.com/windmill-labs/windmill/commit/37c9acb232c64c98ecfb64754f5b69b31047c625))
* Support column detection on S3 objects in DuckDB ([#8018](https://github.com/windmill-labs/windmill/issues/8018)) ([87f3de9](https://github.com/windmill-labs/windmill/commit/87f3de9ae5975c88b6748e297f84a539aec4c0ca))
### Bug Fixes
* Fix DuckDB incorrect pg password encoding ([#8028](https://github.com/windmill-labs/windmill/issues/8028)) ([90b1a7a](https://github.com/windmill-labs/windmill/commit/90b1a7a531bce5621ea4de4792a8c9d3d3beec3d))
* **frontend:** use completed_at instead of created_at for job history ([#8022](https://github.com/windmill-labs/windmill/issues/8022)) ([24d7921](https://github.com/windmill-labs/windmill/commit/24d7921bcf23543759719ffd2463959c627b61b8))
### Performance Improvements
* lazy-load JSZip in RawAppEditorHeader ([#8012](https://github.com/windmill-labs/windmill/issues/8012)) ([a1ba10a](https://github.com/windmill-labs/windmill/commit/a1ba10a29e12ab5f553bd9aad74067cc5b3ead9e))
## [1.639.0](https://github.com/windmill-labs/windmill/compare/v1.638.4...v1.639.0) (2026-02-18)
### Features
* improve FolderPicker with edit icon pattern ([#7995](https://github.com/windmill-labs/windmill/issues/7995)) ([db8aa8a](https://github.com/windmill-labs/windmill/commit/db8aa8a0839b5729f0bb847e7a71766c7883ff36))
### Bug Fixes
* default automate_username_creation to true when setting is missing ([#8006](https://github.com/windmill-labs/windmill/issues/8006)) ([d2d08f8](https://github.com/windmill-labs/windmill/commit/d2d08f8817e6e7818eb4b6f092e66ae039f0c756))
* handle raw app folder deletion in sync push without yaml parse error ([#7994](https://github.com/windmill-labs/windmill/issues/7994)) ([f6d99dd](https://github.com/windmill-labs/windmill/commit/f6d99dd18c06a7f5aea93122276dd68c45772b43))
### Performance Improvements
* **cli:** skip relock more accurate ([#7993](https://github.com/windmill-labs/windmill/issues/7993)) ([cd4151a](https://github.com/windmill-labs/windmill/commit/cd4151a84b2c1e0f2e616079091d0429bf469f4e))
## [1.638.4](https://github.com/windmill-labs/windmill/compare/v1.638.3...v1.638.4) (2026-02-17)
### Bug Fixes
* **frontend:** add folder picker validation, error handling, and loading state ([#7987](https://github.com/windmill-labs/windmill/issues/7987)) ([4ea1692](https://github.com/windmill-labs/windmill/commit/4ea1692ee27adbba583d8ead753fa8a19099183f))
* **frontend:** improve folder picker with sticky create button and drawer flow ([#7985](https://github.com/windmill-labs/windmill/issues/7985)) ([a46924a](https://github.com/windmill-labs/windmill/commit/a46924a0f21314826c00fa4ac61885bdf3700421))
## [1.638.3](https://github.com/windmill-labs/windmill/compare/v1.638.2...v1.638.3) (2026-02-17)
### Bug Fixes
* always create guidance files during wmill init ([#7974](https://github.com/windmill-labs/windmill/issues/7974)) ([f387daa](https://github.com/windmill-labs/windmill/commit/f387daa2a6c7eb260981a19c58374062f652fca6))
* **frontend:** incorrect job result on the runs page ([#7982](https://github.com/windmill-labs/windmill/issues/7982)) ([2d53939](https://github.com/windmill-labs/windmill/commit/2d5393941cf17d45d1d4ff840766f07bd482f70b))
* **frontend:** preserve user config when trimming oneOf non-selected keys ([b094649](https://github.com/windmill-labs/windmill/commit/b0946495863e206d12922536d2cae24cb78b55fc))
## [1.638.2](https://github.com/windmill-labs/windmill/compare/v1.638.1...v1.638.2) (2026-02-17)

View File

@@ -258,10 +258,6 @@ COPY --from=denoland/deno:2.2.1 --chmod=755 /usr/bin/deno /usr/bin/deno
COPY --from=oven/bun:1.3.8 /usr/local/bin/bun /usr/bin/bun
# Install windmill CLI
RUN bun install -g windmill-cli \
&& ln -s $(bun pm bin -g)/wmill /usr/bin/wmill
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

View File

@@ -1,234 +0,0 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
iptables \
gosu \
sudo \
unzip \
# Rust native build deps (for cargo check)
pkg-config \
cmake \
clang \
mold \
libtool \
libssl-dev \
libxml2-dev \
libxmlsec1-dev \
libxslt1-dev \
libffi-dev \
zlib1g-dev \
libcurl4-openssl-dev \
libclang-dev \
libkrb5-dev \
libsasl2-dev \
# PostgreSQL (for local DB during development)
postgresql \
postgresql-client \
# Node.js 22 (for npm run check / frontend dev)
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
# Container runs as arbitrary UIDs (--user uid:gid). These three lines make
# sudo work for any UID:
# 1) NOPASSWD rule so sudo never prompts for a password
# 2) Writable passwd/group so the entrypoint can register the dynamic UID
# 3) Writable shadow so unix_chkpwd can validate the account (without this,
# sudo fails with "account validation failure, is your account locked?")
&& echo "ALL ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/sandbox \
&& chmod 0440 /etc/sudoers.d/sandbox \
&& chmod 666 /etc/passwd /etc/group /etc/shadow
# ── GitHub CLI (for PR creation) ──────────────────────────────────────────────
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*
# ── Rust toolchain ────────────────────────────────────────────────────────────
# Install under /usr/local/lib/ so bins are world-readable with default umask.
# CARGO_HOME is overridden to /tmp/.cargo at the end for mutable runtime state.
ENV RUSTUP_HOME=/usr/local/lib/rustup CARGO_HOME=/usr/local/lib/cargo
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --default-toolchain stable --profile minimal && \
ln -s /usr/local/lib/cargo/bin/* /usr/local/bin/
RUN cargo install sqlx-cli --no-default-features --features native-tls,postgres && \
cargo install cargo-watch && \
ln -sf /usr/local/lib/cargo/bin/sqlx /usr/local/bin/sqlx && \
ln -sf /usr/local/lib/cargo/bin/cargo-watch /usr/local/bin/cargo-watch
# ── Register dynamic runtime users ───────────────────────────────────────────
RUN cat <<'SCRIPT' > /usr/local/bin/register-dynamic-user.sh
#!/bin/sh
set -eu
uid="${1:-}"
gid="${2:-}"
if [ -z "$uid" ] || [ -z "$gid" ]; then
echo "register-dynamic-user: usage: register-dynamic-user <uid> <gid>" >&2
exit 1
fi
if ! getent group "$gid" >/dev/null 2>&1; then
echo "sandbox:x:${gid}:" >> /etc/group
fi
if ! getent passwd "$uid" >/dev/null 2>&1; then
echo "sandbox:x:${uid}:${gid}:sandbox:/tmp:/bin/sh" >> /etc/passwd
fi
# Add a shadow entry ("*" = no password) so unix_chkpwd doesn't reject sudo.
if ! grep -q "^sandbox:" /etc/shadow 2>/dev/null; then
echo "sandbox:*:19000:0:99999:7:::" >> /etc/shadow
fi
SCRIPT
RUN chmod +x /usr/local/bin/register-dynamic-user.sh
# ── Network init script (iptables firewall + privilege drop) ──────────────────
RUN cat <<'SCRIPT' > /usr/local/bin/network-init.sh
#!/bin/bash
set -euo pipefail
if [ -n "${WM_PROXY_HOST:-}" ] && [ -n "${WM_PROXY_PORT:-}" ]; then
# Resolve hostnames to ALL IPs (multi-A records, round-robin DNS)
PROXY_IPS=$(getent ahostsv4 "$WM_PROXY_HOST" | awk '{print $1}' | sort -u)
RPC_HOST="${WM_RPC_HOST:-$WM_PROXY_HOST}"
RPC_IPS=$(getent ahostsv4 "$RPC_HOST" | awk '{print $1}' | sort -u)
if [ -z "$PROXY_IPS" ] || [ -z "$RPC_IPS" ]; then
echo "network-init: failed to resolve proxy/RPC host" >&2
exit 1
fi
# IPv4: default deny outbound
iptables -P OUTPUT DROP
iptables -A OUTPUT -o lo -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow DNS (UDP/TCP 53) to configured nameservers.
if [ -f /etc/resolv.conf ]; then
grep '^nameserver' /etc/resolv.conf | awk '{print $2}' | while read -r ns; do
iptables -A OUTPUT -d "$ns" -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -d "$ns" -p tcp --dport 53 -j ACCEPT
done
fi
# Allow ALL resolved proxy IPs (handles multi-A DNS)
for ip in $PROXY_IPS; do
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_PROXY_PORT" -j ACCEPT
done
# Allow ALL resolved RPC IPs
if [ -n "${WM_RPC_PORT:-}" ]; then
for ip in $RPC_IPS; do
iptables -A OUTPUT -d "$ip" -p tcp --dport "$WM_RPC_PORT" -j ACCEPT
done
fi
# Reject (not drop) everything else to fail fast instead of hanging
iptables -A OUTPUT -j REJECT
# IPv6: block entirely to prevent leaks (fail closed)
if ip6tables -L -n >/dev/null 2>&1; then
ip6tables -P OUTPUT DROP
ip6tables -A OUTPUT -o lo -j ACCEPT
ip6tables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
ip6tables -A OUTPUT -j REJECT
else
if ! sysctl -w net.ipv6.conf.all.disable_ipv6=1 2>/dev/null; then
echo "network-init: failed to block IPv6 (neither ip6tables nor sysctl available)" >&2
exit 1
fi
fi
fi
# Add sandbox user/group so sudo works after dropping privileges.
if [ -z "${WM_TARGET_UID:-}" ] || [ -z "${WM_TARGET_GID:-}" ]; then
echo "network-init: WM_TARGET_UID and WM_TARGET_GID are required" >&2
exit 1
fi
/usr/local/bin/register-dynamic-user.sh "${WM_TARGET_UID}" "${WM_TARGET_GID}"
# Fix PTY ownership so the unprivileged user can read/write the terminal.
if [ -t 0 ]; then
chown "${WM_TARGET_UID}:${WM_TARGET_GID}" "$(tty)"
fi
# Drop privileges and exec the user command.
exec gosu "${WM_TARGET_UID}:${WM_TARGET_GID}" env HOME=/tmp "$@"
SCRIPT
RUN chmod +x /usr/local/bin/network-init.sh
# ── workmux (sandbox RPC) ────────────────────────────────────────────────────
RUN curl -fsSL https://raw.githubusercontent.com/raine/workmux/main/scripts/install.sh | bash
# ── Claude Code ───────────────────────────────────────────────────────────────
RUN curl -fsSL https://claude.ai/install.sh | bash && \
target="$(readlink -f /root/.local/bin/claude)" && \
mv /root/.local/share/claude /usr/local/lib/claude && \
ln -s "/usr/local/lib/claude/versions/$(basename "$target")" /usr/local/bin/claude && \
mkdir -p /tmp/.local/bin && \
ln -s /usr/local/bin/claude /tmp/.local/bin/claude
# ── Codex ─────────────────────────────────────────────────────────────────────
RUN npm i -g @openai/codex
# ── Bun ───────────────────────────────────────────────────────────────────────
ENV BUN_INSTALL=/usr/local/lib/bun
RUN curl -fsSL https://bun.sh/install | bash && \
ln -s /usr/local/lib/bun/bin/bun /usr/local/bin/bun && \
ln -s /usr/local/lib/bun/bin/bunx /usr/local/bin/bunx
# ── Playwright + Chromium (for screenshots) ──────────────────────────────────
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/lib/playwright-browsers
RUN bun add -g @playwright/test \
&& bunx playwright install chromium --with-deps \
&& chmod -R a+rwX /usr/local/lib/playwright-browsers \
&& rm -rf /var/lib/apt/lists/* /tmp/bunx-*
# ── AWS CLI (for S3-compatible uploads to R2) ─────────────────────────────────
RUN curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip \
&& unzip -q /tmp/awscliv2.zip -d /tmp \
&& /tmp/aws/install \
&& rm -rf /tmp/aws /tmp/awscliv2.zip
ENV AWS_DEFAULT_REGION=auto
# ── Runtime env for arbitrary UID ─────────────────────────────────────────────
# Mutable state goes to /tmp (writable by any UID). Toolchains stay read-only.
ENV CARGO_HOME=/tmp/.cargo BUN_TMPDIR=/tmp
# ── Entrypoint ────────────────────────────────────────────────────────────────
RUN cat <<'ENTRY' > /usr/local/bin/entrypoint.sh
#!/bin/sh
/usr/local/bin/register-dynamic-user.sh "$(id -u)" "$(id -g)"
# Start PostgreSQL (unix socket in /tmp, owned by postgres user)
mkdir -p /tmp/pgdata && sudo chown postgres:postgres /tmp/pgdata
if [ ! -f /tmp/pgdata/PG_VERSION ]; then
sudo -u postgres /usr/lib/postgresql/15/bin/initdb -D /tmp/pgdata --auth=trust
fi
sudo -u postgres /usr/lib/postgresql/15/bin/pg_ctl -D /tmp/pgdata -l /tmp/pg.log start -o "-k /tmp"
sudo -u postgres psql -h /tmp -c "CREATE ROLE sandbox SUPERUSER LOGIN" 2>/dev/null || true
sudo -u postgres createdb -h /tmp windmill 2>/dev/null || true
# Run database migrations so sqlx compile-time checks work
if [ -d "$PWD/backend/migrations" ]; then
DATABASE_URL="postgres://sandbox@localhost/windmill?host=/tmp" \
sqlx migrate run --source "$PWD/backend/migrations" 2>/dev/null || true
fi
# Install frontend dependencies and generate backend client
if [ -d "$PWD/frontend" ]; then
(cd "$PWD/frontend" && npm install && npm run generate-backend-client) 2>/dev/null || true
fi
exec "$@"
ENTRY
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -1,196 +0,0 @@
# Windmill Development with workmux
This guide covers the workmux-based development setup for Windmill. Each worktree gets its own tmux window with a Claude Code agent, a backend server (with auto-reload), and a frontend dev server — all on isolated ports.
## Prerequisites
- tmux
- Rust toolchain (rustup)
- Node.js + npm
- PostgreSQL running locally (see `backend/.env`)
## Installation
### 1. Install workmux
```bash
cargo install workmux
```
### 2. Install the Claude Code plugin
```bash
workmux claude install
```
This lets workmux manage Claude Code agents in worktree panes.
### 3. Install cargo-watch
Used for auto-recompiling the backend on file changes:
```bash
cargo install cargo-watch
```
### 4. Install llm CLI (required for auto branch naming)
workmux uses the `llm` CLI to automatically generate branch names from prompts. Install it with:
```bash
uv tool install llm
llm install llm-anthropic
```
Then set your Anthropic API key:
```bash
llm keys set anthropic
# paste your API key when prompted
```
### 5. Recommended: shell alias and autocomplete
Set up a `wm` alias for convenience:
```bash
# Add to your ~/.zshrc
alias wm="workmux"
```
Setting up zsh autocomplete is also recommended — see the [workmux docs](https://github.com/rubenfiszel/workmux) for instructions.
## Port Slot System
Each worktree is assigned a **slot** that determines its ports:
| Slot | Backend | Frontend |
|------|---------|----------|
| 0 | 8000 | 3000 |
| 1 | 8010 | 3010 |
| 2 | 8020 | 3020 |
| 3 | 8030 | 3030 |
| ... | ... | ... |
- **Slot 0** is reserved for the main worktree (default `cargo run` / `npm run dev`).
- Without `WM_SLOT`, the script auto-assigns the first available slot (starting from 1) and prints it.
- With `WM_SLOT=N`, it uses that slot and errors if the ports are taken.
## SSH Port Forwarding
If you develop over SSH, add this to `~/.ssh/config` on your **local machine** to pre-configure tunnels for each slot:
```
Host windmill-dev
HostName <remote-ip>
User <username>
# Slot 0 (main worktree)
LocalForward 8000 localhost:8000
LocalForward 3000 localhost:3000
# Slot 1
LocalForward 8010 localhost:8010
LocalForward 3010 localhost:3010
# Slot 2
LocalForward 8020 localhost:8020
LocalForward 3020 localhost:3020
# Slot 3
LocalForward 8030 localhost:8030
LocalForward 3030 localhost:3030
```
Then connect once and all tunnels are active:
```bash
ssh windmill-dev
```
Access the frontend at `http://localhost:<frontend-port>` in your local browser.
## Quickstart
```bash
# Create a new worktree (auto-assigns slot, prints ports)
workmux add my-feature
# Or with an explicit slot
WM_SLOT=2 workmux add my-feature
# Create a worktree and immediately send a prompt to the agent
workmux add -A -p "fix the login bug in auth.rs"
```
The `add` command creates the worktree but does **not** open it. To open the tmux window and start working:
```bash
workmux open my-feature
```
This will open a tmux window with three panes:
- **Claude Code agent** (focused)
- **Backend**: `cargo watch -x run` on the assigned port (auto-reloads on save)
- **Frontend**: `npm run dev` proxying to the backend
When using `-A` with `add`, the worktree is created and opened automatically, and the prompt is sent to the agent right away.
Check which ports were assigned:
```bash
cat <worktree-path>/.env.local
```
### Sending work to the agent
```bash
# Send a prompt to the agent in a worktree
workmux send my-feature "fix the login bug in auth.rs"
# Check agent status
workmux status
```
### Merging and cleaning up
We never merge worktrees directly — always create a PR on GitHub and let it be merged there. Once the PR is merged, clean up the worktree:
```bash
# Close the tmux window but keep the worktree
workmux close my-feature
# After your PR is merged, remove the worktree, branch, and tmux window
workmux rm my-feature
```
> **Note**: Do not use `workmux merge`. Always go through a PR to get your changes into main. You can ask the Claude Code agent in the worktree to create the PR for you.
## Configuration
The setup is defined in `.workmux.yaml` at the repo root. Key sections:
- **`post_create`**: Runs `scripts/worktree-env` to generate `.env.local` with port assignments
- **`panes`**: Defines the tmux layout (agent, backend, frontend)
- **`files.copy`**: Copies `backend/.env` and `scripts/` into each worktree
- **`files.symlink`**: Symlinks `node_modules` and `.svelte-kit` to avoid reinstalling per worktree
## Enterprise (EE) Code Access
The enterprise source code lives in the `windmill-ee-private` repository (sibling to this repo). When you create a worktree, `scripts/worktree-env` automatically creates a matching EE worktree on the same branch and configures Claude Code's `additionalDirectories` to grant access.
### Sandbox setup
When using sandbox mode, the container needs explicit mounts to access the EE repo. Add the following to your global workmux config (`~/.config/workmux/config.yaml`):
```yaml
sandbox:
extra_mounts:
- host_path: ~/windmill-ee-private
writable: true
- host_path: ~/windmill-ee-private__worktrees
writable: true
```
This mounts both the main EE repo (used by the main worktree) and the EE worktrees directory (used by feature worktrees) into every sandbox container.
## Login
Default credentials: `admin@windmill.dev` / `changeme`

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag)\n SELECT unnest($1::uuid[]), 'test-workspace', now(), 'flow'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "0681b850c033619e1b9498376263681f875a5aba22170ca50ec8b578f7fa478b"
}

View File

@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]

View File

@@ -43,8 +43,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH completed AS (\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result,\n flow_status, workflow_as_code_status, status, worker)\n SELECT\n q.workspace_id, q.id, q.started_at,\n (EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000,\n CASE WHEN q.running\n THEN $3::text::jsonb\n ELSE $4::text::jsonb\n END,\n s.flow_status,\n s.workflow_as_code_status,\n 'skipped'::job_status,\n q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_status s ON s.id = q.id\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = EXCLUDED.result\n RETURNING 1 AS x\n ), _deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n ), _logged AS (\n INSERT INTO job_logs (logs, job_id, workspace_id)\n VALUES ($5, $1, $2)\n ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)\n )\n SELECT x FROM completed\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "x",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "1437b432d2c23e30eb05443e83069cdb049f65ec299b0778ce14677728cf6346"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT result::text FROM v2_job_completed WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "result",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "18b6262a60400f2b58ab26615466c23b4c1a7805c66b70b0fcfb7d33b122a7bf"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag)\n VALUES ($1, $2, now(), 'flow')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar"
]
},
"nullable": []
},
"hash": "1af6885dbc5055281acb82b3e57f7dba2e4b04d9535058fab695660a14bf8890"
}

View File

@@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, is_workspace_integration FROM account WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "client",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "refresh_token",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "grant_type",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "cc_client_id",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "cc_client_secret",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "cc_token_url",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "mcp_server_url",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "is_workspace_integration",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Int4"
]
},
"nullable": [
false,
false,
false,
true,
true,
true,
true,
false
]
},
"hash": "1ba2e23d4ba816048ec1e88af9e342867fc0443cabea16d111afa2b91d3fe03b"
}

View File

@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path)\n VALUES ($1, 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', $2, $3)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "27f70ebe788cca2e88732d8bf978883037bebca4cf75ba459858e4fb197f940b"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "debounce_batch",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "2a95f18e80c55a7e8178a4bd2b781d41fa47efd4da5bb9bc2d72b9aa1e33617f"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path)\n VALUES ($1, 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', 'ws2', 'f/test/flow')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "4010328a9f1611064f497726b69c08625a55a4dab25c3d9b5ece07e44d14915b"
}

View File

@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id, -- replace current job with new one \n debounced_times = debounce_key.debounced_times + 1 -- evaluated only if conflict,\n -- conflict means there is already existing value,\n -- which means overriding it will also imply adding new entry to v2_job_debounce_batch and thus debouncing the job\n -- so the counter should be incremented\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "debounced_times",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "first_started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "job_id_to_debounce",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "454ace9ce391725ef4f4c129cd66e4c12a5c40f512b70551958178c8b4d6c183"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH ids AS (\n SELECT id as job_id FROM v2_job_debounce_batch WHERE debounce_batch = (\n SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = $1\n )\n ) SELECT args->>'items' FROM ids LEFT JOIN v2_job ON v2_job.id = ids.job_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "48536968f4173715d4ef8293683c2a3eb4bd22fbe18c34890a3dc4e96e4e6133"
}

View File

@@ -42,8 +42,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -38,8 +38,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag)\n SELECT unnest($1::uuid[]), 'test-workspace', now(), 'deno'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "539d661500254e2e346490710f5772cb88a1ab6bbddd97a77e06644ac0f61762"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "WITH job_result AS (\n SELECT result\n FROM v2_job_completed\n WHERE id = $1\n ),\n updated_queue AS (\n UPDATE v2_job_queue\n SET running = false,\n tag = COALESCE($3, tag)\n WHERE id = $2\n )\n UPDATE v2_job\n SET\n tag = COALESCE($3, tag),\n concurrent_limit = COALESCE($4, concurrent_limit),\n concurrency_time_window_s = COALESCE($5, concurrency_time_window_s),\n args = COALESCE(\n CASE\n WHEN job_result.result IS NULL THEN NULL\n WHEN jsonb_typeof(job_result.result) = 'object'\n THEN job_result.result\n WHEN jsonb_typeof(job_result.result) = 'null'\n THEN NULL\n ELSE jsonb_build_object('value', job_result.result)\n END,\n '{}'::jsonb\n ),\n preprocessed = TRUE\n FROM job_result\n WHERE v2_job.id = $2;\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Varchar",
"Int4",
"Int4"
]
},
"nullable": []
},
"hash": "5b8c1803f0ccead11517fbc8a9bdc0227dc3922217fa18f0b71ff0484d65838c"
}

View File

@@ -77,8 +77,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE debounce_key SET first_started_at = now() - interval '20 seconds' WHERE key = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "66342c32f7ae0238803cb1896d9f23a74b64573f77dd32189a25b6e8369f147b"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path)\n SELECT unnest($1::uuid[]), 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace', 'f/test/flow'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "66faba2137791e0cb1353545c06f9f7c23a1559e7a761db7c2195736b8b30709"
}

View File

@@ -1,19 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH job_result AS (\n SELECT result\n FROM v2_job_completed\n WHERE id = $1\n ),\n updated_queue AS (\n UPDATE v2_job_queue\n SET running = false,\n tag = COALESCE($3, tag),\n scheduled_for = COALESCE($6, scheduled_for)\n WHERE id = $2\n )\n UPDATE v2_job\n SET\n tag = COALESCE($3, tag),\n concurrent_limit = COALESCE($4, concurrent_limit),\n concurrency_time_window_s = COALESCE($5, concurrency_time_window_s),\n args = COALESCE(\n CASE\n WHEN job_result.result IS NULL THEN NULL\n WHEN jsonb_typeof(job_result.result) = 'object'\n THEN job_result.result\n WHEN jsonb_typeof(job_result.result) = 'null'\n THEN NULL\n ELSE jsonb_build_object('value', job_result.result)\n END,\n '{}'::jsonb\n ),\n preprocessed = TRUE\n FROM job_result\n WHERE v2_job.id = $2;\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid",
"Varchar",
"Int4",
"Int4",
"Timestamptz"
]
},
"nullable": []
},
"hash": "79b437ad31ddab94310989b8fb6a1c130b9be1ab4b6a100fffffd687677b9c92"
}

View File

@@ -44,8 +44,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT logs as \"logs!\" FROM job_logs WHERE job_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "logs!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "7ca599330c9913c7e66b27e2ffcfa18d53cbdd16e179749f0aea7980a901b23c"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag)\n VALUES ($1, $2, now(), 'deno')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar"
]
},
"nullable": []
},
"hash": "7ca7dabfe360845a5b57552b0d02267d5dbbc488bc7ab990c0bda1594bf5ef3a"
}

View File

@@ -42,8 +42,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n -- if it the first one, nextval will be evaluated, otherwise take from the job we will debounce\n SELECT\n $2,\n COALESCE(\n (\n SELECT debounce_batch\n FROM v2_job_debounce_batch\n WHERE id = $1\n LIMIT 1\n ), -- maybe use current batch\n nextval('debounce_batch_seq')\n )\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "8360ab72d60f07dde6ecae599e6531b5b86862029ab51fdbdd44ec16239108e2"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_settings (workspace_id) VALUES ('ws2')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "8cf5af21cde4e4de45f995efa2a9b56ce20c26869ca78d7e17b3504b92ae85b1"
}

View File

@@ -102,8 +102,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,34 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT job_id, previous_job_id, debounced_times FROM debounce_key WHERE key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "previous_job_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "debounced_times",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false
]
},
"hash": "8f442110817244aa9533b014aa3d74a6582937dfe4759932b63b8e531984008e"
}

View File

@@ -32,8 +32,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "debounced_times",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "first_started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "job_id_to_debounce",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id)\n VALUES ($1, 'noop', 'deno', 'test-user', 'u/test-user', 'test@windmill.dev', $2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar"
]
},
"nullable": []
},
"hash": "9f50ec7681a1fcd11cb452c7aba7e8897e49a4b6affa4e9976680336b6bd3115"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace (id, name, owner) VALUES ('ws2', 'Workspace 2', 'test-user')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "a057ff9f5998a162ae6de05f6127b7eefc826af7bf1bb89fd758f7c03c881033"
}

View File

@@ -72,8 +72,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -77,8 +77,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_runtime (id) VALUES ($1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "a68754521bf751450602f04dd4243199a18885e1739a5a0e7f6100eab6f3c803"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET runnable_settings_handle = $1 WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "abb56f78aa39c6b6ae8b0ccb7b724c1f80d717e7c9d76b287da63cb5ee8e8b25"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 as x FROM v2_job_completed WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "x",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "adb98040c8039e5cc27fe0579941f723b38d1784bd2f1b16d8724f1f1612dcbf"
}

View File

@@ -102,8 +102,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -72,8 +72,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind",
"query": "SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval",
"describe": {
"columns": [
{
@@ -36,5 +36,5 @@
false
]
},
"hash": "53648c069749df45c0459d733b3e429af20c69c841fb0c3bceafe3ea6c3f5329"
"hash": "b45e17ad532a23b394226c9a5d7ab5a21e20202dbbf9c67831cc62eb067cd2ba"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_runtime (id) SELECT unnest($1::uuid[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "b4a9abcb38997587b28655b0f4a212a5bd4039b57fab20b163617e33a4c9dd46"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 as x FROM v2_job_queue WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "x",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "b795dc228f93c8b9bedb4a3e7467d941819a7202214e0634580bdc2ec30f0b70"
}

View File

@@ -41,8 +41,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, tag) VALUES ($1, 'ws2', now(), 'flow')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "c1a1ae759ebb84fde3e6d2727991b2a1fcc73e520fee79653bb960dc00c3e2db"
}

View File

@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "debounced_times",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "first_started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "job_id_to_debounce",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\" FROM v2_job_queue WHERE id = ANY($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
null
]
},
"hash": "c63a1949247f1618f6b6acee9bf6b4d3081dfed2e6ff533dbff0bdfd52687cbb"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id)\n SELECT unnest($1::uuid[]), 'noop', 'deno', 'test-user', 'u/test-user', 'test@windmill.dev', 'test-workspace'",
"describe": {
"columns": [],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": []
},
"hash": "c6d963e5cefeea728414892df9f28a89f435fc0fb7e55f243b021200f33d2151"
}

View File

@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH _ AS (\n UPDATE debounce_key\n SET debounced_times = 0,\n first_started_at = now(),\n previous_job_id = NULL\n WHERE job_id = $1\n )\n UPDATE v2_job_debounce_batch\n SET debounce_batch = nextval('debounce_batch_seq')\n WHERE id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "c9530931f670eab1208c4a284a55afdc3fcbb0eb5f98fd63e2ec89442becbfaa"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\" FROM v2_job_completed WHERE id = ANY($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
null
]
},
"hash": "cc309de42a3b630bb83d1b2437633ef0b98ce5e5fba1f5c1dda3ddd874ff3a39"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = ANY($1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "debounce_batch",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
false
]
},
"hash": "ccfed494a8d89eb2c88d72738c341a4dd87701b0636eb9fa001cd1d9cbcd663b"
}

View File

@@ -41,8 +41,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = ANY($1) ORDER BY debounce_batch",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "debounce_batch",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"UuidArray"
]
},
"nullable": [
false
]
},
"hash": "d9400849888dd021b0504b93004ab5e76296ed437c210942363ba06845f9f963"
}

View File

@@ -31,8 +31,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -37,8 +37,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -77,8 +77,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job (id, kind, tag, created_by, permissioned_as, permissioned_as_email, workspace_id, runnable_path, args)\n VALUES ($1, 'flow', 'flow', 'test-user', 'u/test-user', 'test@windmill.dev', $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Varchar",
"Jsonb"
]
},
"nullable": []
},
"hash": "f8cec94b94098e752f7c71cbe5e9996410a07bacebc37ed21e0bedf1a33a8fdc"
}

View File

@@ -32,8 +32,7 @@
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow",
"snapshotbuild"
"unassigned_singlestepflow"
]
}
}

View File

@@ -1,8 +0,0 @@
panes:
# Pane 1: Install dependencies, then start dev server
- command: cargo run
# Pane 2: AI agent
- command: <agent>
split: horizontal
focus: true

View File

@@ -44,22 +44,11 @@ Windmill uses a workspace-based architecture with multiple crates:
## Enterprise Features
- Enterprise files use the `*_ee.rs` suffix
- Enterprise source is in `windmill-ee-private` folder (sibling directory at `../../windmill-ee-private` or `~/windmill-ee-private`), symlinked into each crate's `src/`
- The `_ee.rs` files are gitignored in the main repo — they are tracked only in the `windmill-ee-private` repo
- Enterprise source is in `windmill-ee-private` folder (sibling directory at `../../windmill-ee-private`), symlinked into each crate's `src/`
- You can and should modify `windmill-ee-private` directly when needed (e.g., when creating new crates that need EE code, mirror the package structure there)
- Use feature flags: `#[cfg(feature = "enterprise")]`
- Isolate enterprise code in separate modules
### EE PR Workflow (MUST DO when modifying `*_ee.rs` files)
When you modify any `*_ee.rs` file and create a PR on the windmill repo, you **MUST** also:
1. **Create a matching branch** in the `windmill-ee-private` repo (use the same branch name). If using worktrees, the EE worktree is at `~/windmill-ee-private__worktrees/<branch-name>/`
2. **Commit and push** the `_ee.rs` changes in that branch
3. **Create a PR** on `windmill-ee-private` with a link to the companion windmill PR
4. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` to write the latest EE commit hash. **Important**: the script may fall back to `~/windmill-ee-private` (main branch) instead of the worktree — verify it wrote the correct commit hash from your branch, not from main. If wrong, manually write the correct hash.
5. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref
## Code Validation (MUST DO)
After making backend changes, you MUST run `cargo check` and fix all errors and warnings before considering the work done.

487
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,13 +1,12 @@
[package]
name = "windmill"
version = "1.642.0"
version = "1.638.2"
authors.workspace = true
edition.workspace = true
[workspace]
resolver = "2"
members = [
"./windmill-object-store",
"./windmill-api",
"./windmill-api-scripts",
"./windmill-api-flows",
@@ -38,9 +37,11 @@ members = [
"./windmill-api-inputs",
"./windmill-api-npm-proxy",
"./windmill-api-openapi",
"./windmill-api-sandbox",
"./windmill-api-schedule",
"./windmill-api-settings",
"./windmill-api-workers",
"./windmill-sandbox",
"./windmill-store",
"./windmill-queue",
"./windmill-worker",
@@ -76,7 +77,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.642.0"
version = "1.638.2"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -97,19 +98,19 @@ lto = "thin"
[features]
default = []
private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-object-store/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-operator?/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"]
private = ["windmill-api/private", "windmill-api-agent-workers?/private", "windmill-autoscaling/private", "windmill-common/private", "windmill-git-sync/private", "windmill-indexer/private", "windmill-operator?/private", "windmill-queue/private", "windmill-worker/private", "windmill-test-utils/private"]
agent_worker_server = ["windmill-api/agent_worker_server", "dep:windmill-api-agent-workers", "windmill-test-utils/agent_worker_server"]
enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-api-agent-workers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise", "windmill-object-store/enterprise"]
enterprise = ["windmill-worker/enterprise", "windmill-queue/enterprise", "windmill-api/enterprise", "windmill-api-agent-workers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise", "windmill-git-sync/enterprise", "windmill-common/prometheus", "windmill-common/enterprise"]
local_reports = ["windmill-common/local_reports"]
enterprise_saml = ["windmill-api/enterprise_saml", "oauth2"]
stripe = ["windmill-api/stripe"]
benchmark = ["windmill-api/benchmark", "windmill-worker/benchmark", "windmill-queue/benchmark", "windmill-common/benchmark", "windmill-api-agent-workers?/benchmark"]
benchmark = ["windmill-api/benchmark", "windmill-worker/benchmark", "windmill-queue/benchmark", "windmill-common/benchmark"]
embedding = ["windmill-api/embedding"]
parquet = ["windmill-api/parquet", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker/parquet"]
parquet = ["windmill-api/parquet", "windmill-common/parquet", "windmill-worker/parquet", "dep:object_store"]
prometheus = ["windmill-common/prometheus", "windmill-api/prometheus", "windmill-worker/prometheus", "windmill-queue/prometheus", "dep:prometheus"]
flow_testing = ["windmill-worker/flow_testing"]
quickjs = ["windmill-worker/quickjs", "windmill-api/quickjs"]
openidconnect = ["windmill-api/openidconnect", "windmill-common/openidconnect", "windmill-object-store/openidconnect"]
openidconnect = ["windmill-api/openidconnect", "windmill-common/openidconnect"]
cloud = ["windmill-queue/cloud", "windmill-worker/cloud", "windmill-common/cloud", "windmill-api/cloud"]
jemalloc = ["windmill-common/jemalloc", "dep:tikv-jemallocator", "dep:tikv-jemalloc-sys", "dep:tikv-jemalloc-ctl"]
tantivy = ["dep:windmill-indexer", "windmill-api/tantivy", "windmill-indexer/enterprise", "windmill-indexer/parquet", "windmill-common/tantivy", "enterprise", "parquet"]
@@ -200,7 +201,6 @@ tokio-stream.workspace = true
dotenv.workspace = true
windmill-queue.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-object-store.workspace = true
windmill-git-sync.workspace = true
windmill-api = { workspace = true, default-features = false }
windmill-api-agent-workers = { workspace = true, optional = true }
@@ -230,6 +230,7 @@ serde_derive.workspace = true
serde_yml.workspace = true
serde.workspace = true
windmill-runtime-nativets = { workspace = true, optional = true }
object_store = { workspace = true, optional = true }
sha1 = { workspace = true, optional = true }
constant_time_eq = { workspace = true, optional = true }
rustls.workspace = true
@@ -254,7 +255,6 @@ axum.workspace = true
serde.workspace = true
windmill-api-client.workspace = true
tempfile.workspace = true
windmill-parser-ts.workspace = true
rumqttc.workspace = true
rdkafka.workspace = true
async-nats.workspace = true
@@ -270,7 +270,6 @@ windmill-worker = { path = "./windmill-worker" }
windmill-dep-map = { path = "./windmill-dep-map" }
windmill-types = { path = "./windmill-types" }
windmill-common = { path = "./windmill-common", default-features = false }
windmill-object-store = { path = "./windmill-object-store" }
windmill-audit = { path = "./windmill-audit" }
windmill-git-sync = { path = "./windmill-git-sync" }
windmill-autoscaling = { path = "./windmill-autoscaling" }
@@ -308,9 +307,11 @@ windmill-api-flow-conversations = { path = "./windmill-api-flow-conversations" }
windmill-api-inputs = { path = "./windmill-api-inputs" }
windmill-api-npm-proxy = { path = "./windmill-api-npm-proxy" }
windmill-api-openapi = { path = "./windmill-api-openapi" }
windmill-api-sandbox = { path = "./windmill-api-sandbox" }
windmill-api-schedule = { path = "./windmill-api-schedule" }
windmill-api-settings = { path = "./windmill-api-settings" }
windmill-api-workers = { path = "./windmill-api-workers" }
windmill-sandbox = { path = "./windmill-sandbox" }
windmill-store = { path = "./windmill-store" }
windmill-parser = { path = "./parsers/windmill-parser" }
windmill-parser-ts = { path = "./parsers/windmill-parser-ts" }
@@ -480,7 +481,7 @@ bit-vec = "=0.6.3"
mappable-rc = "^0"
mysql_async = { version = "*", default-features = false, features = ["minimal", "default", "native-tls-tls", "rust_decimal"]}
postgres-native-tls = "^0"
native-tls = ">=0.2, <0.2.17"
native-tls = "^0"
# samael will break compilation on MacOS. Use this fork instead to make it work
# samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] }
libxml = { version = "=0.3.3" }

View File

@@ -1 +1 @@
0fede4b1086bc1456be9cc55b203228c979c5c5e
592848d59ca2304926fb2bd85d000668a7f46a77

View File

@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS sandbox_exec;
DROP TABLE IF EXISTS sandbox;
DROP TABLE IF EXISTS sandbox_host;
DROP TYPE IF EXISTS sandbox_status;

View File

@@ -0,0 +1,69 @@
CREATE TYPE sandbox_status AS ENUM ('creating', 'running', 'suspended', 'stopped', 'error');
CREATE TABLE sandbox_host (
id VARCHAR(255) PRIMARY KEY,
base_url TEXT NOT NULL,
last_ping TIMESTAMPTZ NOT NULL DEFAULT now(),
capacity INT NOT NULL DEFAULT 10,
active_count INT NOT NULL DEFAULT 0,
labels JSONB NOT NULL DEFAULT '{}'
);
CREATE TABLE sandbox (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id),
created_by VARCHAR(55) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
image TEXT,
timeout_secs INT,
idle_timeout_secs INT,
cpu_limit INT NOT NULL DEFAULT 1,
memory_limit_mb INT NOT NULL DEFAULT 512,
disk_limit_mb INT NOT NULL DEFAULT 1024,
env_vars JSONB NOT NULL DEFAULT '{}',
labels JSONB NOT NULL DEFAULT '{}',
mounts JSONB NOT NULL DEFAULT '[]',
network_enabled BOOLEAN NOT NULL DEFAULT false,
mode VARCHAR(20) NOT NULL DEFAULT 'embedded',
parent_job_id UUID,
host_id VARCHAR(255) REFERENCES sandbox_host(id),
status sandbox_status NOT NULL DEFAULT 'creating',
pid INT,
started_at TIMESTAMPTZ,
last_activity_at TIMESTAMPTZ,
suspended_at TIMESTAMPTZ,
stopped_at TIMESTAMPTZ,
error_message TEXT,
ephemeral BOOLEAN NOT NULL DEFAULT false,
auto_stop_after_secs INT,
expires_at TIMESTAMPTZ
);
CREATE INDEX idx_sandbox_workspace_status ON sandbox(workspace_id, status);
CREATE INDEX idx_sandbox_host ON sandbox(host_id) WHERE status IN ('running', 'suspended');
CREATE INDEX idx_sandbox_expires ON sandbox(expires_at) WHERE expires_at IS NOT NULL;
CREATE TABLE sandbox_exec (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sandbox_id UUID NOT NULL REFERENCES sandbox(id) ON DELETE CASCADE,
workspace_id VARCHAR(50) NOT NULL,
command TEXT NOT NULL,
cwd TEXT,
env_vars JSONB,
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
exit_code INT,
stdout TEXT,
stderr TEXT,
duration_ms BIGINT,
created_by VARCHAR(55) NOT NULL
);
CREATE INDEX idx_sandbox_exec_sandbox ON sandbox_exec(sandbox_id, started_at DESC);

View File

@@ -16,7 +16,6 @@ regex.workspace = true
[dependencies]
windmill-parser.workspace = true
windmill-types.workspace = true
anyhow.workspace = true
lazy_static.workspace = true
serde_json.workspace = true

View File

@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
use sqlparser::{
ast::{
CopyTarget, Expr, FunctionArg, FunctionArgExpr, ObjectName, ObjectNamePart, SelectItem,
TableFactor, TableObject, Value, ValueWithSpan, Visit, Visitor,
CopyTarget, Expr, ObjectName, ObjectNamePart, SelectItem, TableFactor, TableObject, Value,
ValueWithSpan, Visit, Visitor,
},
dialect::DuckDbDialect,
parser::Parser,
@@ -125,72 +125,6 @@ impl AssetCollector {
Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None })
}
/// If `table_factor` is a string literal used directly as a table name (e.g. FROM 's3:///file.parquet'),
/// return a `ParseAssetsResult` for it.
fn get_s3_asset_from_str_literal_table(
&self,
table_factor: &TableFactor,
) -> Option<ParseAssetsResult> {
let name = match table_factor {
TableFactor::Table { name, args: None, .. } => name,
_ => return None,
};
let s3_str = get_str_lit_from_obj_name(name)?;
let (kind, path) = parse_asset_syntax(s3_str, false)?;
if kind != AssetKind::S3Object {
return None;
}
Some(ParseAssetsResult {
kind,
path: path.to_string(),
access_type: Some(R),
columns: None,
})
}
/// If `table_factor` is a read function (read_parquet/read_csv/read_json) whose first
/// positional argument is an S3 string literal, return a `ParseAssetsResult` for it.
fn get_s3_asset_from_table_function(
&self,
table_factor: &TableFactor,
) -> Option<ParseAssetsResult> {
let (name, args) = match table_factor {
TableFactor::Table { name, args: Some(args), .. } => (name, args),
_ => return None,
};
let fname = get_trivial_obj_name(name)?;
if !is_read_fn(fname) {
return None;
}
let s3_str = args.args.first().and_then(|arg| match arg {
FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(ValueWithSpan {
value: Value::SingleQuotedString(s),
..
})))
| FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(ValueWithSpan {
value: Value::DoubleQuotedString(s),
..
}))) => Some(s.as_str()),
_ => None,
})?;
let (kind, path) = parse_asset_syntax(s3_str, false)?;
if kind != AssetKind::S3Object {
return None;
}
Some(ParseAssetsResult {
kind,
path: path.to_string(),
access_type: Some(R),
columns: None,
})
}
fn handle_string_literal(&mut self, s: &str) {
// Check if the string matches our asset syntax patterns
if let Some((kind, path)) = parse_asset_syntax(s, false) {
@@ -256,19 +190,13 @@ impl AssetCollector {
projection: &[SelectItem],
from_tables: &[sqlparser::ast::TableWithJoins],
) {
// Check if this is a single-table SELECT (to avoid ambiguity).
// For S3 table functions (read_parquet/read_csv/read_json), detect the asset even
// though args are present, since we know the file path from the string literal arg.
// Check if this is a single-table SELECT (to avoid ambiguity)
let single_table = if from_tables.len() == 1 {
let relation = &from_tables[0].relation;
if let TableFactor::Table { name, args, .. } = relation {
let has_args = args.as_ref().map_or(false, |a| !a.args.is_empty());
if has_args {
self.get_s3_asset_from_table_function(relation)
} else {
self.get_associated_asset_from_obj_name(name, Some(R))
.or_else(|| self.get_s3_asset_from_str_literal_table(relation))
if let TableFactor::Table { name, args, .. } = &from_tables[0].relation {
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
return; // Skip table functions
}
self.get_associated_asset_from_obj_name(name, Some(R))
} else {
None
}
@@ -276,48 +204,26 @@ impl AssetCollector {
None
};
// Build a map of table aliases/names to assets for multi-table queries.
// For S3 table functions, only aliased references are unambiguous
// (e.g. SELECT t.col1 FROM read_parquet('s3://...') AS t).
// Build a map of table aliases/names to assets for multi-table queries
let mut table_to_asset: BTreeMap<String, ParseAssetsResult> = BTreeMap::new();
for table_with_joins in from_tables {
if let TableFactor::Table { name, alias, args, .. } = &table_with_joins.relation {
let has_args = args.as_ref().map_or(false, |a| !a.args.is_empty());
if has_args {
// For table functions, only add to the alias map when an alias is present
if let Some(alias) = alias {
if let Some(asset) =
self.get_s3_asset_from_table_function(&table_with_joins.relation)
{
table_to_asset.insert(alias.name.value.clone(), asset);
}
}
} else if let Some(asset) = self
.get_associated_asset_from_obj_name(name, Some(R))
.or_else(|| {
self.get_s3_asset_from_str_literal_table(&table_with_joins.relation)
})
{
// For string literal S3 tables (e.g. FROM 's3:///file.parquet'), only add to
// the alias map when an alias is present (to avoid false positives).
// For regular named tables, use alias or table name as key.
let is_str_literal = get_str_lit_from_obj_name(name).is_some();
if is_str_literal {
if let Some(alias) = alias {
table_to_asset.insert(alias.name.value.clone(), asset);
}
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
continue; // Skip table functions
}
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(R)) {
// Use alias if present, otherwise use the table name
let table_key = if let Some(alias) = alias {
alias.name.value.clone()
} else {
let table_key = if let Some(alias) = alias {
alias.name.value.clone()
} else {
name.0
.last()
.and_then(|id| id.as_ident())
.map(|id| id.value.clone())
.unwrap_or_default()
};
table_to_asset.insert(table_key, asset);
}
// For qualified names like "dl.table1", use just the last part
name.0
.last()
.and_then(|id| id.as_ident())
.map(|id| id.value.clone())
.unwrap_or_default()
};
table_to_asset.insert(table_key, asset);
}
}
}
@@ -1365,163 +1271,4 @@ mod tests {
assert_eq!(columns.get("age"), Some(&W)); // Only written
assert_eq!(columns.get("id"), Some(&R)); // Only read
}
#[test]
fn test_sql_asset_parser_s3_single_table_column_detection() {
let input = r#"
SELECT col1, col2 FROM read_parquet('s3:///example_file.parquet');
"#;
let result = parse_assets(input).unwrap().assets;
assert_eq!(result.len(), 1);
assert_eq!(result[0].kind, AssetKind::S3Object);
assert_eq!(result[0].path, "/example_file.parquet");
assert_eq!(result[0].access_type, Some(R));
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.len(), 2);
assert_eq!(columns.get("col1"), Some(&R));
assert_eq!(columns.get("col2"), Some(&R));
}
#[test]
fn test_sql_asset_parser_s3_single_table_column_with_alias() {
let input = r#"
SELECT col1 AS c1, col2 AS c2 FROM read_parquet('s3:///example_file.parquet');
"#;
let result = parse_assets(input).unwrap().assets;
assert_eq!(result.len(), 1);
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.get("col1"), Some(&R));
assert_eq!(columns.get("col2"), Some(&R));
}
#[test]
fn test_sql_asset_parser_s3_wildcard_no_columns() {
let input = r#"
SELECT * FROM read_parquet('s3:///example_file.parquet');
"#;
let result = parse_assets(input).unwrap().assets;
assert_eq!(result.len(), 1);
assert_eq!(result[0].kind, AssetKind::S3Object);
assert_eq!(result[0].path, "/example_file.parquet");
assert!(result[0].columns.is_none());
}
#[test]
fn test_sql_asset_parser_s3_table_alias_qualified_columns() {
let input = r#"
SELECT t.col1, t.col2 FROM read_parquet('s3:///example_file.parquet') AS t;
"#;
let result = parse_assets(input).unwrap().assets;
assert_eq!(result.len(), 1);
assert_eq!(result[0].kind, AssetKind::S3Object);
assert_eq!(result[0].path, "/example_file.parquet");
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.get("col1"), Some(&R));
assert_eq!(columns.get("col2"), Some(&R));
}
#[test]
fn test_sql_asset_parser_s3_multi_table_aliased_columns() {
let input = r#"
SELECT t1.col1, t2.col2
FROM read_parquet('s3:///file1.parquet') AS t1,
read_csv('s3://bucket/file2.csv') AS t2;
"#;
let result = parse_assets(input).unwrap().assets;
assert_eq!(result.len(), 2);
assert!(result.iter().any(|a| {
a.path == "/file1.parquet"
&& a.columns.as_ref().map_or(false, |c| c.contains_key("col1"))
}));
assert!(result.iter().any(|a| {
a.path == "bucket/file2.csv"
&& a.columns.as_ref().map_or(false, |c| c.contains_key("col2"))
}));
}
#[test]
fn test_sql_asset_parser_s3_multi_table_no_alias_no_columns() {
// Without aliases, unqualified columns in a multi-table query are ambiguous
let input = r#"
SELECT col1, col2
FROM read_parquet('s3:///file1.parquet'),
read_parquet('s3:///file2.parquet');
"#;
let result = parse_assets(input).unwrap().assets;
// Table-level assets should still be detected, but no columns
assert_eq!(result.iter().filter(|a| a.columns.is_some()).count(), 0);
}
#[test]
fn test_sql_asset_parser_s3_str_literal_table_column_detection() {
// FROM 's3:///file.parquet' (string literal as table, no read_parquet wrapper)
let input = r#"
SELECT a,b,c FROM 's3:///test.parquet';
"#;
let result = parse_assets(input).unwrap().assets;
assert_eq!(result.len(), 1);
assert_eq!(result[0].kind, AssetKind::S3Object);
assert_eq!(result[0].path, "/test.parquet");
assert_eq!(result[0].access_type, Some(R));
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.len(), 3);
assert_eq!(columns.get("a"), Some(&R));
assert_eq!(columns.get("b"), Some(&R));
assert_eq!(columns.get("c"), Some(&R));
}
#[test]
fn test_sql_asset_parser_s3_str_literal_table_with_alias_columns() {
let input = r#"
SELECT t.col1, t.col2 FROM 's3://bucket/file.parquet' AS t;
"#;
let result = parse_assets(input).unwrap().assets;
assert_eq!(result.len(), 1);
assert_eq!(result[0].kind, AssetKind::S3Object);
assert_eq!(result[0].path, "bucket/file.parquet");
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.get("col1"), Some(&R));
assert_eq!(columns.get("col2"), Some(&R));
}
#[test]
fn test_sql_asset_parser_s3_str_literal_wildcard_no_columns() {
let input = r#"
SELECT * FROM 's3:///test.parquet';
"#;
let result = parse_assets(input).unwrap().assets;
assert_eq!(result.len(), 1);
assert_eq!(result[0].kind, AssetKind::S3Object);
assert!(result[0].columns.is_none());
}
#[test]
fn test_sql_asset_parser_s3_read_csv_columns() {
let input = r#"
SELECT name, age FROM read_csv('s3://my-bucket/data.csv');
"#;
let result = parse_assets(input).unwrap().assets;
assert_eq!(result.len(), 1);
assert_eq!(result[0].kind, AssetKind::S3Object);
assert_eq!(result[0].path, "my-bucket/data.csv");
let columns = result[0].columns.as_ref().expect("Should have columns");
assert_eq!(columns.get("name"), Some(&R));
assert_eq!(columns.get("age"), Some(&R));
}
}

View File

@@ -141,7 +141,19 @@ pub fn parse_db_resource(code: &str) -> Option<String> {
cap.map(|x| x.get(1).map(|x| x.as_str().to_string()).unwrap())
}
pub use windmill_types::s3::{s3_mode_extension, S3ModeFormat};
#[derive(Clone, Copy, Debug)]
pub enum S3ModeFormat {
Json,
Csv,
Parquet,
}
pub fn s3_mode_extension(format: S3ModeFormat) -> &'static str {
match format {
S3ModeFormat::Json => "json",
S3ModeFormat::Csv => "csv",
S3ModeFormat::Parquet => "parquet",
}
}
pub struct S3ModeArgs {
pub prefix: Option<String>,
pub storage: Option<String>,

View File

@@ -59,6 +59,3 @@ wasm-bindgen.workspace = true
serde_json.workspace = true
getrandom = { workspace = true, features = ["js"] }
# getrandom 0.3 is pulled in transitively by rand 0.9 (via windmill-types).
# It requires the "wasm_js" feature to work on wasm32-unknown-unknown.
getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }

View File

@@ -108,7 +108,7 @@ use crate::monitor::{
};
#[cfg(feature = "parquet")]
use windmill_object_store::reload_object_store_setting;
use windmill_common::s3_helpers::reload_object_store_setting;
const DEFAULT_NUM_WORKERS: usize = 1;
const DEFAULT_PORT: u16 = 8000;

View File

@@ -39,6 +39,8 @@ use windmill_common::ee_oss::{jobs_waiting_alerts, worker_groups_alerts};
#[cfg(feature = "oauth2")]
use windmill_common::global_settings::OAUTH_SETTING;
#[cfg(feature = "parquet")]
use windmill_common::s3_helpers::reload_object_store_setting;
use windmill_common::{
agent_workers::DECODED_AGENT_TOKEN,
apps::APP_WORKSPACED_ROUTE,
@@ -54,7 +56,7 @@ use windmill_common::{
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,
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,
@@ -82,20 +84,18 @@ use windmill_common::{
OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS,
};
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
#[cfg(feature = "parquet")]
use windmill_object_store::reload_object_store_setting;
use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload};
use windmill_worker::{
result_processor::handle_job_error, JobCompletedSender, JobIsolationLevel,
OtelTracingProxySettings, SameWorkerSender, BUNFIG_INSTALL_SCOPES, CARGO_REGISTRIES,
INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR, JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR,
MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY,
NSJAIL_AVAILABLE, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL,
PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UV_INDEX_STRATEGY,
MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE,
NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL,
POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UV_INDEX_STRATEGY,
};
#[cfg(feature = "parquet")]
use windmill_object_store::ObjectStoreReload;
use windmill_common::s3_helpers::ObjectStoreReload;
#[cfg(feature = "enterprise")]
use crate::ee_oss::verify_license_key;
@@ -330,7 +330,6 @@ pub async fn initial_load(
reload_uv_index_strategy_setting(&conn).await;
reload_npm_config_registry_setting(&conn).await;
reload_bunfig_install_scopes_setting(&conn).await;
reload_npmrc_setting(&conn).await;
reload_instance_python_version_setting(&conn).await;
reload_nuget_config_setting(&conn).await;
reload_powershell_repo_url_setting(&conn).await;
@@ -713,7 +712,7 @@ async fn send_log_file_to_object_store(
}
#[cfg(feature = "parquet")]
let s3_client = windmill_object_store::get_object_store().await;
let s3_client = windmill_common::s3_helpers::get_object_store().await;
#[cfg(feature = "parquet")]
if let Some(s3_client) = s3_client {
let path = std::path::Path::new(TMP_WINDMILL_LOGS_SERVICE)
@@ -726,7 +725,7 @@ async fn send_log_file_to_object_store(
tracing::error!("Error reading log file: {:?}", e);
return;
}
let path = windmill_object_store::object_store_reexports::Path::from_url_path(format!(
let path = object_store::path::Path::from_url_path(format!(
"{}{hostname}/{highest_file}",
windmill_common::tracing_init::LOGS_SERVICE
));
@@ -1005,6 +1004,77 @@ pub async fn delete_expired_items(db: &DB) -> () {
),
}
// Sandbox cleanup: terminate expired sandboxes, stop idle ones, delete ephemeral stopped sandboxes
let sandbox_expired = sqlx::query_scalar!(
"UPDATE sandbox SET status = 'stopped'::sandbox_status, stopped_at = now()
WHERE status IN ('running', 'suspended', 'creating')
AND expires_at IS NOT NULL AND expires_at <= now()
RETURNING id"
)
.fetch_all(db)
.await;
match sandbox_expired {
Ok(ids) if !ids.is_empty() => {
tracing::info!("stopped {} expired sandboxes", ids.len());
}
Err(e) => tracing::error!("Error stopping expired sandboxes: {e}"),
_ => {}
}
let sandbox_idle_stopped = sqlx::query_scalar!(
"UPDATE sandbox SET status = 'stopped'::sandbox_status, stopped_at = now()
WHERE status = 'running'
AND idle_timeout_secs IS NOT NULL
AND last_activity_at IS NOT NULL
AND last_activity_at + (idle_timeout_secs::text || ' seconds')::interval <= now()
RETURNING id"
)
.fetch_all(db)
.await;
match sandbox_idle_stopped {
Ok(ids) if !ids.is_empty() => {
tracing::info!("stopped {} idle sandboxes", ids.len());
}
Err(e) => tracing::error!("Error stopping idle sandboxes: {e}"),
_ => {}
}
let sandbox_ephemeral_deleted = sqlx::query_scalar!(
"DELETE FROM sandbox WHERE ephemeral = true AND status IN ('stopped', 'error')
RETURNING id"
)
.fetch_all(db)
.await;
match sandbox_ephemeral_deleted {
Ok(ids) if !ids.is_empty() => {
tracing::info!("deleted {} ephemeral stopped sandboxes", ids.len());
}
Err(e) => tracing::error!("Error deleting ephemeral sandboxes: {e}"),
_ => {}
}
// Mark sandboxes on dead hosts as errored
let sandbox_orphaned = sqlx::query_scalar!(
"UPDATE sandbox SET status = 'error'::sandbox_status,
error_message = 'Sandbox host became unreachable'
WHERE status IN ('running', 'suspended')
AND mode = 'remote'
AND host_id IS NOT NULL
AND host_id NOT IN (
SELECT id FROM sandbox_host WHERE last_ping > now() - interval '2 minutes'
)
RETURNING id"
)
.fetch_all(db)
.await;
match sandbox_orphaned {
Ok(ids) if !ids.is_empty() => {
tracing::info!("marked {} orphaned sandboxes as error", ids.len());
}
Err(e) => tracing::error!("Error marking orphaned sandboxes: {e}"),
_ => {}
}
let job_retention_secs = *JOB_RETENTION_SECS.read().await;
if job_retention_secs > 0 {
let batch_size = *JOB_CLEANUP_BATCH_SIZE;
@@ -1175,7 +1245,7 @@ async fn delete_log_files_from_disk_and_store(
_s3_prefix: &str,
) {
#[cfg(feature = "parquet")]
let os = windmill_object_store::get_object_store().await;
let os = windmill_common::s3_helpers::get_object_store().await;
#[cfg(not(feature = "parquet"))]
let os: Option<()> = None;
@@ -1205,10 +1275,7 @@ async fn delete_log_files_from_disk_and_store(
#[cfg(feature = "parquet")]
if _should_del_from_store {
if let Some(os) = _os2 {
let p = windmill_object_store::object_store_reexports::Path::from(format!(
"{}{}",
_s3_prefix, path
));
let p = object_store::path::Path::from(format!("{}{}", _s3_prefix, path));
if let Err(e) = os.delete(&p).await {
tracing::error!("Failed to delete from object store {}: {e}", p.to_string())
} else {
@@ -1307,10 +1374,6 @@ pub async fn reload_bunfig_install_scopes_setting(conn: &Connection) {
.await;
}
pub async fn reload_npmrc_setting(conn: &Connection) {
reload_option_setting_with_tracing(conn, NPMRC_SETTING, "NPMRC", NPMRC.clone()).await;
}
pub async fn reload_nuget_config_setting(conn: &Connection) {
reload_option_setting_with_tracing(
conn,
@@ -2349,7 +2412,7 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> {
async fn stale_job_cancellation(db: &Pool<Postgres>) {
if let Some(threshold) = *STALE_JOB_THRESHOLD_MINUTES {
let stale_jobs = sqlx::query!(
"SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval AND v2_job.trigger_kind IS DISTINCT FROM 'schedule'::job_trigger_kind",
"SELECT v2_job_queue.id, v2_job.tag, v2_job_queue.scheduled_for, v2_job_queue.workspace_id FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id WHERE running = false AND scheduled_for < now() - ($1 || ' minutes')::interval",
threshold.to_string()
)
.fetch_all(db)

View File

@@ -7,7 +7,6 @@ REVERT="NO"
COPY="NO"
MOVE_NEW_FILES="NO"
EE_CODE_DIR="../windmill-ee-private/"
DIR_EXPLICIT="NO"
while [[ $# -gt 0 ]]; do
case $1 in
@@ -35,7 +34,6 @@ while [[ $# -gt 0 ]]; do
# Path to the local directory of the windmill-ee-private repository. By defaults, it
# assumes it is cloned next to the Windmill OSS repo.
EE_CODE_DIR="$2"
DIR_EXPLICIT="YES"
shift # past argument
shift # past value
;;
@@ -55,26 +53,9 @@ if [[ $EE_CODE_DIR == /* ]]; then
else
EE_CODE_DIR="${root_dirpath}/${EE_CODE_DIR}"
fi
# Fallback to ~/windmill-ee-private if the default location doesn't exist
if [ ! -d "${EE_CODE_DIR}" ]; then
EE_CODE_DIR="${HOME}/windmill-ee-private"
fi
# Unless --dir was explicitly set, try to find an EE worktree on the same branch
if [ "$DIR_EXPLICIT" == "NO" ] && [ -d "${HOME}/windmill-ee-private" ]; then
current_branch=$(git -C "${root_dirpath}" branch --show-current 2>/dev/null || true)
if [ -n "$current_branch" ]; then
ee_worktree=$(git -C "${HOME}/windmill-ee-private" worktree list 2>/dev/null \
| awk -v branch="[${current_branch}]" '$NF == branch {print $1; exit}')
if [ -n "$ee_worktree" ] && [ -d "$ee_worktree" ]; then
EE_CODE_DIR="$ee_worktree"
fi
fi
fi
echo "EE code directory = ${EE_CODE_DIR} | Revert = ${REVERT}"
if [ ! -d "${EE_CODE_DIR}" ]; then
echo "Windmill EE repo not found, please clone it next to this repository (or use the --dir option) and try again"
echo "> git clone git@github.com:windmill-labs/windmill-ee-private.git"

View File

@@ -1,474 +0,0 @@
#!/usr/bin/env bash
# End-to-end debounce tests against the running backend API
# Usage: BACKEND_PORT=8030 ./test_debounce_e2e.sh
set -uo pipefail
BASE="http://localhost:${BACKEND_PORT:-8030}/api"
W="admins"
EMAIL="admin@windmill.dev"
PASSWORD="changeme"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
pass=0
fail=0
log_pass() { echo -e "${GREEN}PASS${NC}: $1"; ((pass++)) || true; }
log_fail() { echo -e "${RED}FAIL${NC}: $1$2"; ((fail++)) || true; }
log_info() { echo -e "${YELLOW}INFO${NC}: $1"; }
# Unique suffix for idempotent re-runs
TS=$(date +%s)
# --- Auth ---
log_info "Logging in..."
TOKEN=$(curl -s "$BASE/auth/login" \
-H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}")
if [ -z "$TOKEN" ]; then
echo "Failed to login"; exit 1
fi
AUTH="Authorization: Bearer $TOKEN"
log_info "Logged in"
# --- Helpers ---
api() {
# Usage: api METHOD path [data]
local method="$1" path="$2" data="${3:-}"
if [ -n "$data" ]; then
curl -s "$BASE/w/$W/$path" -X "$method" -H "$AUTH" -H 'Content-Type: application/json' -d "$data"
else
curl -s "$BASE/w/$W/$path" -X "$method" -H "$AUTH"
fi
}
wait_job() {
local job_id="$1" max_wait="${2:-30}"
for _ in $(seq 1 "$max_wait"); do
local r
r=$(api GET "jobs/completed/get_result_maybe/$job_id")
if echo "$r" | jq -e '.completed == true' > /dev/null 2>&1; then
echo "$r"; return 0
fi
sleep 1
done
echo '{"completed":false,"error":"timeout"}'; return 1
}
BUN_EMPTY_LOCK=$'{"dependencies": {}}\n//bun.lock\n'
create_script() {
# Usage: create_script path language content [extra_json_fields]
# Note: lock must be non-empty; empty string ("") is treated as None by the backend
# (scripts.rs:798-800), which triggers dependency resolution instead of direct deployment.
# For bun scripts, the lock must contain "//bun.lock" as a split pattern.
local path="$1" lang="$2" content="$3" extra="${4:-}"
local json
json=$(jq -n \
--arg path "$path" \
--arg lang "$lang" \
--arg content "$content" \
--arg summary "test" \
--arg desc "test" \
--arg lock "$BUN_EMPTY_LOCK" \
'{path: $path, language: $lang, content: $content, summary: $summary, description: $desc, lock: $lock}')
if [ -n "$extra" ]; then
json=$(echo "$json" | jq ". + $extra")
fi
local hash
hash=$(api POST "scripts/create" "$json")
# Small delay for DB visibility after tx commit
sleep 0.2
echo "$hash"
}
run_script() {
# Usage: run_script path args_json
api POST "jobs/run/p/$1" "$2"
}
###############################################################################
# TEST 1: Deploy a script and run it 5 times in close succession
###############################################################################
echo ""
log_info "=== TEST 1: Deploy & run script 5 times rapidly ==="
P1="u/admin/e2e_simple_$TS"
H1=$(create_script "$P1" "bun" 'export function main(x: number = 0) { return { result: x * 2 }; }')
if echo "$H1" | grep -qE '^[0-9a-f]{16}$'; then
log_pass "Script created: $H1"
else
log_fail "Script creation" "$H1"
fi
log_info "Running 5 times rapidly..."
JOB_IDS=()
for i in $(seq 1 5); do
JID=$(run_script "$P1" "{\"x\": $i}")
JOB_IDS+=("$JID")
done
log_info "Jobs: ${JOB_IDS[*]}"
log_info "Waiting for completion..."
all_ok=true
for i in "${!JOB_IDS[@]}"; do
JID="${JOB_IDS[$i]}"
R=$(wait_job "$JID" 30)
success=$(echo "$R" | jq -r '.success // false')
value=$(echo "$R" | jq -r '.result.result // "null"')
expected=$(( (i + 1) * 2 ))
if [ "$success" = "true" ] && [ "$value" = "$expected" ]; then
log_pass "Job $((i+1)): x=$((i+1))$value (correct)"
else
log_fail "Job $((i+1))" "success=$success value=$value expected=$expected"
all_ok=false
fi
done
if [ "$all_ok" = "true" ]; then
log_pass "All 5 runs completed correctly (no debounce — different args)"
fi
###############################################################################
# TEST 2: Redeploy script WITHOUT lock in close succession
###############################################################################
echo ""
log_info "=== TEST 2: Redeploy without lock in rapid succession ==="
P2="u/admin/e2e_nolock_$TS"
# Deploy 5 versions of the same script without lock → triggers dependency jobs
DEPLOY_HASHES=()
for i in $(seq 1 5); do
content="export function main(x: number = 0) { return { result: x * $i, version: $i }; }"
parent_extra=""
if [ "${#DEPLOY_HASHES[@]}" -gt 0 ]; then
last_hash="${DEPLOY_HASHES[-1]}"
parent_extra="{\"parent_hash\": \"$last_hash\"}"
fi
# Deploy without lock (omit lock field entirely)
json=$(jq -n \
--arg path "$P2" \
--arg content "$content" \
--arg summary "v$i" \
--arg desc "test" \
'{path: $path, language: "bun", content: $content, summary: $summary, description: $desc}')
if [ -n "$parent_extra" ]; then
json=$(echo "$json" | jq ". + $parent_extra")
fi
hash=$(api POST "scripts/create" "$json")
if echo "$hash" | grep -qE '^[0-9a-f]{16}$'; then
DEPLOY_HASHES+=("$hash")
log_info "Deploy $i: $hash"
else
log_fail "Deploy $i" "$hash"
# If path conflict, the script already exists from a previous version
break
fi
sleep 0.1
done
# Wait for dependency resolution
log_info "Waiting 15s for dependency jobs..."
sleep 15
# Check the latest script — should have lock resolved
SCRIPT_INFO=$(api GET "scripts/get/p/$P2")
LOCK=$(echo "$SCRIPT_INFO" | jq -r '.lock // "null"')
if [ "$LOCK" != "null" ] && [ -n "$LOCK" ]; then
log_pass "Latest version has lock resolved"
else
log_info "Lock not yet resolved: $LOCK"
fi
# Run the latest version to verify it works
sleep 0.5
JID2=$(run_script "$P2" '{"x": 10}')
if echo "$JID2" | grep -qE '^[0-9a-f-]{36}$'; then
R2=$(wait_job "$JID2" 30)
success=$(echo "$R2" | jq -r '.success // false')
if [ "$success" = "true" ]; then
version=$(echo "$R2" | jq -r '.result.version // "?"')
log_pass "Latest version runs: version=$version"
else
err=$(echo "$R2" | jq -r '.result.error.message // "unknown"' 2>/dev/null)
log_fail "Run latest version" "success=false err=$err"
fi
else
log_fail "Run latest version" "bad job id: $JID2"
fi
###############################################################################
# TEST 3: Script with debounce_delay_s — rapid runs with SAME args
###############################################################################
echo ""
log_info "=== TEST 3: Debounce with same args (should debounce) ==="
P3="u/admin/e2e_debounce_$TS"
H3=$(create_script "$P3" "bun" \
'export function main(x: number = 0) { return { result: x }; }' \
'{"debounce_delay_s": 3}')
if echo "$H3" | grep -qE '^[0-9a-f]{16}$'; then
log_pass "Debounce script created: $H3"
else
log_fail "Debounce script creation" "$H3"
fi
log_info "Running 5 times with same args {x: 42}..."
DEB_IDS=()
for i in $(seq 1 5); do
JID=$(run_script "$P3" '{"x": 42}')
DEB_IDS+=("$JID")
log_info " Run $i: $JID"
done
log_info "Waiting 10s for debounce delay (3s) + execution..."
sleep 10
executed=0
skipped=0
for JID in "${DEB_IDS[@]}"; do
if ! echo "$JID" | grep -qE '^[0-9a-f-]{36}$'; then
log_info " Invalid job id: $JID"
continue
fi
R=$(wait_job "$JID" 5 2>/dev/null || echo '{"completed":false}')
completed=$(echo "$R" | jq -r '.completed // false')
success=$(echo "$R" | jq -r '.success // false')
if [ "$completed" = "true" ] && [ "$success" = "true" ]; then
((executed++)) || true
elif [ "$completed" = "true" ]; then
((skipped++)) || true
fi
done
log_info "Results: $executed executed, $skipped skipped out of ${#DEB_IDS[@]}"
if [ "$executed" -eq 1 ] && [ "$skipped" -ge 3 ]; then
log_pass "Debouncing perfect: 1 executed, $skipped skipped"
elif [ "$executed" -le 2 ] && [ "$skipped" -ge 2 ]; then
log_pass "Debouncing working: $executed executed, $skipped skipped"
else
log_fail "Debounce same args" "executed=$executed skipped=$skipped (want ~1 exec, ~4 skip)"
fi
###############################################################################
# TEST 3b: Different args should NOT debounce against each other
###############################################################################
echo ""
log_info "=== TEST 3b: Debounce with different args (should NOT debounce) ==="
DIFF_IDS=()
for i in $(seq 1 3); do
JID=$(run_script "$P3" "{\"x\": $((i * 100))}")
DIFF_IDS+=("$JID")
done
log_info "Waiting 8s..."
sleep 8
diff_exec=0
for JID in "${DIFF_IDS[@]}"; do
if ! echo "$JID" | grep -qE '^[0-9a-f-]{36}$'; then continue; fi
R=$(wait_job "$JID" 5 2>/dev/null || echo '{"completed":false}')
success=$(echo "$R" | jq -r '.success // false')
if [ "$success" = "true" ]; then ((diff_exec++)) || true; fi
done
if [ "$diff_exec" -eq 3 ]; then
log_pass "Different args: all 3 executed independently"
else
log_fail "Different args" "only $diff_exec/3 executed"
fi
###############################################################################
# TEST 4: Custom debounce_key with $args interpolation
###############################################################################
echo ""
log_info "=== TEST 4: Custom debounce key ==="
P4="u/admin/e2e_custom_key_$TS"
H4=$(create_script "$P4" "bun" \
'export function main(event_id: string = "", data: string = "") { return { event_id, data }; }' \
'{"debounce_delay_s": 3, "debounce_key": "event#$args.event_id"}')
if echo "$H4" | grep -qE '^[0-9a-f]{16}$'; then
log_pass "Custom key script created: $H4"
else
log_fail "Custom key script creation" "$H4"
fi
# Same event_id → should debounce
log_info "3 runs with same event_id..."
SAME_IDS=()
for i in $(seq 1 3); do
JID=$(run_script "$P4" "{\"event_id\": \"evt_001\", \"data\": \"payload_$i\"}")
SAME_IDS+=("$JID")
done
# Different event_id → should NOT debounce
JID_DIFF=$(run_script "$P4" '{"event_id": "evt_002", "data": "different"}')
log_info "Waiting 8s..."
sleep 8
same_exec=0
same_skip=0
for JID in "${SAME_IDS[@]}"; do
if ! echo "$JID" | grep -qE '^[0-9a-f-]{36}$'; then continue; fi
R=$(wait_job "$JID" 5 2>/dev/null || echo '{"completed":false}')
completed=$(echo "$R" | jq -r '.completed // false')
success=$(echo "$R" | jq -r '.success // false')
if [ "$completed" = "true" ] && [ "$success" = "true" ]; then
data=$(echo "$R" | jq -r '.result.data // "?"')
((same_exec++)) || true
log_info " Executed: data=$data"
elif [ "$completed" = "true" ]; then
((same_skip++)) || true
fi
done
log_info "Same event_id: $same_exec executed, $same_skip skipped"
if [ "$same_exec" -eq 1 ] && [ "$same_skip" -ge 1 ]; then
log_pass "Custom key debounce: same event_id debounced correctly"
elif [ "$same_exec" -le 2 ]; then
log_pass "Custom key debounce working: $same_exec executed, $same_skip skipped"
else
log_fail "Custom key debounce" "exec=$same_exec skip=$same_skip"
fi
# Check different event_id ran independently
if echo "$JID_DIFF" | grep -qE '^[0-9a-f-]{36}$'; then
R_DIFF=$(wait_job "$JID_DIFF" 10 2>/dev/null || echo '{"completed":false}')
diff_success=$(echo "$R_DIFF" | jq -r '.success // false')
if [ "$diff_success" = "true" ]; then
log_pass "Different event_id: executed independently"
else
log_info "Different event_id: success=$diff_success"
fi
fi
###############################################################################
# TEST 5: Git sync with bad target — debounced deployment callbacks
###############################################################################
echo ""
log_info "=== TEST 5: Git sync debounce + aggregation ==="
# Create git repo resource
api POST "resources/create?update_if_exists=true" '{
"path": "u/admin/e2e_bad_git_repo",
"description": "Bad git repo for testing",
"resource_type": "git_repository",
"value": {"url": "https://github.com/nonexistent/nope.git", "branch": "main", "token": "bad"}
}' > /dev/null 2>&1
log_info "Created git repo resource"
# Create a sync script at a folder path where the 2nd segment is a number >= 28103.
# is_script_meets_min_version parses split("/").skip(1).next() as the version number.
# This enables debounce_delay_s=5 and debounce_args_to_accumulate=["items"].
api POST "folders/create" '{"name": "28103"}' > /dev/null 2>&1
P5="f/28103/e2e_sync_$TS"
H5=$(create_script "$P5" "bun" \
'export function main(repo_url_resource_path: string = "", workspace_id: string = "", items: any[] = [], use_individual_branch: boolean = false, group_by_folder: boolean = false, parent_workspace_id: string = "") { return { synced: items.length, items }; }')
if echo "$H5" | grep -qE '^[0-9a-f]{16}$'; then
log_pass "Sync script created: $H5"
else
log_fail "Sync script creation" "$H5"
fi
# Configure git sync with include_path to match deployed scripts.
# Without include_path, path_matches_filters returns false and no DeploymentCallback is created.
api POST "workspaces/edit_git_sync_config" "{
\"git_sync_settings\": {
\"include_type\": [\"script\"],
\"include_path\": [\"**\"],
\"repositories\": [{
\"script_path\": \"$P5\",
\"git_repo_resource_path\": \"\$res:u/admin/e2e_bad_git_repo\",
\"use_individual_branch\": false,
\"group_by_folder\": false
}]
}
}" > /dev/null 2>&1
log_pass "Git sync configured with include_path and versioned folder script path"
# Deploy 5 scripts rapidly to trigger git sync.
# Scripts are created with lock="" (via create_script), so handle_deployment_metadata
# fires immediately after tx commit (not after dependency resolution).
log_info "Deploying 5 scripts to trigger git sync..."
for i in $(seq 1 5); do
dp="u/admin/e2e_gitsync_${TS}_$i"
create_script "$dp" "bun" "export function main() { return { v: $i }; }" > /dev/null
log_info " Deployed $dp"
done
# Wait for debounce delay (5s) + execution
log_info "Waiting 15s for debounce (5s) + execution..."
sleep 15
# Check deployment callback jobs for our sync script.
# Debounced jobs have is_skipped=true (but success=true), so we use is_skipped to distinguish.
SYNC_JOBS=$(api GET "jobs/completed/list?script_path_exact=$P5&job_kinds=deploymentcallback")
SYNC_TOTAL=$(echo "$SYNC_JOBS" | jq 'length')
SYNC_EXECUTED=$(echo "$SYNC_JOBS" | jq '[.[] | select(.is_skipped != true)] | length')
SYNC_SKIPPED=$(echo "$SYNC_JOBS" | jq '[.[] | select(.is_skipped == true)] | length')
log_info "Sync jobs: total=$SYNC_TOTAL executed=$SYNC_EXECUTED skipped=$SYNC_SKIPPED"
if [ "$SYNC_TOTAL" -gt 0 ]; then
# With debouncing (5s delay), rapid deploys should be consolidated.
# All 5 jobs are created but most should be skipped (debounced).
if [ "$SYNC_SKIPPED" -gt 0 ]; then
log_pass "Git sync debouncing: $SYNC_EXECUTED executed, $SYNC_SKIPPED debounced out of $SYNC_TOTAL"
else
log_fail "Git sync debouncing" "No jobs were debounced ($SYNC_TOTAL all executed independently)"
fi
# Check if items were aggregated in the executed (non-skipped) job(s)
for idx in $(seq 0 $((SYNC_TOTAL - 1))); do
is_skipped=$(echo "$SYNC_JOBS" | jq -r ".[$idx].is_skipped")
[ "$is_skipped" = "true" ] && continue
jid=$(echo "$SYNC_JOBS" | jq -r ".[$idx].id")
r=$(api GET "jobs/completed/get_result/$jid")
items_count=$(echo "$r" | jq '.items | length // 0')
log_info " Executed sync job $jid: items=$items_count"
if [ "$items_count" -gt 1 ]; then
log_pass "Items aggregated: $items_count items in single sync job"
fi
done
else
# Check queued — jobs may still be pending debounce delay
Q=$(api GET "jobs/queue/list?script_path_exact=$P5&job_kinds=deploymentcallback")
QC=$(echo "$Q" | jq 'length')
log_info "No completed sync jobs. $QC queued."
if [ "$QC" -gt 0 ] && [ "$QC" -lt 5 ]; then
log_pass "Git sync debouncing (queued): $QC jobs for 5 deploys"
elif [ "$QC" -eq 0 ]; then
log_fail "Git sync" "No deployment callback jobs found (completed or queued)"
fi
fi
# Cleanup git sync
api POST "workspaces/edit_git_sync_config" '{"git_sync_settings": null}' > /dev/null 2>&1
log_info "Git sync config cleared"
###############################################################################
# Summary
###############################################################################
echo ""
echo "========================================="
echo -e "Results: ${GREEN}$pass passed${NC}, ${RED}$fail failed${NC}"
echo "========================================="
if [ "$fail" -gt 0 ]; then
exit 1
fi

View File

@@ -1,8 +1,8 @@
use windmill_test_utils::*;
use sqlx::postgres::Postgres;
use sqlx::Pool;
use windmill_common::jobs::{JobPayload, RawCode};
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
// ============================================================================
// Basic Execution Tests
@@ -27,8 +27,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -63,8 +63,8 @@ export function main(name: string, count: number) {
path: None,
language: ScriptLang::Bun,
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,
@@ -104,9 +104,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -136,9 +135,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -169,9 +167,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -210,8 +207,8 @@ export async function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -248,9 +245,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -280,9 +276,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -323,8 +318,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -363,8 +358,8 @@ export function notMain() {
path: None,
language: ScriptLang::Bun,
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,
@@ -403,8 +398,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -442,8 +437,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -479,8 +474,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -521,8 +516,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -618,9 +613,8 @@ export function main() {
path: Some("f/nested/test_deep".to_string()),
language: ScriptLang::Bun,
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,
@@ -653,9 +647,8 @@ export function main() {
path: Some("f/nested/test_deep_relative".to_string()),
language: ScriptLang::Bun,
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,
@@ -700,8 +693,8 @@ export function main() {
path: Some("f/circular/test_both".to_string()),
language: ScriptLang::Bun,
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,
@@ -748,8 +741,8 @@ export function main(x: number) {
path: None,
language: ScriptLang::Bun,
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,
@@ -798,8 +791,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -843,8 +836,8 @@ export function main() {
path: None,
language: ScriptLang::Bun,
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,
@@ -866,11 +859,11 @@ export function main() {
// ============================================================================
mod dedicated_worker_protocol {
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
use windmill_worker::{
build_loader, generate_dedicated_worker_wrapper, LoaderMode, BUN_DEDICATED_WORKER_ARGS,
build_loader, generate_dedicated_worker_wrapper, BUN_DEDICATED_WORKER_ARGS, LoaderMode,
BUN_PATH, NODE_BIN_PATH,
};
@@ -941,8 +934,12 @@ mod dedicated_worker_protocol {
let temp_dir = tempfile::tempdir().unwrap();
// Create files and get the wrapper path (bundled for node, raw for bun)
let wrapper_path =
create_test_worker_files(temp_dir.path(), script, arg_names, runtime == "node");
let wrapper_path = create_test_worker_files(
temp_dir.path(),
script,
arg_names,
runtime == "node",
);
let wrapper_str = wrapper_path.to_str().unwrap();
// Build args matching production behavior
@@ -995,10 +992,7 @@ mod dedicated_worker_protocol {
match parse_dedicated_worker_line(response.trim()) {
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
DedicatedWorkerResult::Error(err) => {
let msg = err["message"]
.as_str()
.unwrap_or("Unknown error")
.to_string();
let msg = err["message"].as_str().unwrap_or("Unknown error").to_string();
results.push(Err(msg));
}
other => panic!("Unexpected response: {:?}", other),
@@ -1168,8 +1162,8 @@ export function main(name: string) {
path: None,
language: ScriptLang::Bun,
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,
@@ -1196,68 +1190,6 @@ export function main(name: string) {
Ok(())
}
/// Test that full .npmrc content works for bun jobs with private registries.
/// Requires:
/// - `TEST_NPMRC` environment variable set to the full .npmrc content
#[cfg(feature = "private_registry_test")]
#[sqlx::test(fixtures("base"))]
async fn test_bun_job_private_npmrc(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_worker::NPMRC;
let npmrc_content = std::env::var("TEST_NPMRC")
.expect("TEST_NPMRC must be set when running private_registry_test");
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
{
let mut npmrc = NPMRC.write().await;
*npmrc = Some(npmrc_content.clone());
}
let content = r#"
import { greet } from "@windmill-test/private-pkg";
export function main(name: string) {
return greet(name);
}
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Bun,
lock: None,
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,
dedicated_worker: None,
});
let result = RunJob::from(job)
.arg("name", serde_json::json!("World"))
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
{
let mut npmrc = NPMRC.write().await;
*npmrc = None;
}
assert_eq!(
result,
serde_json::json!("Hello from private package, World!")
);
Ok(())
}
/// Tests for RELATIVE_BUN_BUILDER (loader_builder.bun.js)
/// These tests verify Bun's behavior for import scanning and package.json generation.
/// Purpose: Catch regressions when upgrading Bun versions.
@@ -1309,8 +1241,8 @@ mod bun_builder_tests {
}
// Read generated package.json
let package_json =
std::fs::read_to_string(dir.join("package.json")).expect("package.json not generated");
let package_json = std::fs::read_to_string(dir.join("package.json"))
.expect("package.json not generated");
serde_json::from_str(&package_json).expect("Invalid JSON in package.json")
}
@@ -1325,10 +1257,7 @@ export function main() { return lodash; }
let pkg = run_builder(main_ts);
let deps = pkg["dependencies"].as_object().unwrap();
assert!(
deps.contains_key("lodash"),
"lodash should be in dependencies"
);
assert!(deps.contains_key("lodash"), "lodash should be in dependencies");
assert_eq!(deps["lodash"], "latest");
}
@@ -1342,10 +1271,7 @@ export function main() { return _; }
let pkg = run_builder(main_ts);
let deps = pkg["dependencies"].as_object().unwrap();
assert!(
deps.contains_key("lodash"),
"lodash should be in dependencies"
);
assert!(deps.contains_key("lodash"), "lodash should be in dependencies");
assert_eq!(deps["lodash"], "4.17.21");
}
@@ -1378,18 +1304,9 @@ export function main() { return { lodash, axios, dayjs }; }
let pkg = run_builder(main_ts);
let deps = pkg["dependencies"].as_object().unwrap();
assert!(
deps.contains_key("lodash"),
"lodash should be in dependencies"
);
assert!(
deps.contains_key("axios"),
"axios should be in dependencies"
);
assert!(
deps.contains_key("dayjs"),
"dayjs should be in dependencies"
);
assert!(deps.contains_key("lodash"), "lodash should be in dependencies");
assert!(deps.contains_key("axios"), "axios should be in dependencies");
assert!(deps.contains_key("dayjs"), "dayjs should be in dependencies");
assert_eq!(deps.len(), 3, "Should have exactly 3 dependencies");
}
@@ -1413,15 +1330,8 @@ export function main() { return { fs, path, lodash }; }
!deps.contains_key("path"),
"path (builtin) should NOT be in dependencies"
);
assert!(
deps.contains_key("lodash"),
"lodash should be in dependencies"
);
assert_eq!(
deps.len(),
1,
"Should have exactly 1 dependency (lodash only)"
);
assert!(deps.contains_key("lodash"), "lodash should be in dependencies");
assert_eq!(deps.len(), 1, "Should have exactly 1 dependency (lodash only)");
}
/// Test: semver.order() resolves version conflicts (picks lowest version)
@@ -1437,10 +1347,7 @@ export function main() { return { a, b }; }
let pkg = run_builder(main_ts);
let deps = pkg["dependencies"].as_object().unwrap();
assert!(
deps.contains_key("lodash"),
"lodash should be in dependencies"
);
assert!(deps.contains_key("lodash"), "lodash should be in dependencies");
// The builder sorts by semver and picks the first (lowest) version
assert_eq!(
deps["lodash"], "4.17.10",

View File

@@ -1,10 +0,0 @@
-- Fixture for testing wmill CLI variable/resource get from bash scripts
INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms)
VALUES ('test-workspace', 'u/test-user/test_var', 'hello from variable', false, 'A test variable', '{"u/test-user": true}');
INSERT INTO resource_type (workspace_id, name, schema, description, created_by)
VALUES ('test-workspace', 'test_object', '{}', 'Test object type', 'test-user');
INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by)
VALUES ('test-workspace', 'u/test-user/test_res', '{"host": "localhost", "port": 5432}', 'A test resource', 'test_object', '{"u/test-user": true}', 'test-user');

View File

@@ -1,275 +0,0 @@
/*
* Tests for the PrewarmedIsolate used by nativets dedicated workers.
*
* Run with:
* cargo test -p windmill --features "deno_core" --test nativets_dedicated -- --nocapture
*/
#[cfg(feature = "deno_core")]
mod prewarmed_isolate_tests {
use std::process::Command;
use windmill_runtime_nativets::{NativeAnnotation, PrewarmedIsolate};
use windmill_worker::{build_loader, LoaderMode, BUN_PATH};
fn default_annotation() -> NativeAnnotation {
NativeAnnotation { useragent: None, proxy: None }
}
/// Bundle a TypeScript script into JS suitable for `PrewarmedIsolate`.
///
/// Returns `(ts_source, js_bundle, arg_names)`.
async fn bundle_script(script: &str) -> (String, String, Vec<String>) {
let temp_dir = tempfile::tempdir().unwrap();
let dir = temp_dir.path();
let dir_str = dir.to_str().unwrap();
std::fs::write(dir.join("main.ts"), script).unwrap();
build_loader(
dir_str,
"http://localhost:8000",
"test_token",
"test-workspace",
"f/test/script",
LoaderMode::BrowserBundle,
)
.await
.expect("build_loader failed");
let output = Command::new(BUN_PATH.as_str())
.args(["run", dir.join("node_builder.ts").to_str().unwrap()])
.current_dir(dir)
.output()
.expect("Failed to run bun build");
if !output.status.success() {
panic!(
"Bun build failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let ts = std::fs::read_to_string(dir.join("main.ts")).unwrap();
let js = std::fs::read_to_string(dir.join("main.js")).unwrap();
let parsed = windmill_parser_ts::parse_deno_signature(&ts, true, false, None)
.expect("failed to parse signature");
let arg_names: Vec<String> = parsed.args.into_iter().map(|a| a.name).collect();
(ts, js, arg_names)
}
const TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
async fn run_prewarmed_test(
script: &str,
jobs: Vec<serde_json::Value>,
) -> Vec<Result<serde_json::Value, String>> {
tokio::time::timeout(TEST_TIMEOUT, run_prewarmed_test_inner(script, jobs))
.await
.expect("test timed out after 30s")
}
async fn run_prewarmed_test_inner(
script: &str,
jobs: Vec<serde_json::Value>,
) -> Vec<Result<serde_json::Value, String>> {
windmill_runtime_nativets::setup_deno_runtime().expect("V8 init failed");
let (_ts, js, arg_names) = bundle_script(script).await;
let ann = default_annotation();
let mut results = Vec::new();
for job_args in &jobs {
let mut isolate =
PrewarmedIsolate::spawn("".to_string(), js.clone(), ann.clone(), arg_names.clone());
isolate.wait_ready().await.expect("isolate failed to warm");
let args = serde_json::to_string(job_args).unwrap();
let executing = isolate.start_execution(args);
let prewarmed_result = executing.wait().await.expect("isolate execution failed");
match prewarmed_result.result {
Ok(raw) => {
let value: serde_json::Value =
serde_json::from_str(raw.get()).unwrap_or(serde_json::Value::Null);
results.push(Ok(value));
}
Err(e) => {
results.push(Err(e));
}
}
}
results
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_simple() {
let script = r#"
export function main(x: number, y: number): number {
return x + y;
}
"#;
let results = run_prewarmed_test(script, vec![serde_json::json!({"x": 2, "y": 3})]).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(5)));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_isolation() {
let script = r#"
let counter = 0;
export function main(): number {
counter++;
return counter;
}
"#;
let results =
run_prewarmed_test(script, vec![serde_json::json!({}), serde_json::json!({})]).await;
assert_eq!(results.len(), 2);
// Each job gets a fresh isolate, so counter should be 1 both times
assert_eq!(results[0], Ok(serde_json::json!(1)));
assert_eq!(results[1], Ok(serde_json::json!(1)));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_error() {
let script = r#"
export function main(msg: string): never {
throw new Error(msg);
}
"#;
let results =
run_prewarmed_test(script, vec![serde_json::json!({"msg": "test error"})]).await;
assert_eq!(results.len(), 1);
assert!(results[0].is_err());
assert!(
results[0].as_ref().unwrap_err().contains("test error"),
"Error should contain 'test error', got: {}",
results[0].as_ref().unwrap_err()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_async() {
let script = r#"
export async function main(x: number): Promise<number> {
const val = await Promise.resolve(x * 10);
return val + 1;
}
"#;
let results = run_prewarmed_test(script, vec![serde_json::json!({"x": 7})]).await;
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(71)));
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_pipeline() {
windmill_runtime_nativets::setup_deno_runtime().expect("V8 init failed");
let script = r#"
export function main(n: number): number {
return n * 2;
}
"#;
let (_ts, js, arg_names) = bundle_script(script).await;
let ann = default_annotation();
// Pre-warm first isolate
let mut warm =
PrewarmedIsolate::spawn("".to_string(), js.clone(), ann.clone(), arg_names.clone());
warm.wait_ready()
.await
.expect("first isolate failed to warm");
let mut results = Vec::new();
for i in 1..=3 {
let args = serde_json::to_string(&serde_json::json!({"n": i})).unwrap();
let executing = warm.start_execution(args);
// Pipeline: start pre-warming next isolate while current one runs
warm =
PrewarmedIsolate::spawn("".to_string(), js.clone(), ann.clone(), arg_names.clone());
let prewarmed_result = executing.wait().await.expect("isolate execution failed");
match prewarmed_result.result {
Ok(raw) => {
let value: serde_json::Value =
serde_json::from_str(raw.get()).unwrap_or(serde_json::Value::Null);
results.push(value);
}
Err(e) => panic!("unexpected error: {e}"),
}
warm.wait_ready()
.await
.expect("next isolate failed to warm");
}
assert_eq!(
results,
vec![
serde_json::json!(2),
serde_json::json!(4),
serde_json::json!(6),
]
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_complex_return() {
let script = r#"
export function main(name: string, items: number[]): any {
return {
greeting: `hello ${name}`,
sum: items.reduce((a, b) => a + b, 0),
items: items.map(x => x * 2),
};
}
"#;
let results = run_prewarmed_test(
script,
vec![serde_json::json!({"name": "world", "items": [1, 2, 3]})],
)
.await;
assert_eq!(results.len(), 1);
assert_eq!(
results[0],
Ok(serde_json::json!({
"greeting": "hello world",
"sum": 6,
"items": [2, 4, 6],
}))
);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_prewarmed_null_undefined() {
let script = r#"
export function main(returnNull: boolean): any {
if (returnNull) {
return null;
}
return undefined;
}
"#;
let results = run_prewarmed_test(
script,
vec![
serde_json::json!({"returnNull": true}),
serde_json::json!({"returnNull": false}),
],
)
.await;
assert_eq!(results.len(), 2);
assert_eq!(results[0], Ok(serde_json::Value::Null));
assert_eq!(results[1], Ok(serde_json::Value::Null));
}
}

View File

@@ -993,80 +993,6 @@ echo "hello $msg"
Ok(())
}
#[sqlx::test(fixtures("base", "wmill_cli_test"))]
async fn test_bash_wmill_variable_get(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// The bash script uses wmill CLI to get the variable value.
// The worker sets WM_TOKEN, WM_WORKSPACE, and BASE_INTERNAL_URL as env vars,
// and the CLI auto-configures from them when no workspace is explicitly set.
// We point WMILL_CONFIG_DIR to a clean temp dir so no local active workspace interferes.
let content = r#"
export WMILL_CONFIG_DIR=$(mktemp -d)
result=$(wmill variable get "u/test-user/test_var" --json | jq -r .value)
echo "$result"
"#
.to_owned();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content,
path: None,
lock: None,
language: ScriptLang::Bash,
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(),
}))
.run_until_complete(&db, false, port)
.await;
assert_eq!(job.json_result(), Some(json!("hello from variable")));
Ok(())
}
#[sqlx::test(fixtures("base", "wmill_cli_test"))]
async fn test_bash_wmill_resource_get(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// The bash script uses wmill CLI to get the resource value.
// We point WMILL_CONFIG_DIR to a clean temp dir so no local active workspace interferes.
let content = r#"
export WMILL_CONFIG_DIR=$(mktemp -d)
result=$(wmill resource get "u/test-user/test_res" --json | jq -c .value)
echo "$result"
"#
.to_owned();
let job = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content,
path: None,
lock: None,
language: ScriptLang::Bash,
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(),
}))
.run_until_complete(&db, false, port)
.await;
// Bash echo outputs are returned as strings, so the JSON is a string value
assert_eq!(
job.json_result(),
Some(json!("{\"host\":\"localhost\",\"port\":5432}"))
);
Ok(())
}
#[cfg(feature = "nu")]
#[sqlx::test(fixtures("base"))]
async fn test_nu_job(db: Pool<Postgres>) -> anyhow::Result<()> {
@@ -1666,66 +1592,6 @@ export async function main(a: Date) {
Ok(())
}
/// Test that full .npmrc content works for deno jobs with private registries.
/// Requires:
/// - `TEST_NPMRC` environment variable set to the full .npmrc content
#[cfg(feature = "private_registry_test")]
#[sqlx::test(fixtures("base"))]
async fn test_deno_job_private_npmrc(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_worker::NPMRC;
let npmrc_content = std::env::var("TEST_NPMRC")
.expect("TEST_NPMRC must be set when running private_registry_test");
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
{
let mut npmrc = NPMRC.write().await;
*npmrc = Some(npmrc_content.clone());
}
let content = r#"
import { greet } from "npm:@windmill-test/private-pkg";
export function main(name: string) {
return greet(name);
}
"#
.to_owned();
let result = RunJob::from(JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Deno,
lock: None,
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,
dedicated_worker: None,
}))
.arg("name", json!("World"))
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
{
let mut npmrc = NPMRC.write().await;
*npmrc = None;
}
assert_eq!(
result,
serde_json::json!("Hello from private package, World!")
);
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_job_datetime_and_bytes(db: Pool<Postgres>) -> anyhow::Result<()> {

View File

@@ -13,7 +13,7 @@ default = []
enterprise = ["windmill-common/enterprise", "windmill-queue/enterprise"]
private = ["windmill-common/private", "windmill-queue/private"]
python = ["dep:windmill-parser-py-imports"]
benchmark = ["windmill-queue/benchmark"]
benchmark = []
[dependencies]
windmill-api-auth.workspace = true

View File

@@ -6,6 +6,7 @@
* LICENSE-AGPL for a copy of the license.
*/
use windmill_api_auth::ApiAuthed;
use axum::{
extract::{Path, Query},
routing::{get, post},
@@ -19,7 +20,6 @@ use std::{
fmt::{Display, Formatter},
vec,
};
use windmill_api_auth::ApiAuthed;
use windmill_common::{
db::UserDB,
error::JsonResult,
@@ -109,7 +109,7 @@ pub struct Input {
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct CompletedJobMini {
id: Uuid,
completed_at: chrono::DateTime<chrono::Utc>,
created_at: chrono::DateTime<chrono::Utc>,
args: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>>,
created_by: String,
success: bool,
@@ -147,9 +147,9 @@ async fn get_input_history(
};
let sql = &format!(
"select id, v2_job_completed.completed_at, created_by, 'null'::jsonb as args, status = 'success' as success from v2_job JOIN v2_job_completed USING (id) \
"select id, v2_job.created_at, created_by, 'null'::jsonb as args, status = 'success' as success from v2_job JOIN v2_job_completed USING (id) \
where v2_job.workspace_id = $3 and {} = $1 and kind = any($2) {args_query} AND v2_job_completed.status != 'skipped' {include_non_root} \
order by v2_job_completed.completed_at desc limit $4 offset $5",
order by v2_job.created_at desc limit $4 offset $5",
r.runnable_type.column_name(),
);
@@ -189,10 +189,10 @@ async fn get_input_history(
id: row.id,
name: format!(
"{} {}",
row.completed_at.format("%H:%M %-d/%-m"),
row.created_at.format("%H:%M %-d/%-m"),
row.created_by
),
created_at: row.completed_at,
created_at: row.created_at,
args: sqlx::types::Json(
serde_json::value::RawValue::from_string("null".to_string()).unwrap(),
),
@@ -352,12 +352,11 @@ async fn update_input(
) -> JsonResult<String> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query("UPDATE input SET name = $1, is_public = $2 WHERE id = $3 and workspace_id = $4 AND created_by = $5")
sqlx::query("UPDATE input SET name = $1, is_public = $2 WHERE id = $3 and workspace_id = $4")
.bind(&input.name)
.bind(&input.is_public)
.bind(&input.id)
.bind(&w_id)
.bind(&authed.username)
.execute(&mut *tx)
.await?;
@@ -373,10 +372,9 @@ async fn delete_input(
) -> JsonResult<String> {
let mut tx = user_db.begin(&authed).await?;
sqlx::query("DELETE FROM input WHERE id = $1 and workspace_id = $2 AND created_by = $3")
sqlx::query("DELETE FROM input WHERE id = $1 and workspace_id = $2")
.bind(&i_id)
.bind(&w_id)
.bind(&authed.username)
.execute(&mut *tx)
.await?;

View File

@@ -13,7 +13,6 @@ windmill-api-auth.workspace = true
windmill-common = { workspace = true, default-features = false }
axum.workspace = true
flate2.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true

View File

@@ -14,10 +14,8 @@ use std::collections::HashMap;
use tower_http::cors::{Any, CorsLayer};
use windmill_common::{
error::{Error, JsonResult, Result},
global_settings::{
load_value_from_global_settings, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
},
utils::{parse_npmrc_registry, StripPath},
global_settings::{load_value_from_global_settings, NPM_CONFIG_REGISTRY_SETTING},
utils::StripPath,
};
use windmill_api_auth::ApiAuthed;
@@ -131,14 +129,6 @@ pub fn workspaced_service() -> Router {
)
}
fn build_registry_request(url: &str, auth_token: &Option<String>) -> reqwest::RequestBuilder {
let mut req = HTTP_CLIENT.get(url);
if let Some(token) = auth_token {
req = req.bearer_auth(token);
}
req
}
/// Get package metadata (versions and tags) from the private registry
async fn get_package_metadata(
_authed: ApiAuthed,
@@ -146,14 +136,21 @@ async fn get_package_metadata(
Extension(db): Extension<sqlx::Pool<sqlx::Postgres>>,
) -> JsonResult<PackageVersions> {
let package = parse_package_name(package_path.to_path());
let (registry_url, auth_token) = get_npm_registry(&db)
.await?
.ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?;
let npm_registry = get_npm_registry(&db).await?;
if npm_registry.is_none() {
return Err(Error::BadRequest(
"No private npm registry configured".to_string(),
));
}
let registry_url = npm_registry.unwrap();
let package_url = format_registry_url(&registry_url, &package, None, None);
tracing::info!("Fetching package metadata from: {}", package_url);
let response = build_registry_request(&package_url, &auth_token)
let response = HTTP_CLIENT
.get(&package_url)
.send()
.await
.map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?;
@@ -170,6 +167,7 @@ async fn get_package_metadata(
.await
.map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?;
// Extract versions and dist-tags from the package metadata
let mut versions = Vec::new();
let mut tags = HashMap::new();
@@ -196,15 +194,22 @@ async fn resolve_package_version(
Extension(db): Extension<sqlx::Pool<sqlx::Postgres>>,
) -> JsonResult<PackageVersion> {
let package = parse_package_name(package_path.to_path());
let (registry_url, auth_token) = get_npm_registry(&db)
.await?
.ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?;
let npm_registry = get_npm_registry(&db).await?;
if npm_registry.is_none() {
return Err(Error::BadRequest(
"No private npm registry configured".to_string(),
));
}
let registry_url = npm_registry.unwrap();
let reference = query.tag.unwrap_or_else(|| "latest".to_string());
let package_url = format_registry_url(&registry_url, &package, None, None);
tracing::info!("Resolving package version from: {}", package_url);
let response = build_registry_request(&package_url, &auth_token)
let response = HTTP_CLIENT
.get(&package_url)
.send()
.await
.map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?;
@@ -251,14 +256,21 @@ async fn get_package_filetree(
Extension(db): Extension<sqlx::Pool<sqlx::Postgres>>,
) -> JsonResult<PackageFiletree> {
let (package, version) = parse_package_and_version(package_version_path.to_path())?;
let (registry_url, auth_token) = get_npm_registry(&db)
.await?
.ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?;
let npm_registry = get_npm_registry(&db).await?;
if npm_registry.is_none() {
return Err(Error::BadRequest(
"No private npm registry configured".to_string(),
));
}
let registry_url = npm_registry.unwrap();
let package_url = format_registry_url(&registry_url, &package, None, None);
tracing::info!("Fetching package filetree from: {}", package_url);
let response = build_registry_request(&package_url, &auth_token)
let response = HTTP_CLIENT
.get(&package_url)
.send()
.await
.map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?;
@@ -275,6 +287,7 @@ async fn get_package_filetree(
.await
.map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?;
// Get the tarball URL for this version
let tarball_url = package_json
.get("versions")
.and_then(|v| v.get(&version))
@@ -283,7 +296,9 @@ async fn get_package_filetree(
.and_then(|t| t.as_str())
.ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?;
let tarball_response = build_registry_request(tarball_url, &auth_token)
// Download and extract tarball to get file list
let tarball_response = HTTP_CLIENT
.get(tarball_url)
.send()
.await
.map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?;
@@ -322,14 +337,21 @@ async fn get_package_file(
Extension(db): Extension<sqlx::Pool<sqlx::Postgres>>,
) -> Result<String> {
let (package, version, filepath) = parse_package_version_and_file(full_path.to_path())?;
let (registry_url, auth_token) = get_npm_registry(&db)
.await?
.ok_or_else(|| Error::BadRequest("No private npm registry configured".to_string()))?;
let npm_registry = get_npm_registry(&db).await?;
if npm_registry.is_none() {
return Err(Error::BadRequest(
"No private npm registry configured".to_string(),
));
}
let registry_url = npm_registry.unwrap();
let package_url = format_registry_url(&registry_url, &package, None, None);
tracing::info!("Fetching package file from: {}", package_url);
let response = build_registry_request(&package_url, &auth_token)
let response = HTTP_CLIENT
.get(&package_url)
.send()
.await
.map_err(|e| Error::InternalErr(format!("Failed to fetch package metadata: {}", e)))?;
@@ -346,6 +368,7 @@ async fn get_package_file(
.await
.map_err(|e| Error::InternalErr(format!("Failed to parse package metadata: {}", e)))?;
// Get the tarball URL for this version
let tarball_url = package_json
.get("versions")
.and_then(|v| v.get(&version))
@@ -354,7 +377,9 @@ async fn get_package_file(
.and_then(|t| t.as_str())
.ok_or_else(|| Error::NotFound(format!("Tarball not found for {}@{}", package, version)))?;
let tarball_response = build_registry_request(tarball_url, &auth_token)
// Download tarball
let tarball_response = HTTP_CLIENT
.get(tarball_url)
.send()
.await
.map_err(|e| Error::InternalErr(format!("Failed to download tarball: {}", e)))?;
@@ -377,38 +402,13 @@ async fn get_package_file(
Ok(file_content)
}
/// Get the npm registry URL and optional auth token from global settings.
/// Checks the `npmrc` setting first, then falls back to `npm_config_registry`.
async fn get_npm_registry(
db: &sqlx::Pool<sqlx::Postgres>,
) -> Result<Option<(String, Option<String>)>> {
let npmrc = load_value_from_global_settings(db, NPMRC_SETTING)
.await?
.and_then(|v| v.as_str().map(|s| s.to_string()));
if let Some(ref npmrc_content) = npmrc {
if let Some(parsed) = parse_npmrc_registry(npmrc_content) {
return Ok(Some(parsed));
}
}
/// Get the npm registry URL from global settings
async fn get_npm_registry(db: &sqlx::Pool<sqlx::Postgres>) -> Result<Option<String>> {
let registry = load_value_from_global_settings(db, NPM_CONFIG_REGISTRY_SETTING)
.await?
.and_then(|v| v.as_str().map(|s| s.to_string()));
if let Some(ref s) = registry {
let (url, token) = if s.contains(":_authToken=") {
let parts: Vec<&str> = s.split(":_authToken=").collect();
let url = parts[0].to_string();
let token = parts.get(1).map(|t| t.to_string());
(url, token)
} else {
(s.clone(), None)
};
return Ok(Some((url, token)));
}
Ok(None)
Ok(registry)
}
/// Format a registry URL for a package

View File

@@ -0,0 +1,22 @@
[package]
name = "windmill-api-sandbox"
version.workspace = true
authors.workspace = true
edition.workspace = true
[lib]
name = "windmill_api_sandbox"
path = "src/lib.rs"
[dependencies]
windmill-api-auth.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-sandbox.workspace = true
axum.workspace = true
chrono.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tracing.workspace = true
uuid.workspace = true

View File

@@ -0,0 +1,703 @@
/*
* Author: Windmill Labs, Inc
* Copyright: Windmill Labs, Inc 2024
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use axum::{
extract::{Path, Query},
routing::{delete, get, post},
Extension, Json, Router,
};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use windmill_common::{
db::UserDB,
error::{Error, JsonResult},
};
use windmill_sandbox::{ExecRequest, ExecResult, SandboxConfig, SandboxInfo, SandboxStatus};
use windmill_api_auth::ApiAuthed;
pub fn workspaced_service() -> Router {
Router::new()
.route("/create", post(create_sandbox))
.route("/list", get(list_sandboxes))
.route("/:sandbox_id", get(get_sandbox))
.route("/:sandbox_id", delete(delete_sandbox))
.route("/:sandbox_id/exec", post(exec_sandbox))
.route("/:sandbox_id/suspend", post(suspend_sandbox))
.route("/:sandbox_id/resume", post(resume_sandbox))
.route("/:sandbox_id/terminate", post(terminate_sandbox))
.route("/:sandbox_id/write_file", post(write_file))
.route("/:sandbox_id/read_file", get(read_file))
.route("/:sandbox_id/execs", get(list_execs))
}
#[derive(sqlx::FromRow)]
struct SandboxRow {
id: Uuid,
workspace_id: String,
status: SandboxStatus,
image: Option<String>,
labels: serde_json::Value,
mode: String,
created_by: String,
created_at: chrono::DateTime<Utc>,
started_at: Option<chrono::DateTime<Utc>>,
last_activity_at: Option<chrono::DateTime<Utc>>,
suspended_at: Option<chrono::DateTime<Utc>>,
stopped_at: Option<chrono::DateTime<Utc>>,
error_message: Option<String>,
ephemeral: bool,
cpu_limit: i32,
memory_limit_mb: i32,
network_enabled: bool,
}
impl From<SandboxRow> for SandboxInfo {
fn from(r: SandboxRow) -> Self {
SandboxInfo {
id: r.id,
workspace_id: r.workspace_id,
status: r.status,
image: r.image,
labels: r.labels,
mode: r.mode,
created_by: r.created_by,
created_at: r.created_at,
started_at: r.started_at,
last_activity_at: r.last_activity_at,
suspended_at: r.suspended_at,
stopped_at: r.stopped_at,
error_message: r.error_message,
ephemeral: r.ephemeral,
cpu_limit: r.cpu_limit,
memory_limit_mb: r.memory_limit_mb,
network_enabled: r.network_enabled,
}
}
}
#[derive(sqlx::FromRow)]
struct SandboxStatusRow {
status: SandboxStatus,
host_id: Option<String>,
mode: String,
}
async fn create_sandbox(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
Json(config): Json<SandboxConfig>,
) -> JsonResult<SandboxInfo> {
let mut tx = user_db.begin(&authed).await?;
let mode = config.mode.to_string();
let ephemeral = config.ephemeral || config.mode == windmill_sandbox::SandboxMode::Embedded;
let expires_at = config
.timeout_secs
.map(|t| Utc::now() + chrono::Duration::seconds(t as i64));
let row = sqlx::query_as::<_, (Uuid, chrono::DateTime<Utc>)>(
"INSERT INTO sandbox (
workspace_id, created_by, image, timeout_secs, idle_timeout_secs,
cpu_limit, memory_limit_mb, disk_limit_mb, env_vars, labels,
mounts, network_enabled, mode, status, ephemeral,
auto_stop_after_secs, expires_at, started_at, last_activity_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12, $13, 'creating', $14, $15, $16, now(), now()
) RETURNING id, created_at",
)
.bind(&w_id)
.bind(&authed.username)
.bind(config.image.as_deref())
.bind(config.timeout_secs)
.bind(config.idle_timeout_secs)
.bind(config.cpu_limit)
.bind(config.memory_limit_mb)
.bind(config.disk_limit_mb)
.bind(&config.env_vars)
.bind(&config.labels)
.bind(&config.mounts)
.bind(config.network_enabled)
.bind(&mode)
.bind(ephemeral)
.bind(config.auto_stop_after_secs)
.bind(expires_at)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(SandboxInfo {
id: row.0,
workspace_id: w_id,
status: SandboxStatus::Creating,
image: config.image,
labels: config.labels,
mode,
created_by: authed.username,
created_at: row.1,
started_at: None,
last_activity_at: None,
suspended_at: None,
stopped_at: None,
error_message: None,
ephemeral,
cpu_limit: config.cpu_limit,
memory_limit_mb: config.memory_limit_mb,
network_enabled: config.network_enabled,
}))
}
#[derive(Deserialize)]
struct ListSandboxesQuery {
status: Option<String>,
label_key: Option<String>,
label_value: Option<String>,
}
const SANDBOX_SELECT_COLS: &str = "id, workspace_id, status, image, labels, mode, created_by, created_at, started_at, last_activity_at, suspended_at, stopped_at, error_message, ephemeral, cpu_limit, memory_limit_mb, network_enabled";
async fn list_sandboxes(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
Query(query): Query<ListSandboxesQuery>,
) -> JsonResult<Vec<SandboxInfo>> {
let mut tx = user_db.begin(&authed).await?;
let statuses: Option<Vec<String>> = query
.status
.map(|s| s.split(',').map(|s| s.trim().to_string()).collect());
let rows = sqlx::query_as::<_, SandboxRow>(&format!(
"SELECT {SANDBOX_SELECT_COLS}
FROM sandbox
WHERE workspace_id = $1
AND ($2::text[] IS NULL OR status::text = ANY($2))
AND ($3::text IS NULL OR $4::text IS NULL OR labels->>$3 = $4)
ORDER BY created_at DESC
LIMIT 100"
))
.bind(&w_id)
.bind(statuses.as_deref())
.bind(query.label_key.as_deref())
.bind(query.label_value.as_deref())
.fetch_all(&mut *tx)
.await?;
let sandboxes = rows.into_iter().map(SandboxInfo::from).collect();
Ok(Json(sandboxes))
}
async fn get_sandbox(
authed: ApiAuthed,
Path((w_id, sandbox_id)): Path<(String, Uuid)>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<SandboxInfo> {
let mut tx = user_db.begin(&authed).await?;
let r = sqlx::query_as::<_, SandboxRow>(&format!(
"SELECT {SANDBOX_SELECT_COLS} FROM sandbox WHERE id = $1 AND workspace_id = $2"
))
.bind(sandbox_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Sandbox {sandbox_id} not found")))?;
Ok(Json(r.into()))
}
async fn delete_sandbox(
authed: ApiAuthed,
Path((w_id, sandbox_id)): Path<(String, Uuid)>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<String> {
let mut tx = user_db.begin(&authed).await?;
let status = sqlx::query_scalar::<_, SandboxStatus>(
"SELECT status FROM sandbox WHERE id = $1 AND workspace_id = $2",
)
.bind(sandbox_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Sandbox {sandbox_id} not found")))?;
if status != SandboxStatus::Stopped && status != SandboxStatus::Error {
return Err(Error::BadRequest(
"Sandbox must be stopped or in error state before deletion".to_string(),
));
}
sqlx::query("DELETE FROM sandbox WHERE id = $1")
.bind(sandbox_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(format!("Sandbox {sandbox_id} deleted")))
}
async fn exec_sandbox(
authed: ApiAuthed,
Path((w_id, sandbox_id)): Path<(String, Uuid)>,
Extension(user_db): Extension<UserDB>,
Json(request): Json<ExecRequest>,
) -> JsonResult<ExecResult> {
let mut tx = user_db.begin(&authed).await?;
let sandbox = sqlx::query_as::<_, SandboxStatusRow>(
"SELECT status, host_id, mode FROM sandbox WHERE id = $1 AND workspace_id = $2",
)
.bind(sandbox_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Sandbox {sandbox_id} not found")))?;
if sandbox.status != SandboxStatus::Running {
return Err(Error::BadRequest(format!(
"Sandbox is not running (status: {})",
sandbox.status
)));
}
let host_base_url = if sandbox.mode == "remote" {
let host_id = sandbox.host_id.as_ref().ok_or_else(|| {
Error::InternalErr("Remote sandbox has no host_id".to_string())
})?;
let host = sqlx::query_scalar::<_, String>(
"SELECT base_url FROM sandbox_host WHERE id = $1",
)
.bind(host_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Sandbox host {host_id} not found")))?;
Some(host)
} else {
None
};
let started_at = Utc::now();
let result = if let Some(base_url) = host_base_url {
proxy_exec(&base_url, sandbox_id, &request).await?
} else {
return Err(Error::BadRequest(
"Embedded sandbox exec must be performed directly via WM_SANDBOX_URL".to_string(),
));
};
let completed_at = Utc::now();
let duration_ms = (completed_at - started_at).num_milliseconds();
sqlx::query(
"INSERT INTO sandbox_exec (
sandbox_id, workspace_id, command, cwd, env_vars,
started_at, completed_at, exit_code, stdout, stderr,
duration_ms, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
)
.bind(sandbox_id)
.bind(&w_id)
.bind(&request.command)
.bind(request.cwd.as_deref())
.bind(&request.env)
.bind(started_at)
.bind(completed_at)
.bind(result.exit_code)
.bind(&result.stdout)
.bind(&result.stderr)
.bind(duration_ms)
.bind(&authed.username)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE sandbox SET last_activity_at = now() WHERE id = $1")
.bind(sandbox_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json(result))
}
async fn proxy_exec(
base_url: &str,
sandbox_id: Uuid,
request: &ExecRequest,
) -> windmill_common::error::Result<ExecResult> {
let client = reqwest::Client::new();
let url = format!("{}/sandbox/{}/exec", base_url, sandbox_id);
let resp = client
.post(&url)
.json(request)
.timeout(std::time::Duration::from_secs(
request.timeout_secs.unwrap_or(300) as u64 + 5,
))
.send()
.await
.map_err(|e| Error::InternalErr(format!("Failed to proxy exec to sandbox host: {e}")))?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::InternalErr(format!(
"Sandbox host returned error: {body}"
)));
}
resp.json::<ExecResult>()
.await
.map_err(|e| Error::InternalErr(format!("Failed to parse exec result: {e}")))
}
async fn proxy_action(
base_url: &str,
sandbox_id: Uuid,
action: &str,
) -> windmill_common::error::Result<SandboxStatus> {
let client = reqwest::Client::new();
let url = format!("{}/sandbox/{}/{}", base_url, sandbox_id, action);
let resp = client
.post(&url)
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| {
Error::InternalErr(format!("Failed to proxy {action} to sandbox host: {e}"))
})?;
if !resp.status().is_success() {
let body = resp.text().await.unwrap_or_default();
return Err(Error::InternalErr(format!(
"Sandbox host returned error: {body}"
)));
}
#[derive(Deserialize)]
struct StatusResponse {
status: SandboxStatus,
}
let status_resp = resp.json::<StatusResponse>().await.map_err(|e| {
Error::InternalErr(format!("Failed to parse action response: {e}"))
})?;
Ok(status_resp.status)
}
async fn get_host_base_url(
tx: &mut sqlx::PgConnection,
host_id: &str,
) -> windmill_common::error::Result<String> {
sqlx::query_scalar::<_, String>("SELECT base_url FROM sandbox_host WHERE id = $1")
.bind(host_id)
.fetch_one(&mut *tx)
.await
.map_err(|_| Error::NotFound(format!("Sandbox host {host_id} not found")))
}
async fn suspend_sandbox(
authed: ApiAuthed,
Path((w_id, sandbox_id)): Path<(String, Uuid)>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<SandboxInfo> {
let mut tx = user_db.clone().begin(&authed).await?;
let sandbox = sqlx::query_as::<_, SandboxStatusRow>(
"SELECT status, host_id, mode FROM sandbox WHERE id = $1 AND workspace_id = $2",
)
.bind(sandbox_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Sandbox {sandbox_id} not found")))?;
if sandbox.status != SandboxStatus::Running {
return Err(Error::BadRequest(format!(
"Can only suspend a running sandbox (status: {})",
sandbox.status
)));
}
if sandbox.mode == "remote" {
let host_id = sandbox.host_id.as_ref().ok_or_else(|| {
Error::InternalErr("Remote sandbox has no host_id".to_string())
})?;
let base_url = get_host_base_url(&mut *tx, host_id).await?;
proxy_action(&base_url, sandbox_id, "suspend").await?;
}
sqlx::query(
"UPDATE sandbox SET status = 'suspended', suspended_at = now() WHERE id = $1",
)
.bind(sandbox_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
get_sandbox_info(user_db, &authed, &w_id, sandbox_id).await
}
async fn resume_sandbox(
authed: ApiAuthed,
Path((w_id, sandbox_id)): Path<(String, Uuid)>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<SandboxInfo> {
let mut tx = user_db.clone().begin(&authed).await?;
let sandbox = sqlx::query_as::<_, SandboxStatusRow>(
"SELECT status, host_id, mode FROM sandbox WHERE id = $1 AND workspace_id = $2",
)
.bind(sandbox_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Sandbox {sandbox_id} not found")))?;
if sandbox.status != SandboxStatus::Suspended {
return Err(Error::BadRequest(format!(
"Can only resume a suspended sandbox (status: {})",
sandbox.status
)));
}
if sandbox.mode == "remote" {
let host_id = sandbox.host_id.as_ref().ok_or_else(|| {
Error::InternalErr("Remote sandbox has no host_id".to_string())
})?;
let base_url = get_host_base_url(&mut *tx, host_id).await?;
proxy_action(&base_url, sandbox_id, "resume").await?;
}
sqlx::query(
"UPDATE sandbox SET status = 'running', suspended_at = NULL, last_activity_at = now() WHERE id = $1",
)
.bind(sandbox_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
get_sandbox_info(user_db, &authed, &w_id, sandbox_id).await
}
async fn terminate_sandbox(
authed: ApiAuthed,
Path((w_id, sandbox_id)): Path<(String, Uuid)>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<SandboxInfo> {
let mut tx = user_db.clone().begin(&authed).await?;
let sandbox = sqlx::query_as::<_, SandboxStatusRow>(
"SELECT status, host_id, mode FROM sandbox WHERE id = $1 AND workspace_id = $2",
)
.bind(sandbox_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Sandbox {sandbox_id} not found")))?;
if sandbox.status == SandboxStatus::Stopped {
return get_sandbox_info(user_db, &authed, &w_id, sandbox_id).await;
}
if sandbox.mode == "remote" {
if let Some(host_id) = sandbox.host_id.as_ref() {
if let Ok(base_url) = get_host_base_url(&mut *tx, host_id).await {
let _ = proxy_action(&base_url, sandbox_id, "terminate").await;
}
}
}
sqlx::query(
"UPDATE sandbox SET status = 'stopped', stopped_at = now() WHERE id = $1",
)
.bind(sandbox_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
get_sandbox_info(user_db, &authed, &w_id, sandbox_id).await
}
#[derive(Deserialize, Serialize)]
struct WriteFileBody {
path: String,
content: String,
}
async fn write_file(
authed: ApiAuthed,
Path((w_id, sandbox_id)): Path<(String, Uuid)>,
Extension(user_db): Extension<UserDB>,
Json(body): Json<WriteFileBody>,
) -> JsonResult<String> {
let mut tx = user_db.begin(&authed).await?;
let sandbox = sqlx::query_as::<_, SandboxStatusRow>(
"SELECT status, host_id, mode FROM sandbox WHERE id = $1 AND workspace_id = $2",
)
.bind(sandbox_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Sandbox {sandbox_id} not found")))?;
if sandbox.status == SandboxStatus::Stopped {
return Err(Error::BadRequest("Sandbox is stopped".to_string()));
}
if sandbox.mode == "remote" {
let host_id = sandbox.host_id.as_ref().ok_or_else(|| {
Error::InternalErr("Remote sandbox has no host_id".to_string())
})?;
let base_url = get_host_base_url(&mut *tx, host_id).await?;
let client = reqwest::Client::new();
let url = format!("{}/sandbox/{}/write_file", base_url, sandbox_id);
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| Error::InternalErr(format!("Failed to proxy write_file: {e}")))?;
if !resp.status().is_success() {
let err = resp.text().await.unwrap_or_default();
return Err(Error::InternalErr(format!("Write file failed: {err}")));
}
}
sqlx::query("UPDATE sandbox SET last_activity_at = now() WHERE id = $1")
.bind(sandbox_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(Json("ok".to_string()))
}
#[derive(Deserialize)]
struct ReadFileQuery {
path: String,
}
async fn read_file(
authed: ApiAuthed,
Path((w_id, sandbox_id)): Path<(String, Uuid)>,
Extension(user_db): Extension<UserDB>,
Query(query): Query<ReadFileQuery>,
) -> JsonResult<String> {
let mut tx = user_db.begin(&authed).await?;
let sandbox = sqlx::query_as::<_, SandboxStatusRow>(
"SELECT status, host_id, mode FROM sandbox WHERE id = $1 AND workspace_id = $2",
)
.bind(sandbox_id)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| Error::NotFound(format!("Sandbox {sandbox_id} not found")))?;
if sandbox.status == SandboxStatus::Stopped {
return Err(Error::BadRequest("Sandbox is stopped".to_string()));
}
if sandbox.mode == "remote" {
let host_id = sandbox.host_id.as_ref().ok_or_else(|| {
Error::InternalErr("Remote sandbox has no host_id".to_string())
})?;
let base_url = get_host_base_url(&mut *tx, host_id).await?;
let client = reqwest::Client::new();
let url = format!(
"{}/sandbox/{}/read_file?path={}",
base_url, sandbox_id, query.path
);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| Error::InternalErr(format!("Failed to proxy read_file: {e}")))?;
if !resp.status().is_success() {
let err = resp.text().await.unwrap_or_default();
return Err(Error::InternalErr(format!("Read file failed: {err}")));
}
let content = resp.text().await.map_err(|e| {
Error::InternalErr(format!("Failed to read response body: {e}"))
})?;
return Ok(Json(content));
}
Err(Error::BadRequest(
"Embedded sandbox file ops must be performed directly via WM_SANDBOX_URL".to_string(),
))
}
#[derive(Serialize, sqlx::FromRow)]
struct ExecRecord {
id: Uuid,
sandbox_id: Uuid,
command: String,
started_at: chrono::DateTime<Utc>,
completed_at: Option<chrono::DateTime<Utc>>,
exit_code: Option<i32>,
stdout: Option<String>,
stderr: Option<String>,
duration_ms: Option<i64>,
created_by: String,
}
async fn list_execs(
authed: ApiAuthed,
Path((w_id, sandbox_id)): Path<(String, Uuid)>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<Vec<ExecRecord>> {
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query_as::<_, ExecRecord>(
"SELECT id, sandbox_id, command, started_at, completed_at,
exit_code, stdout, stderr, duration_ms, created_by
FROM sandbox_exec
WHERE sandbox_id = $1 AND workspace_id = $2
ORDER BY started_at DESC
LIMIT 100",
)
.bind(sandbox_id)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
Ok(Json(rows))
}
async fn get_sandbox_info(
user_db: UserDB,
authed: &ApiAuthed,
w_id: &str,
sandbox_id: Uuid,
) -> JsonResult<SandboxInfo> {
let mut tx = user_db.begin(authed).await?;
let r = sqlx::query_as::<_, SandboxRow>(&format!(
"SELECT {SANDBOX_SELECT_COLS} FROM sandbox WHERE id = $1 AND workspace_id = $2"
))
.bind(sandbox_id)
.bind(w_id)
.fetch_one(&mut *tx)
.await?;
Ok(Json(r.into()))
}

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