Merge origin/main into glm/group-nodes-layout

Resolve conflicts in graphBuilder, ModuleNode, and SubflowBound.
Remove offset param (dropped in main). Regenerate backend client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-03-10 14:32:04 +01:00
764 changed files with 24061 additions and 5047 deletions
-1
View File
@@ -110,7 +110,6 @@
]
},
"enabledPlugins": {
"rust-analyzer-lsp@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"code-review@claude-plugins-official": true
}
+165
View File
@@ -0,0 +1,165 @@
name: Backend integration tests (Windows)
on:
workflow_dispatch:
push:
branches:
- "ci-windows-tests"
env:
CARGO_INCREMENTAL: 0
SQLX_OFFLINE: true
DISABLE_EMBEDDING: true
jobs:
cargo_test_windows:
runs-on: blacksmith-16vcpu-windows-2025
steps:
- uses: actions/checkout@v4
- name: Read EE repo commit hash
shell: pwsh
run: |
$ee_repo_ref = Get-Content .\backend\ee-repo-ref.txt
echo "ee_repo_ref=$ee_repo_ref" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Checkout windmill-ee-private repository
uses: actions/checkout@v4
with:
repository: windmill-labs/windmill-ee-private
path: ./windmill-ee-private
ref: ${{ env.ee_repo_ref }}
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
fetch-depth: 0
- name: Substitute EE code
shell: bash
run: |
./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Setup PostgreSQL
uses: ikalnytskyi/action-setup-postgres@v6
with:
username: postgres
password: changeme
database: windmill
port: 5432
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.93.0
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- uses: actions/setup-go@v2
with:
go-version: 1.21.5
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
- uses: actions/setup-node@v4
with:
node-version: "20"
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.9.24"
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
tools: composer
- name: Install windmill CLI
shell: bash
run: |
cd cli
bash gen_wm_client.sh
bun install
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\nexec bun run "%s/cli/src/main.ts" "$@"\n' "$GITHUB_WORKSPACE" > "$HOME/.local/bin/wmill"
chmod +x "$HOME/.local/bin/wmill"
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install OpenSSL via vcpkg
run: |
vcpkg.exe install openssl-windows:x64-windows
vcpkg.exe install openssl:x64-windows-static
vcpkg.exe integrate install
- name: Get runtime paths
id: runtime-paths
shell: pwsh
run: |
echo "DENO_PATH=$($(Get-Command deno).Source)" >> $env:GITHUB_OUTPUT
echo "BUN_PATH=$($(Get-Command bun).Source)" >> $env:GITHUB_OUTPUT
echo "NODE_BIN_PATH=$($(Get-Command node).Source)" >> $env:GITHUB_OUTPUT
echo "GO_PATH=$($(Get-Command go).Source)" >> $env:GITHUB_OUTPUT
echo "UV_PATH=$($(Get-Command uv).Source)" >> $env:GITHUB_OUTPUT
echo "PHP_PATH=$($(Get-Command php).Source)" >> $env:GITHUB_OUTPUT
echo "COMPOSER_PATH=$($(Get-Command composer).Source)" >> $env:GITHUB_OUTPUT
echo "POWERSHELL_PATH=$($(Get-Command pwsh).Source)" >> $env:GITHUB_OUTPUT
echo "DOTNET_PATH=$($(Get-Command dotnet).Source)" >> $env:GITHUB_OUTPUT
- name: Build DuckDB FFI module
working-directory: backend/windmill-duckdb-ffi-internal
timeout-minutes: 30
run: |
cargo build --release -p windmill_duckdb_ffi_internal
New-Item -ItemType Directory -Path ..\target\debug -Force
Copy-Item target\release\windmill_duckdb_ffi_internal.dll ..\target\debug\
- name: Print runtime versions and env
shell: pwsh
run: |
deno --version
bun -v
node --version
go version
python3 --version
php --version
pwsh --version
dotnet --version
echo "TEMP=$env:TEMP"
echo "TMP=$env:TMP"
echo "USERPROFILE=$env:USERPROFILE"
echo "HOME=$env:HOME"
- name: cargo test
working-directory: backend
timeout-minutes: 60
env:
DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill
RUST_LOG: "off"
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
VCPKGRS_DYNAMIC: 1
OPENSSL_DIR: ${{ env.VCPKG_INSTALLATION_ROOT }}\installed\x64-windows-static
DENO_PATH: ${{ steps.runtime-paths.outputs.DENO_PATH }}
BUN_PATH: ${{ steps.runtime-paths.outputs.BUN_PATH }}
NODE_BIN_PATH: ${{ steps.runtime-paths.outputs.NODE_BIN_PATH }}
GO_PATH: ${{ steps.runtime-paths.outputs.GO_PATH }}
UV_PATH: ${{ steps.runtime-paths.outputs.UV_PATH }}
PHP_PATH: ${{ steps.runtime-paths.outputs.PHP_PATH }}
COMPOSER_PATH: ${{ steps.runtime-paths.outputs.COMPOSER_PATH }}
POWERSHELL_PATH: ${{ steps.runtime-paths.outputs.POWERSHELL_PATH }}
DOTNET_PATH: ${{ steps.runtime-paths.outputs.DOTNET_PATH }}
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
run: >
cargo test
--no-fail-fast
--features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,csharp,php,quickjs,mcp,run_inline
--all
-- --nocapture --test-threads=10
+65
View File
@@ -0,0 +1,65 @@
# Project display name in the dashboard
name: Windmill
workspace:
mainBranch: main
worktreeRoot: ../windmill__worktrees
defaultAgent: claude
startupEnvs:
CARGO_FEATURES: "quickjs"
WM_CLONE_DB: false
USE_RUST_PLUGIN: false
lifecycleHooks:
postCreate: bash ./scripts/post-create.sh
preRemove: bash ./scripts/pre-remove.sh
auto_name:
model: gemini-2.5-flash-lite
# Each service defines a port env var that webmux injects into pane and agent
# process environments when creating a worktree. Ports are auto-assigned:
# base + (slot x step).
services:
- name: backend
portEnv: BACKEND_PORT
portStart: 8000
portStep: 10
- name: frontend
portEnv: FRONTEND_PORT
portStart: 3000
portStep: 10
profiles:
default:
runtime: host
yolo: true
envPassthrough: []
systemPrompt: >
You are running inside a tmux session with other panes running services.
Pane layout (current window):
- Pane 0: this pane (claude agent)
- Pane 1: backend (cargo watch -x run)
- Pane 2: frontend (npm run dev)
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).
When restarting backend or frontend, make sure to use ${BACKEND_PORT} and ${FRONTEND_PORT}.
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.
panes:
- id: agent
kind: agent
focus: true
- id: backend
kind: command
split: right
command: ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/backend" && PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
- id: frontend
kind: command
split: bottom
command: ROOT="$(git rev-parse --show-toplevel)"; [ -f "$ROOT/.env.local" ] && source "$ROOT/.env.local"; cd "$ROOT/frontend" && npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
integrations:
github:
linkedRepos: []
linear:
enabled: true
-111
View File
@@ -1,111 +0,0 @@
name: Windmill
startupEnvs:
CARGO_FEATURES: "quickjs"
services:
- name: BE
portEnv: BACKEND_PORT
- name: FE
portEnv: FRONTEND_PORT
profiles:
default:
name: default
sandbox:
name: sandbox
image: windmill-sandbox
envPassthrough:
- AWS_ACCESS_KEY_ID
- AWS_SECRET_ACCESS_KEY
- R2_ENDPOINT
- R2_BUCKET
- R2_PUBLIC_URL
extraMounts:
- hostPath: ~/.ssh
guestPath: /root/.ssh
writable: true
- hostPath: ~/.codex
guestPath: /root/.codex
writable: true
- hostPath: ~/windmill-ee-private
writable: true
- hostPath: ~/windmill-ee-private__worktrees
writable: true
systemPrompt: >
You are running inside a sandboxed container with full permissions.
This worktree is configured with the following ports:
- Backend: port ${BACKEND_PORT}.
Start with: cd backend && PORT=${BACKEND_PORT}
DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill
cargo watch -x run
- Frontend: port ${FRONTEND_PORT}.
Start with: cd frontend && REMOTE=http://localhost:${BACKEND_PORT}
npm run dev -- --port ${FRONTEND_PORT} --host 0.0.0.0
--- Screenshots ---
You can take screenshots of the frontend UI and upload them to R2
for use in PR descriptions.
1) Take a screenshot:
bunx playwright screenshot --browser chromium
http://localhost:${FRONTEND_PORT}/path/to/page /tmp/screenshot.png
2) Upload to R2:
aws s3 cp /tmp/screenshot.png
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/screenshot.png"
--endpoint-url "$(printenv R2_ENDPOINT)"
3) The public URL will be:
$(printenv R2_PUBLIC_URL)/<branch>/screenshot.png
4) Include in PR descriptions using markdown image syntax.
--- Terminal Recordings (asciinema) ---
You can record terminal sessions and upload them for sharing.
asciinema is available on PATH.
1) Write a shell script with the commands to demo. Add sleep
delays for readable pacing:
- 0.5s after printing a "$ command" line (lets viewer read it)
- 1.5-2s after command output (lets viewer absorb the result)
- Set GIT_PAGER=cat and PAGER=cat to prevent pager hangs
2) Record headlessly:
asciinema rec --headless --overwrite \
-c "bash /tmp/demo.sh" \
--window-size 120x50 \
--title "Description of demo" \
/tmp/demo.cast
3) Upload to asciinema.org:
XDG_DATA_HOME=/tmp/.local/share \
asciinema upload --server-url https://asciinema.org /tmp/demo.cast
--- Mermaid Diagrams ---
You can render Mermaid diagrams to SVG using the pre-installed mmdc CLI.
The puppeteer config (no-sandbox + Chromium path) is at /root/.puppeteerrc.json.
1) Write a .mmd file with your diagram:
cat > /tmp/diagram.mmd << 'EOF'
graph TD
A[Start] --> B[End]
EOF
2) Render to SVG (the -p flag is required):
mmdc -i /tmp/diagram.mmd -o /tmp/diagram.svg -p /root/.puppeteerrc.json
3) Upload to R2:
aws s3 cp /tmp/diagram.svg
"s3://$(printenv R2_BUCKET)/$(git rev-parse --abbrev-ref HEAD)/diagram.svg"
--endpoint-url "$(printenv R2_ENDPOINT)"
4) The public URL will be:
$(printenv R2_PUBLIC_URL)/<branch>/diagram.svg
5) Include in PR descriptions using markdown image syntax.
IMPORTANT: Read docs/autonomous-mode.md before starting any work.
linkedRepos:
- repo: windmill-labs/windmill-ee-private
alias: ee
+82
View File
@@ -1,5 +1,87 @@
# Changelog
## [1.653.0](https://github.com/windmill-labs/windmill/compare/v1.652.0...v1.653.0) (2026-03-10)
### Features
* add indexer time window setting (default 7 days) ([#8290](https://github.com/windmill-labs/windmill/issues/8290)) ([0c4d72c](https://github.com/windmill-labs/windmill/commit/0c4d72cfe38d61cf3f6e9bc31056005f1adb494d))
* add slack connection fields to workspace settings export/import ([#8287](https://github.com/windmill-labs/windmill/issues/8287)) ([39e77ec](https://github.com/windmill-labs/windmill/commit/39e77ecd002b41630fa8d146ee0f15369656acda))
### Performance Improvements
* optimize job_stats storage for timestamps and zero-memory jobs ([#8289](https://github.com/windmill-labs/windmill/issues/8289)) ([2d8335d](https://github.com/windmill-labs/windmill/commit/2d8335dc43a7cb182eb5a058119d8b0be067cdfd))
## [1.652.0](https://github.com/windmill-labs/windmill/compare/v1.651.1...v1.652.0) (2026-03-09)
### Features
* add secretKeyRef support for package registry and storage credentials ([#8275](https://github.com/windmill-labs/windmill/issues/8275)) ([73d27e9](https://github.com/windmill-labs/windmill/commit/73d27e92dd6ced1602f6328f245fec0fa96860e1))
* expose OTEL trace context as env vars in job execution ([#8277](https://github.com/windmill-labs/windmill/issues/8277)) ([93f75ad](https://github.com/windmill-labs/windmill/commit/93f75ada5e49036f0d998e3d3d53de4dc2c2e83f))
* workflow-as-code (WAC) v2 ([#8172](https://github.com/windmill-labs/windmill/issues/8172)) ([a6d4390](https://github.com/windmill-labs/windmill/commit/a6d4390790d21d535df1e9d525bffd577c50d8dc))
### Bug Fixes
* cli: support deleting linked resources-variables without throwing ([#8248](https://github.com/windmill-labs/windmill/issues/8248)) ([7859bca](https://github.com/windmill-labs/windmill/commit/7859bca6ae80d32a73a46910960afc6812e64115))
* Database studio fixes ([#8251](https://github.com/windmill-labs/windmill/issues/8251)) ([1d78589](https://github.com/windmill-labs/windmill/commit/1d785899404e8636a206cda9a2914df32a1a5269))
* **frontend:** unsaved changes dialog when flow already saved ([#8259](https://github.com/windmill-labs/windmill/issues/8259)) ([0330993](https://github.com/windmill-labs/windmill/commit/0330993cb66cdabffcd6e552a0f85a9a3931c62d))
* gracefully handle uninitialized OTEL tracing proxy port ([#8274](https://github.com/windmill-labs/windmill/issues/8274)) ([8b1fe8f](https://github.com/windmill-labs/windmill/commit/8b1fe8f9de7b0c03655558d0c46cfff71a4b2047))
* guard iteration picker VirtualList against empty items array ([#8273](https://github.com/windmill-labs/windmill/issues/8273)) ([c97cf60](https://github.com/windmill-labs/windmill/commit/c97cf604ab4a902d89fe873b90dbeb9dabc940eb)), closes [#8272](https://github.com/windmill-labs/windmill/issues/8272)
* mask secrets in OAuth config debug/log output ([#8269](https://github.com/windmill-labs/windmill/issues/8269)) ([e75763d](https://github.com/windmill-labs/windmill/commit/e75763dbe5ffe08e6cde082203596d510c2c3b29))
* parallel branchall hang on bad stop_after_all_iters_if + results.x.length null ([#8276](https://github.com/windmill-labs/windmill/issues/8276)) ([41e523f](https://github.com/windmill-labs/windmill/commit/41e523f827c4e3d5db525a1f14e24936b0b8af46))
* redact secrets in set_global_setting log line ([#8270](https://github.com/windmill-labs/windmill/issues/8270)) ([6a0473c](https://github.com/windmill-labs/windmill/commit/6a0473c5783dc0fef2ae82dc5345a5f0596f124d))
* remove $bindable() fallback values causing props_invalid_value error in oauth settings ([#8265](https://github.com/windmill-labs/windmill/issues/8265)) ([037035e](https://github.com/windmill-labs/windmill/commit/037035e094937827305dad29bd76a495d78bc46f))
* skip down migrations in potentially_stale checksum comparison ([#8271](https://github.com/windmill-labs/windmill/issues/8271)) ([5ba4029](https://github.com/windmill-labs/windmill/commit/5ba4029d8692b2e6054fca7f45ed4cfded4738ef))
* sql input horizontal scroll missing after switching flow steps ([#8249](https://github.com/windmill-labs/windmill/issues/8249)) ([ce8ac9c](https://github.com/windmill-labs/windmill/commit/ce8ac9cf52dc17061673b9b72556279c48c26f8e))
* wmill workspace whoami output ([#8246](https://github.com/windmill-labs/windmill/issues/8246)) ([1ac391a](https://github.com/windmill-labs/windmill/commit/1ac391a795585747fe5911ac41b157556569fedb))
## [1.651.1](https://github.com/windmill-labs/windmill/compare/v1.651.0...v1.651.1) (2026-03-05)
### Bug Fixes
* prevent slow loading toast interval from leaking on promise cancellation ([#8240](https://github.com/windmill-labs/windmill/issues/8240)) ([2e582b1](https://github.com/windmill-labs/windmill/commit/2e582b1bc1c299388a3c97cfddff9d0eb92858f2))
* suppress unused variable warnings on windows builds ([#8241](https://github.com/windmill-labs/windmill/issues/8241)) ([2d58382](https://github.com/windmill-labs/windmill/commit/2d583826dc065c05684d4cd1d1510f0d1f2d9ae9))
## [1.651.0](https://github.com/windmill-labs/windmill/compare/v1.650.0...v1.651.0) (2026-03-05)
### Features
* add sandbox annotations, volume mounts, for AI sandbox starting with claude ([#8058](https://github.com/windmill-labs/windmill/issues/8058)) ([5f0ef93](https://github.com/windmill-labs/windmill/commit/5f0ef936d1d5d07d01c8e07e26ec254feebef8fb))
* hash-based MCP tool names for long paths ([#8133](https://github.com/windmill-labs/windmill/issues/8133)) ([ce041e8](https://github.com/windmill-labs/windmill/commit/ce041e8a5e7ff105df389875d9981f3843d4ce39))
### Bug Fixes
* **python-client:** add delete_s3_object ([#8216](https://github.com/windmill-labs/windmill/issues/8216)) ([90f4c64](https://github.com/windmill-labs/windmill/commit/90f4c64ee12e1d04ce846ff88d6658f667e194e0))
* update CLI bun template to match UI template ([#8238](https://github.com/windmill-labs/windmill/issues/8238)) ([a8cbe93](https://github.com/windmill-labs/windmill/commit/a8cbe9396ffc51140dce5582d57f4dc59873304e))
* write fallback package.json for codebase mode nsjail ([#8239](https://github.com/windmill-labs/windmill/issues/8239)) ([d46913b](https://github.com/windmill-labs/windmill/commit/d46913b74a0ffd41d2323e0355cc81954f09e29d))
## [1.650.0](https://github.com/windmill-labs/windmill/compare/v1.649.0...v1.650.0) (2026-03-05)
### Features
* add move, delete, and duplicate to flow node context menu ([#8050](https://github.com/windmill-labs/windmill/issues/8050)) ([c0c9388](https://github.com/windmill-labs/windmill/commit/c0c9388415716ce77d841bd08a46f94e0a529685))
* add variable and resource types to flow env variables ([#8214](https://github.com/windmill-labs/windmill/issues/8214)) ([164e499](https://github.com/windmill-labs/windmill/commit/164e499c64dc5eb76fcfb0f8cefbad2df244f610))
* Ducklake typechecker ([#8118](https://github.com/windmill-labs/windmill/issues/8118)) ([53caecf](https://github.com/windmill-labs/windmill/commit/53caecf1da8d76e246178dfb9b86d330f0ec52fd))
* make WINDMILL_DIR configurable via environment variable ([#8215](https://github.com/windmill-labs/windmill/issues/8215)) ([424ca59](https://github.com/windmill-labs/windmill/commit/424ca59dfe3e730f5388d9cac4ea7e69773614d3))
* make WM_END_USER_EMAIL display users from different workspaces ([#8208](https://github.com/windmill-labs/windmill/issues/8208)) ([baf2bcf](https://github.com/windmill-labs/windmill/commit/baf2bcf14da0c8c95bdbbf511fcaee48be33948b))
* persistent Db manager state in URI ([#8134](https://github.com/windmill-labs/windmill/issues/8134)) ([4bf827b](https://github.com/windmill-labs/windmill/commit/4bf827bea4d44aca8c5ff7aa67ad449dbcf00673))
* replace hub error toasts with warning alerts and add disable hub setting ([#8225](https://github.com/windmill-labs/windmill/issues/8225)) ([63ebae8](https://github.com/windmill-labs/windmill/commit/63ebae8829a6dc47a4e23c8670b514f042c9d4be))
* token expiration notifications ([#8190](https://github.com/windmill-labs/windmill/issues/8190)) ([e56ccd2](https://github.com/windmill-labs/windmill/commit/e56ccd200be29e6ac8ea2b04a341b1ce78a307f6))
### Bug Fixes
* handle multipart stream errors gracefully instead of panicking ([#8226](https://github.com/windmill-labs/windmill/issues/8226)) ([19c065b](https://github.com/windmill-labs/windmill/commit/19c065bed5468c484c8e7a50a6b79ab90153cc0e))
* improve windows compatibility ([077779e](https://github.com/windmill-labs/windmill/commit/077779ec52f7d3e5fcc93951544bf47bd6dc30b6))
* wrap set_encryption_key in a single database transaction ([#8212](https://github.com/windmill-labs/windmill/issues/8212)) ([62382fd](https://github.com/windmill-labs/windmill/commit/62382fd2869ea0190dd0c0b714f9cbd35ceddd7a))
## [1.649.0](https://github.com/windmill-labs/windmill/compare/v1.648.0...v1.649.0) (2026-03-03)
+19
View File
@@ -0,0 +1,19 @@
# Agent 6 — Dev Environment
## Running Services
- **Frontend**: http://localhost:5179
- **Backend**: http://localhost:8006 (docker)
- **Database**: postgres://postgres:changeme@localhost:5432/windmill
## Authentication
Default credentials (auto-configured):
- **Email**: admin@windmill.dev
- **Password**: changeme
Use these for API calls and Playwright login flows.
## Playwright MCP
When testing with Playwright, use **http://localhost:5179** as the base URL.
+21
View File
@@ -26,6 +26,27 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Login**: `admin@windmill.dev` / `changeme`
- **Instance settings**: navigate to `/#superadmin-settings`
## Banned Patterns
### `$bindable(default_value)` on optional props
Using `$bindable(default_value)` on props that can be `undefined` is **banned**. This pattern causes subtle bugs because the default value masks the `undefined` state.
**Bad:**
```svelte
let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props()
```
**Correct alternatives:**
1. **Use `$derived` with nullish coalescing** — handle the potential `undefined` at the usage site:
```svelte
let { my_prop = $bindable() }: { my_prop?: string } = $props()
let effective_value = $derived(my_prop ?? default_value)
```
2. **Create a `useMyPropState()` helper** — encapsulate the undefined-handling logic in a reusable function and call it higher in the component tree, so the child component always receives a defined value.
## Core Principles
- Search for existing code to reuse before writing new code
+6
View File
@@ -262,6 +262,12 @@ COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
RUN bun install -g windmill-cli \
&& ln -s $(bun pm bin -g)/wmill /usr/bin/wmill
# Install Claude Code CLI (used by claude sandbox scripts)
# The installer puts the binary in ~/.local/bin/claude (symlink to ~/.local/share/claude/versions/*)
# Copy it to /usr/bin/claude so it's accessible inside nsjail sandbox (which mounts /usr but not /root)
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
COPY --from=php:8.3.7-cli /usr/local/bin/php /usr/bin/php
COPY --from=composer:2.7.6 /usr/bin/composer /usr/bin/composer
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "00bf3dbd9d3f51dd7fdefcbd654d55e0379cc84188954037165cbe2d198ef71f"
}
@@ -1,16 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT token\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ",
"query": "SELECT group_ FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"name": "group_",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
@@ -18,5 +19,5 @@
false
]
},
"hash": "90092c0b3f7612373fcc8fb7a966200118ab308430d4a0cbb5cb16c397246492"
"hash": "015a8551c646f9b027fc23752c5c5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT large_file_storage->>'volume_storage' FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "083d69abc8a662bb364cf43b8ffc6e9b159a54c179cecb108068597536835f7e"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_stats SET offsets_cs = array_append(offsets_cs, (EXTRACT(EPOCH FROM (now() - timeseries_start)) * 100)::int), timeseries_int = array_append(timeseries_int, $4) WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "0af0e0a1dddeee2021ba060e390e1b60caa3752669636e9fb0817a68121a9451"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "extra_perms",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0afd4ae50ff7e1b0dcca4b483816c595401dd2e1f7699a28bf3b79db5e3841f4"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET canceled_by = $2, canceled_reason = $3 WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "0cd9cad7109340edc81a5a40620b6efdae570e3416ec6c2493cc04f75c32a699"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT j.id, j.runnable_path, j.args, j.kind::text AS \"kind!\"\n FROM v2_job j\n JOIN v2_job_queue q ON j.id = q.id\n WHERE j.runnable_path = $1\n AND j.kind = 'deploymentcallback'\n ORDER BY j.created_at DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "args",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "kind!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
null
]
},
"hash": "0d4f28ca0c5697c96711ca7225a9a4013e6ccabb495c371471c9d1287defda8f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0eb54f04a8185085b3f80772f5c28e666f6fbd1ec5ee9d30ee0cdb5e30a68750"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = $2, suspend_until = now() + interval '14 day' WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Int4"
]
},
"nullable": []
},
"hash": "10af387fce25f6ea7af275e8e93b7ab1f2fc29a2ba79a39576551bdf66b592b6"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "14004a7c1641a3157eddd571fea11a1dfb1422187200119268b2342b47a960c6"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "1d2f765c2a71e1154ca5d9f5e52ef31e6d647377d37747f7bdc834748a59419e"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_stats SET timestamps = array_append(timestamps, now()), timeseries_int = array_append(timeseries_int, $4) WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"Int4"
]
},
"nullable": []
},
"hash": "1db82007445ff5f644bb607aa28f5747cb50d193475fff5fcfdde37d1bc74636"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)\n VALUES ($1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET size_bytes = $3, last_used_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "1e9b9a02f45e6200f4d101bd5336fc8ce983f857339e6fccf799dc6587964aab"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "23f47f5207abe0cfaede197aeee485957990eb92fa3ce515895eab0d3f28bfdc"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "28df7bbe1f54f69640bc76def9e580b4c7ba25f279644e3233b63f4f6db0ad98"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), updated_by = $5, last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int4",
"Varchar"
]
},
"nullable": []
},
"hash": "3955e57e216d169c30b1548a2252eb169329116cba57780fa90ecf2bdb910f34"
}
@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n name as \"name!\",\n size_bytes as \"size_bytes!\",\n file_count as \"file_count!\",\n created_at as \"created_at!\",\n created_by as \"created_by!\",\n updated_at,\n updated_by,\n description as \"description!\",\n last_used_at,\n extra_perms as \"extra_perms!\"\n FROM (\n SELECT\n COALESCE(v.name, a.path) as name,\n COALESCE(v.size_bytes, 0) as size_bytes,\n COALESCE(v.file_count, 0) as file_count,\n COALESCE(v.created_at, a.min_created_at) as created_at,\n COALESCE(v.created_by, 'unknown') as created_by,\n v.updated_at,\n v.updated_by,\n COALESCE(v.description, '') as description,\n v.last_used_at,\n COALESCE(v.extra_perms, '{}'::jsonb) as extra_perms\n FROM (\n SELECT path, MIN(created_at) as min_created_at\n FROM asset\n WHERE workspace_id = $1 AND kind = 'volume'\n GROUP BY path\n ) a\n FULL OUTER JOIN volume v ON v.workspace_id = $1 AND v.name = a.path\n WHERE v.workspace_id = $1 OR a.path IS NOT NULL\n ) combined\n ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "size_bytes!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "file_count!",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "created_at!",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "created_by!",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "updated_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "description!",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "last_used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 9,
"name": "extra_perms!",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null,
null,
null,
null,
true,
true,
null,
true,
null
]
},
"hash": "40d0f6dca30456514cb85e36c6e367b27171894016c714e41497e69115be1468"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status SET\n workflow_as_code_status = jsonb_set(\n jsonb_set(\n COALESCE(workflow_as_code_status, '{}'::jsonb),\n array[$1],\n COALESCE(workflow_as_code_status->$1, '{}'::jsonb)\n ),\n array[$1, 'duration_ms'],\n to_jsonb($2::bigint)\n )\n WHERE id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8",
"Uuid"
]
},
"nullable": []
},
"hash": "56f7325e3b0316866714e76d94b50d9d258c288883b2b5b0ab286f5cb50850b5"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2\n AND (lease_until IS NULL OR lease_until < now())\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "5af44b46a2e2f1a9adeb39013790be7046cf8789d842717b6c793c22a2a05daa"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_by, extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "extra_perms",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "6086849bb08e1b37d6693d2808767cd897dca4722e4f2076308afdb7ee9fc147"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM volume WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "712092e5033bc6894025a55ebc58bca8450d09982e582266d215dff521256fa6"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $5",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "75a03e9e4cba350a104e2e3a95de919cd25538c0b433bc29bb052c7a7b8568ca"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "769035629df5a5034f64bf38992e142006825a3911addacdf1a026660b5e2b7f"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now() AND leased_by = $3)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "78af8bdb6a3ee6396c54f87ff6403b566fc75e16e0b7a81204816fd50b3346a5"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "7ce06d4f623932fce12352be3a09ba8973a2ef1defa36c6d46d9c1c6406a7c33"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "7e8e79a7d140be511cedbfe9ff8eea76a8a3079ce80c035087f797cdc410f35b"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "803abdcd3614437b26c5d2e4f1ad75ca7014b431239ac1b681f2b26380c719c4"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET extra_perms = extra_perms - $1\n WHERE workspace_id = $2 AND name = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "82b3bd95e5d28c4cd4eedcae8cf050ba7b7e4d9eabba03be251ae9a8017b317d"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "leased_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "88e25dc24bb06237b3677c947ee53fd6e9c7606231ad3c522e98cb1fcc14361a"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "907241c195fea227e4a945ee472425e5f7600e28c728a06235f7ff430a4bd77a"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT size_bytes FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "size_bytes",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "94d6f598076ad67d68e6f01926c9fc2c73e855790e17abf5461b96ea30fbbdb7"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9662f1e304124fa52db4aa1e80e03b2601630f2d31458bdaf70c2702b2998d89"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9e30b5545a51453205a713a6276156ada29ae320465d9790dce7e1e8a436d4de"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT extra_perms, created_by FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "9f64d6ed0adb609ced1551563062550919fcac56deaf1b3cb36b3e15117936e7"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "a35164456ade8e79cb8f5418c8fe82c45be45409f881292f0d4a3316362ba1f4"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, 0, $3)\n ON CONFLICT (workspace_id, name) DO NOTHING\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "a3970c15271a124307301c0dafa263e7168fa325c5ceb44e9dd1595bdb7e7ce6"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Timestamptz"
]
},
"nullable": []
},
"hash": "a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET running = false, started_at = null WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "a684f160d1a366c1928fef27c613e6e08f808f423c8f2d58b9c849aba7d176f5"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token_expiry_notification WHERE expiration <= now()",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "a6b1c8808c892e62ae4ba04171d856a39c89cdc658b09c478050de5145a45ca4"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = 0, suspend_until = NULL WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "a72081cb042f09034338dcb49381e91093233cf16af0dae666b4743f3878b22e"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_stats SET offsets_cs = array_append(offsets_cs, (EXTRACT(EPOCH FROM (now() - timeseries_start)) * 100)::int), timeseries_float = array_append(timeseries_float, $4) WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"Float4"
]
},
"nullable": []
},
"hash": "a837494a58ab58bfa18c0385350a861076a5fc4f7eefceb1c0de5cf55293f327"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "ab8daa93bc66d0142b9e9e8d7fa6719fc41b2ca5cb0b7ac5ad73ab01b650c935"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1 RETURNING suspend",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "suspend",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "b663e6baf2f8da00c6d94e5b8e35be9d9f51071978c0e48df4284d8b90000a4a"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token WHERE expiration <= now()\n RETURNING substring(token for 10) as token_prefix, label, email, workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token_prefix",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
true,
true,
true
]
},
"hash": "bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT size_bytes, last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "size_bytes",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "bc61ca62d8f71880facb5d701a6e78697414b35618c50f8693f4e804bf1d7dbb"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status SET\n workflow_as_code_status = jsonb_set(\n jsonb_set(\n workflow_as_code_status,\n array[$1],\n COALESCE(workflow_as_code_status->$1, '{}'::jsonb)\n ),\n array[$1, 'duration_ms'],\n to_jsonb($2::bigint)\n )\n WHERE id = $3 AND workflow_as_code_status IS NOT NULL\n RETURNING workflow_as_code_status->'_checkpoint'->'pending_steps'->'job_ids' AS \"job_ids: serde_json::Value\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "job_ids: serde_json::Value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "c331609952e0b98d36f605bd5d2933aa54523bf44d63e304440e53b9eadd5340"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, size_bytes, created_by, last_used_at\n FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "size_bytes",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "d0869a340c8f34ca7a560d3b4c0070c9f117da3dd00ce3247c54a61052a6809c"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "leased_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "d1ad2baf5e3a6f45f1f079d494e8d6affad03a1f388024806a5de3f9cc939c04"
}
@@ -1,17 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE job_stats SET timestamps = array_append(timestamps, now()), timeseries_float = array_append(timeseries_float, $4) WHERE workspace_id = $1 AND job_id = $2 AND metric_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text",
"Float4"
]
},
"nullable": []
},
"hash": "d44c37882150532383d1058639f4d3af288a346c50f29809dbc55a34088a0abc"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token = t.token\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token_prefix",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
true,
true,
true
]
},
"hash": "d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview')) AS \"is_flow_level!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id\n ELSE ji.parent_job\n END\n JOIN v2_job j ON j.id = q.id\n JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
"describe": {
"columns": [
{
@@ -32,6 +32,11 @@
"ordinal": 5,
"name": "is_flow_level!",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "is_wac!",
"type_info": "Bool"
}
],
"parameters": {
@@ -45,8 +50,9 @@
false,
true,
false,
null,
null
]
},
"hash": "1a0ab65bbf2751f702fc696c1e32a7dd9524cdd806be1ad8e9ab88d4c88d3f82"
"hash": "dbc7e74e259b502e700491ee0248e0c9c8c61e1bf609be60ac5dc5d438189353"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, size_bytes FROM volume WHERE workspace_id = $1 ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "size_bytes",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "dc18db954239c4ebdd3b46cfd34f33554794444f0dc4e2d2fec158eca5ebe865"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2::bool), true)\n WHERE workspace_id = $3 AND name = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "eb79db2aeac7bf246ad56a5f116511b9d3183cb91b740a86944a77a2a964b57d"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT token as \"token!\"\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT permissioned_as FROM v2_job WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "permissioned_as",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "f0ac12b66c5d3cca680541aed04359b064baf73b890efdc25426261d4eadfee0"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET suspend = 1, suspend_until = now() + make_interval(secs => $2) WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Float8"
]
},
"nullable": []
},
"hash": "f56c58fea9f27d2e55d33720e032808e90cb068d1048717f82992f476377cc20"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT size_bytes, file_count, leased_by, lease_until\n FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "size_bytes",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "file_count",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "leased_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "lease_until",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "f7ba87d5804b9bc05e7156c7c18c5a30037abef63efb5b44dc535c5f45d62a06"
}
+161 -117
View File
@@ -1900,7 +1900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
dependencies = [
"once_cell",
"proc-macro-crate 3.4.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -2550,6 +2550,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cooked-waker"
version = "5.0.0"
@@ -7094,7 +7103,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.2",
"socket2 0.6.3",
"system-configuration",
"tokio",
"tower-service",
@@ -7965,9 +7974,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.182"
version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libffi"
@@ -8093,9 +8102,9 @@ dependencies = [
[[package]]
name = "libz-sys"
version = "1.1.24"
version = "1.1.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4735e9cbde5aac84a5ce588f6b23a90b9b0b528f6c5a8db8a4aff300463a0839"
checksum = "d52f4c29e2a68ac30c9087e1b772dc9f44a2b66ed44edf2266cf2be9b03dafc1"
dependencies = [
"cc",
"libc",
@@ -8683,7 +8692,7 @@ dependencies = [
"darling 0.20.11",
"heck 0.5.0",
"num-bigint",
"proc-macro-crate 3.4.0",
"proc-macro-crate",
"proc-macro-error2",
"proc-macro2",
"quote",
@@ -9252,7 +9261,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -10331,16 +10340,6 @@ dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [
"once_cell",
"toml_edit 0.19.15",
]
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
@@ -10665,7 +10664,7 @@ dependencies = [
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.35",
"socket2 0.6.2",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -10674,9 +10673,9 @@ dependencies = [
[[package]]
name = "quinn-proto"
version = "0.11.13"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"aws-lc-rs",
"bytes",
@@ -10703,16 +10702,16 @@ dependencies = [
"cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2 0.6.2",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.44"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -11094,9 +11093,12 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "relative-path"
version = "1.9.3"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0"
dependencies = [
"serde",
]
[[package]]
name = "rend"
@@ -11390,9 +11392,9 @@ dependencies = [
[[package]]
name = "rquickjs"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c"
checksum = "c50dc6d6c587c339edb4769cf705867497a2baf0eca8b4645fa6ecd22f02c77a"
dependencies = [
"rquickjs-core",
"rquickjs-macro",
@@ -11400,26 +11402,27 @@ dependencies = [
[[package]]
name = "rquickjs-core"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b"
checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1"
dependencies = [
"async-lock",
"hashbrown 0.16.0",
"relative-path",
"rquickjs-sys",
]
[[package]]
name = "rquickjs-macro"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6041104330c019fcd936026ae05e2446f5e8a2abef329d924f25424b7052a2f3"
checksum = "7106215ff41a5677b104906a13e1a440b880f4b6362b5dc4f3978c267fad2b80"
dependencies = [
"convert_case 0.6.0",
"convert_case 0.10.0",
"fnv",
"ident_case",
"indexmap 2.11.1",
"proc-macro-crate 1.3.1",
"proc-macro-crate",
"proc-macro2",
"quote",
"rquickjs-core",
@@ -11428,9 +11431,9 @@ dependencies = [
[[package]]
name = "rquickjs-sys"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a"
checksum = "27344601ef27460e82d6a4e1ecb9e7e99f518122095f3c51296da8e9be2b9d83"
dependencies = [
"cc",
]
@@ -12674,12 +12677,12 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.6.2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -14459,7 +14462,7 @@ dependencies = [
"indexmap 2.11.1",
"toml_datetime 0.7.0",
"toml_parser",
"winnow 0.7.14",
"winnow 0.7.15",
]
[[package]]
@@ -14468,7 +14471,7 @@ version = "1.0.9+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
dependencies = [
"winnow 0.7.14",
"winnow 0.7.15",
]
[[package]]
@@ -15738,7 +15741,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15770,6 +15773,7 @@ dependencies = [
"sql-builder",
"sqlx",
"strum 0.27.2",
"tar",
"tempfile",
"tikv-jemalloc-ctl",
"tikv-jemalloc-sys",
@@ -15795,14 +15799,16 @@ dependencies = [
"windmill-queue",
"windmill-runtime-nativets",
"windmill-test-utils",
"windmill-types",
"windmill-worker",
"windmill-worker-volumes",
"windows-service",
"windows-sys 0.52.0",
]
[[package]]
name = "windmill-alerting"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15815,7 +15821,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"argon2",
@@ -15843,6 +15849,7 @@ dependencies = [
"dashmap 6.1.0",
"datafusion",
"ed25519-dalek",
"eventsource-stream",
"flate2",
"futures",
"git-version",
@@ -15949,11 +15956,12 @@ dependencies = [
"windmill-trigger-websocket",
"windmill-types",
"windmill-worker",
"windmill-worker-volumes",
]
[[package]]
name = "windmill-api-agent-workers"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15976,7 +15984,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15989,7 +15997,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16015,7 +16023,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16025,7 +16033,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16042,7 +16050,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -16065,7 +16073,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16088,7 +16096,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16104,7 +16112,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16124,7 +16132,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16144,7 +16152,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16158,7 +16166,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16185,7 +16193,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16210,7 +16218,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"flate2",
@@ -16228,7 +16236,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16249,7 +16257,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16269,7 +16277,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16299,7 +16307,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16326,7 +16334,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"lazy_static",
"serde",
@@ -16338,7 +16346,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16361,7 +16369,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16375,7 +16383,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16383,6 +16391,7 @@ dependencies = [
"http 1.4.0",
"hyper 1.8.1",
"lazy_static",
"magic-crypt",
"regex",
"serde",
"serde_json",
@@ -16405,7 +16414,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"chrono",
"lazy_static",
@@ -16419,7 +16428,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16438,7 +16447,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16537,7 +16546,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16556,7 +16565,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"regex",
"serde",
@@ -16571,7 +16580,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16595,7 +16604,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"futures",
@@ -16612,7 +16621,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16628,7 +16637,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16649,7 +16658,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16680,7 +16689,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16704,7 +16713,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-stream",
@@ -16738,7 +16747,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"futures",
@@ -16756,7 +16765,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16765,7 +16774,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16777,7 +16786,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16789,7 +16798,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"gosyn",
@@ -16801,7 +16810,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16813,7 +16822,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16825,7 +16834,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16836,7 +16845,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16847,7 +16856,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16860,7 +16869,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16884,7 +16893,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16898,7 +16907,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16915,7 +16924,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16930,7 +16939,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16947,9 +16956,25 @@ dependencies = [
"windmill-parser-sql",
]
[[package]]
name = "windmill-parser-wac"
version = "1.653.0"
dependencies = [
"anyhow",
"rustpython-ast",
"rustpython-parser",
"serde",
"serde_json",
"sha2 0.10.9",
"swc_common",
"swc_ecma_ast",
"swc_ecma_parser",
"swc_ecma_visit",
]
[[package]]
name = "windmill-parser-yaml"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"serde",
@@ -16960,7 +16985,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16997,7 +17022,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"const_format",
@@ -17035,7 +17060,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17046,7 +17071,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17075,7 +17100,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -17098,7 +17123,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17131,7 +17156,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17151,7 +17176,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17185,7 +17210,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17220,7 +17245,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17243,7 +17268,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17267,7 +17292,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-nats",
@@ -17291,7 +17316,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17326,7 +17351,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17354,7 +17379,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17377,7 +17402,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17395,7 +17420,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.649.0"
version = "1.653.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17495,9 +17520,28 @@ dependencies = [
"windmill-queue",
"windmill-runtime-nativets",
"windmill-types",
"windmill-worker-volumes",
"yaml-rust",
]
[[package]]
name = "windmill-worker-volumes"
version = "1.653.0"
dependencies = [
"bytes",
"futures",
"lazy_static",
"md-5 0.10.6",
"object_store",
"regex",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"windmill-common",
]
[[package]]
name = "windows"
version = "0.56.0"
@@ -18082,9 +18126,9 @@ dependencies = [
[[package]]
name = "winnow"
version = "0.7.14"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
@@ -18365,18 +18409,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.40"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.40"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
dependencies = [
"proc-macro2",
"quote",
+11 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.649.0"
version = "1.653.0"
authors.workspace = true
edition.workspace = true
@@ -68,15 +68,17 @@ members = [
"./parsers/windmill-parser-bash",
"./parsers/windmill-parser-py",
"./parsers/windmill-parser-py-imports",
"./parsers/windmill-parser-wac",
"./parsers/windmill-sql-datatype-parser-wasm",
"./parsers/windmill-parser-yaml", "windmill-macros", "parsers/windmill-parser-nu",
"./windmill-worker-volumes",
"./windmill-test-utils",
"./windmill-api-integration-tests",
]
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.649.0"
version = "1.653.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -250,10 +252,13 @@ reqwest.workspace = true
windmill-queue = { workspace = true, features = ["failpoints"] }
windmill-dep-map.workspace = true
windmill-test-utils.workspace = true
windmill-worker-volumes.workspace = true
windmill-types.workspace = true
axum.workspace = true
serde.workspace = true
windmill-api-client.workspace = true
tempfile.workspace = true
tar.workspace = true
windmill-parser-ts.workspace = true
rumqttc.workspace = true
rdkafka.workspace = true
@@ -267,6 +272,7 @@ aws-credential-types.workspace = true
windmill-api = { path = "./windmill-api", default-features = false }
windmill-queue = { path = "./windmill-queue" }
windmill-worker = { path = "./windmill-worker" }
windmill-worker-volumes = { path = "./windmill-worker-volumes" }
windmill-dep-map = { path = "./windmill-dep-map" }
windmill-types = { path = "./windmill-types" }
windmill-common = { path = "./windmill-common", default-features = false }
@@ -327,6 +333,7 @@ windmill-parser-bash = { path = "./parsers/windmill-parser-bash" }
windmill-parser-sql = { path = "./parsers/windmill-parser-sql" }
windmill-parser-graphql = { path = "./parsers/windmill-parser-graphql" }
windmill-parser-php = { path = "./parsers/windmill-parser-php" }
windmill-parser-wac = { path = "./parsers/windmill-parser-wac" }
windmill-jseval = { path = "./windmill-jseval" }
windmill-runtime-nativets = { path = "./windmill-runtime-nativets" }
windmill-api-client = { path = "./windmill-api-client" }
@@ -439,6 +446,7 @@ base64 = "^0.22.1"
base32 = "^0"
hmac = "0.12.1"
sha2 = "0.10.6"
md-5 = "0.10.6"
sha1 = "0.10.6"
sqlx = { version = "0.8.0", features = [
"macros",
@@ -512,7 +520,7 @@ nu-parser = { version = "0.101.0", default-features = false }
globset = "0.4.16"
croner = "2.2.0"
rmcp = { version = "=0.15.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
rquickjs = { version = "0.8", features = ["futures", "parallel", "macro"] }
rquickjs = { version = "0.11", features = ["futures", "parallel", "macro"] }
process-wrap = { version = "8.2.1", features = ["tokio1"] }
systemstat = "0.2.4"
+1 -1
View File
@@ -1 +1 @@
6fd5a2ce908235a17975ad4dbdf0051cd89334f3
09dfb247f6f59c61b7f2431932c4557fb26c22d8
@@ -0,0 +1 @@
DROP TABLE IF EXISTS volume;
@@ -0,0 +1,22 @@
-- Add 'volume' to the asset_kind enum
ALTER TYPE asset_kind ADD VALUE IF NOT EXISTS 'volume';
-- Volume metadata table
CREATE TABLE volume (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
file_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(255) NOT NULL,
updated_at TIMESTAMPTZ,
updated_by VARCHAR(255),
description TEXT NOT NULL DEFAULT '',
lease_until TIMESTAMPTZ,
leased_by VARCHAR(255),
last_used_at TIMESTAMPTZ,
extra_perms JSONB NOT NULL DEFAULT '{}',
PRIMARY KEY (workspace_id, name)
);
CREATE INDEX idx_volume_last_used ON volume(workspace_id, last_used_at);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS token_expiry_notification;
@@ -0,0 +1,8 @@
-- Tracks pending expiry notifications: row exists = not yet notified.
-- Deleted once the notification is sent. Orphaned rows are harmless (filtered out by the join).
CREATE TABLE token_expiry_notification (
token VARCHAR(255) PRIMARY KEY,
expiration TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_token_expiry_notification_expiration ON token_expiry_notification (expiration);
@@ -0,0 +1,2 @@
ALTER TABLE job_stats DROP COLUMN IF EXISTS timeseries_start;
ALTER TABLE job_stats DROP COLUMN IF EXISTS offsets_cs;
@@ -0,0 +1,5 @@
-- Store timeseries timestamps as a start time + integer centisecond offsets
-- instead of full TIMESTAMPTZ[] arrays. Saves ~4 bytes per data point.
-- i32 centiseconds gives ~248 days of range with 10ms precision.
ALTER TABLE job_stats ADD COLUMN IF NOT EXISTS timeseries_start TIMESTAMPTZ;
ALTER TABLE job_stats ADD COLUMN IF NOT EXISTS offsets_cs INTEGER[];
@@ -296,11 +296,14 @@ pub fn parse_python_signature(
// Check if main function was found
if params.is_none() {
let is_wac_v2 = (code.contains("@workflow") || code.contains("workflow("))
&& (code.contains("@task") || code.contains("task("))
&& (code.contains("import wmill") || code.contains("from wmill"));
return Ok(MainArgSignature {
star_args: false,
star_kwargs: false,
args: vec![],
no_main_func: Some(true),
no_main_func: Some(!is_wac_v2),
has_preprocessor: Some(has_preprocessor),
});
}
+33 -1
View File
@@ -238,7 +238,7 @@ lazy_static::lazy_static! {
// used for `unsafe` sql interpolation
// -- %%name%% (type) = default
static ref RE_ARG_SQL_INTERPOLATION: Regex = Regex::new(r#"(?m)^--\s*%%([a-z_][a-z0-9_]*)%%\s*([\s\w\/]+)?(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
static ref RE_ARG_SQL_INTERPOLATION: Regex = Regex::new(r#"(?m)^--\s*%%([a-z_][a-z0-9_]*)%%[ \t]*([\w][\w \t\/]*)?(?: ?\= ?(.+))? *(?:\r|\n|$)"#).unwrap();
}
fn parsed_default(parsed_typ: &Typ, default: String) -> Option<serde_json::Value> {
@@ -1547,4 +1547,36 @@ SELECT $1::integer;
Ok(())
}
#[test]
fn test_parse_pgsql_safe_interpolated_args() -> anyhow::Result<()> {
// There was a bug where enum would be "angrycreative"/"bishop"/"test SELECT x"
let code = r#"
-- %%table_name%% angrycreative/bishop/test
SELECT x
"#;
assert_eq!(
parse_pgsql_sig(code)?,
MainArgSignature {
star_args: false,
star_kwargs: false,
args: vec![Arg {
otyp: Some("__sanitized_enum__".to_string()),
name: "table_name".to_string(),
typ: Typ::Str(Some(vec![
"angrycreative".to_string(),
"bishop".to_string(),
"test".to_string()
])),
default: None,
has_default: false,
oidx: None,
},],
no_main_func: None,
has_preprocessor: None
}
);
Ok(())
}
}
+11 -3
View File
@@ -261,7 +261,9 @@ pub fn parse_deno_signature(
for specifier in &named_export.specifiers {
if let swc_ecma_ast::ExportSpecifier::Named(spec) = specifier {
let export_name = match &spec.exported {
Some(swc_ecma_ast::ModuleExportName::Ident(ident)) => ident.sym.as_ref(),
Some(swc_ecma_ast::ModuleExportName::Ident(ident)) => {
ident.sym.as_ref()
}
Some(swc_ecma_ast::ModuleExportName::Str(s)) => s.value.as_ref(),
None => match &spec.orig {
swc_ecma_ast::ModuleExportName::Ident(ident) => ident.sym.as_ref(),
@@ -315,7 +317,11 @@ pub fn parse_deno_signature(
let mut c: u16 = 0;
let no_main_func = entrypoint_params.is_none();
let is_wac_v2 = entrypoint_params.is_none()
&& code.contains("workflow(")
&& code.contains("task(")
&& code.contains("windmill-client");
let no_main_func = entrypoint_params.is_none() && !is_wac_v2;
let mut type_resolver = HashMap::new();
let r = MainArgSignature {
star_args: false,
@@ -833,7 +839,9 @@ fn tstype_to_typ(
false,
),
symbol @ _ if symbol.starts_with("DynMultiselect_") => (
Typ::DynMultiselect(symbol.strip_prefix("DynMultiselect_").unwrap().to_string()),
Typ::DynMultiselect(
symbol.strip_prefix("DynMultiselect_").unwrap().to_string(),
),
false,
),
symbol @ _ => {
@@ -0,0 +1,21 @@
[package]
name = "windmill-parser-wac"
version.workspace = true
edition.workspace = true
authors.workspace = true
[lib]
name = "windmill_parser_wac"
path = "./src/lib.rs"
[dependencies]
rustpython-parser.workspace = true
rustpython-ast = { version = "0.4.0", features = ["visitor"] }
swc_common.workspace = true
swc_ecma_parser.workspace = true
swc_ecma_ast.workspace = true
swc_ecma_visit.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
sha2.workspace = true
@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WorkflowDag {
pub nodes: Vec<DagNode>,
pub edges: Vec<DagEdge>,
pub params: Vec<Param>,
pub source_hash: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Param {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub typ: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DagNode {
pub id: String,
pub node_type: DagNodeType,
pub label: String,
pub line: usize,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(tag = "type")]
pub enum DagNodeType {
Step { name: String, script: String },
Branch { condition_source: String },
ParallelStart,
ParallelEnd,
LoopStart { iter_source: String },
LoopEnd,
Return,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DagEdge {
pub from: String,
pub to: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
@@ -0,0 +1,32 @@
pub mod dag;
pub mod python;
pub mod typescript;
pub mod validation;
use dag::WorkflowDag;
use validation::CompileError;
#[derive(Debug, serde::Serialize)]
#[serde(tag = "type")]
pub enum ParseResult {
#[serde(rename = "success")]
Success(WorkflowDag),
#[serde(rename = "error")]
Error { errors: Vec<CompileError> },
}
pub fn parse_workflow(code: &str, language: &str) -> ParseResult {
let result = match language {
"python" | "python3" | "py" => python::parse_python_workflow(code),
"typescript" | "ts" | "deno" | "bun" => typescript::parse_ts_workflow(code),
_ => Err(vec![CompileError {
message: format!("Unsupported language: {language}"),
line: 0,
}]),
};
match result {
Ok(dag) => ParseResult::Success(dag),
Err(errors) => ParseResult::Error { errors },
}
}
@@ -0,0 +1,717 @@
use std::collections::HashMap;
use rustpython_parser::{
ast::{
Expr, ExprAwait, ExprCall, ExprName, Stmt, StmtExpr, StmtFor, StmtIf, StmtReturn, StmtTry,
StmtTryStar, StmtWhile,
},
Parse,
};
use crate::dag::{DagEdge, DagNode, DagNodeType, Param, WorkflowDag};
use crate::validation::{self, CompileError};
struct LineIndex {
newline_offsets: Vec<usize>,
}
impl LineIndex {
fn new(source: &str) -> Self {
let mut offsets = vec![0];
for (i, c) in source.char_indices() {
if c == '\n' {
offsets.push(i + 1);
}
}
Self { newline_offsets: offsets }
}
fn line_of(&self, byte_offset: usize) -> usize {
match self.newline_offsets.binary_search(&byte_offset) {
Ok(line) => line + 1,
Err(line) => line,
}
}
}
/// Maps task function name → optional external path (from `@task(path="...")`)
type TaskFunctions = HashMap<String, Option<String>>;
/// First pass: scan top-level `@task async def foo(...)` declarations.
fn collect_task_functions(stmts: &[Stmt]) -> TaskFunctions {
let mut tasks = HashMap::new();
for stmt in stmts {
if let Stmt::AsyncFunctionDef(func) = stmt {
for dec in &func.decorator_list {
match dec {
// @task (bare decorator)
Expr::Name(ExprName { id, .. }) if id.as_str() == "task" => {
tasks.insert(func.name.to_string(), None);
}
// @task(path="...")
Expr::Call(call) => {
if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() {
if id.as_str() == "task" {
let path = extract_task_path_kwarg(call);
tasks.insert(func.name.to_string(), path);
}
}
}
_ => {}
}
}
}
}
tasks
}
/// Extract the `path=` keyword argument from a `@task(path="...")` call.
fn extract_task_path_kwarg(call: &ExprCall) -> Option<String> {
for kw in &call.keywords {
if let Some(ref arg) = kw.arg {
if arg.as_str() == "path" {
if let Expr::Constant(c) = &kw.value {
if let rustpython_parser::ast::Constant::Str(s) = &c.value {
return Some(s.to_string());
}
}
}
}
}
None
}
struct WacWalker {
nodes: Vec<DagNode>,
edges: Vec<DagEdge>,
errors: Vec<CompileError>,
node_counter: usize,
line_index: LineIndex,
task_functions: TaskFunctions,
in_try: bool,
in_while: bool,
in_nested_func: bool,
in_comprehension: bool,
}
impl WacWalker {
fn new(source: &str, task_functions: TaskFunctions) -> Self {
Self {
nodes: Vec::new(),
edges: Vec::new(),
errors: Vec::new(),
node_counter: 0,
line_index: LineIndex::new(source),
task_functions,
in_try: false,
in_while: false,
in_nested_func: false,
in_comprehension: false,
}
}
fn next_id(&mut self) -> String {
let id = format!("step_{}", self.node_counter);
self.node_counter += 1;
id
}
fn add_node(&mut self, node: DagNode) -> String {
let id = node.id.clone();
self.nodes.push(node);
id
}
fn add_edge(&mut self, from: &str, to: &str, label: Option<String>) {
self.edges
.push(DagEdge { from: from.to_string(), to: to.to_string(), label });
}
fn line_of_expr(&self, expr: &Expr) -> usize {
let offset = match expr {
Expr::Call(c) => c.range.start().to_usize(),
Expr::Await(a) => a.range.start().to_usize(),
Expr::Attribute(a) => a.range.start().to_usize(),
Expr::Name(n) => n.range.start().to_usize(),
_ => 0,
};
self.line_index.line_of(offset)
}
fn line_of_stmt(&self, stmt: &Stmt) -> usize {
let offset = match stmt {
Stmt::If(s) => s.range.start().to_usize(),
Stmt::For(s) => s.range.start().to_usize(),
Stmt::While(s) => s.range.start().to_usize(),
Stmt::Return(s) => s.range.start().to_usize(),
Stmt::Expr(s) => s.range.start().to_usize(),
Stmt::Try(s) => s.range.start().to_usize(),
Stmt::TryStar(s) => s.range.start().to_usize(),
Stmt::Assign(s) => s.range.start().to_usize(),
Stmt::AnnAssign(s) => s.range.start().to_usize(),
Stmt::FunctionDef(s) => s.range.start().to_usize(),
Stmt::AsyncFunctionDef(s) => s.range.start().to_usize(),
_ => 0,
};
self.line_index.line_of(offset)
}
/// Check if an expression is a call to a known @task function
fn is_task_fn_call(&self, expr: &Expr) -> bool {
if let Expr::Call(call) = expr {
if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() {
return self.task_functions.contains_key(id.as_str());
}
}
false
}
/// Check if an expression is `asyncio.gather(...)` call
fn is_asyncio_gather_call(expr: &Expr) -> bool {
if let Expr::Call(call) = expr {
if let Expr::Attribute(rustpython_parser::ast::ExprAttribute { value, attr, .. }) =
call.func.as_ref()
{
if attr.as_str() == "gather" {
if let Expr::Name(ExprName { id, .. }) = value.as_ref() {
return id.as_str() == "asyncio";
}
}
}
}
false
}
/// Extract step name and script from a task function call.
/// Name = function name, script = task_path or function name.
fn extract_step_info_from_task_call(&self, call: &ExprCall) -> Option<(String, String)> {
if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() {
let name = id.to_string();
let script = self
.task_functions
.get(id.as_str())
.and_then(|p| p.clone())
.unwrap_or_else(|| name.clone());
Some((name, script))
} else {
None
}
}
fn expr_to_source(expr: &Expr) -> String {
match expr {
Expr::Compare(c) => {
let left = Self::expr_to_source(&c.left);
if let Some(comparator) = c.comparators.first() {
let right = Self::expr_to_source(comparator);
let op = match c.ops.first() {
Some(rustpython_parser::ast::CmpOp::Gt) => ">",
Some(rustpython_parser::ast::CmpOp::Lt) => "<",
Some(rustpython_parser::ast::CmpOp::GtE) => ">=",
Some(rustpython_parser::ast::CmpOp::LtE) => "<=",
Some(rustpython_parser::ast::CmpOp::Eq) => "==",
Some(rustpython_parser::ast::CmpOp::NotEq) => "!=",
Some(rustpython_parser::ast::CmpOp::In) => "in",
Some(rustpython_parser::ast::CmpOp::NotIn) => "not in",
Some(rustpython_parser::ast::CmpOp::Is) => "is",
Some(rustpython_parser::ast::CmpOp::IsNot) => "is not",
None => "?",
};
format!("{left} {op} {right}")
} else {
left
}
}
Expr::Subscript(s) => {
let value = Self::expr_to_source(&s.value);
let slice = Self::expr_to_source(&s.slice);
format!("{value}[{slice}]")
}
Expr::Attribute(a) => {
let value = Self::expr_to_source(&a.value);
format!("{value}.{}", a.attr)
}
Expr::Name(n) => n.id.to_string(),
Expr::Constant(c) => match &c.value {
rustpython_parser::ast::Constant::Str(s) => format!("\"{s}\""),
rustpython_parser::ast::Constant::Int(i) => i.to_string(),
rustpython_parser::ast::Constant::Float(f) => f.to_string(),
rustpython_parser::ast::Constant::Bool(b) => b.to_string(),
rustpython_parser::ast::Constant::None => "None".to_string(),
_ => "...".to_string(),
},
_ => "...".to_string(),
}
}
/// Check if a statement body contains any task function calls (recursively)
fn body_contains_step(&self, body: &[Stmt]) -> bool {
for stmt in body {
if self.stmt_contains_step(stmt) {
return true;
}
}
false
}
fn stmt_contains_step(&self, stmt: &Stmt) -> bool {
match stmt {
Stmt::Expr(StmtExpr { value, .. }) => self.expr_contains_step(value),
Stmt::Assign(a) => self.expr_contains_step(&a.value),
Stmt::If(s) => self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse),
Stmt::For(s) => self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse),
Stmt::While(s) => {
self.body_contains_step(&s.body) || self.body_contains_step(&s.orelse)
}
Stmt::Try(s) => {
self.body_contains_step(&s.body)
|| self.body_contains_step(&s.orelse)
|| self.body_contains_step(&s.finalbody)
|| s.handlers.iter().any(|h| match h {
rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => {
self.body_contains_step(&eh.body)
}
})
}
Stmt::TryStar(s) => {
self.body_contains_step(&s.body)
|| self.body_contains_step(&s.orelse)
|| self.body_contains_step(&s.finalbody)
|| s.handlers.iter().any(|h| match h {
rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => {
self.body_contains_step(&eh.body)
}
})
}
Stmt::Return(_) => false,
_ => false,
}
}
fn expr_contains_step(&self, expr: &Expr) -> bool {
if self.is_task_fn_call(expr) {
return true;
}
match expr {
Expr::Await(ExprAwait { value, .. }) => self.expr_contains_step(value),
Expr::Call(call) => {
if self.is_task_fn_call(&Expr::Call(call.clone())) {
return true;
}
if Self::is_asyncio_gather_call(&Expr::Call(call.clone())) {
return call.args.iter().any(|a| self.expr_contains_step(a));
}
false
}
_ => false,
}
}
/// Walk a list of statements, returning (first_node_id, last_node_id)
fn walk_body(&mut self, body: &[Stmt]) -> Option<(String, String)> {
let mut first_id: Option<String> = None;
let mut prev_id: Option<String> = None;
for stmt in body {
if let Some((stmt_first, stmt_last)) = self.walk_stmt(stmt) {
if let Some(ref prev) = prev_id {
self.add_edge(prev, &stmt_first, None);
}
if first_id.is_none() {
first_id = Some(stmt_first);
}
prev_id = Some(stmt_last);
}
}
match (first_id, prev_id) {
(Some(f), Some(l)) => Some((f, l)),
_ => None,
}
}
fn walk_stmt(&mut self, stmt: &Stmt) -> Option<(String, String)> {
match stmt {
Stmt::Expr(StmtExpr { value, .. }) => self.walk_expr_stmt(value),
Stmt::Assign(a) => self.walk_expr_stmt(&a.value),
Stmt::If(if_stmt) => self.walk_if(if_stmt),
Stmt::For(for_stmt) => self.walk_for(for_stmt),
Stmt::While(while_stmt) => self.walk_while(while_stmt),
Stmt::Try(try_stmt) => self.walk_try(try_stmt),
Stmt::TryStar(try_stmt) => self.walk_try_star(try_stmt),
Stmt::Return(ret) => self.walk_return(ret),
Stmt::FunctionDef(_) | Stmt::AsyncFunctionDef(_) => {
if self.stmt_contains_step(stmt) {
self.errors.push(validation::error_step_in_nested_function(
self.line_of_stmt(stmt),
));
}
None
}
_ => None,
}
}
fn walk_expr_stmt(&mut self, expr: &Expr) -> Option<(String, String)> {
// await task_fn(...)
if let Expr::Await(ExprAwait { value, .. }) = expr {
// await task_fn(...)
if let Expr::Call(call) = value.as_ref() {
if self.is_task_fn_call(&Expr::Call(call.clone())) {
return self.emit_step(call, expr);
}
}
// await asyncio.gather(task_fn(...), task_fn(...), ...)
if Self::is_asyncio_gather_call(value) {
if let Expr::Call(gather_call) = value.as_ref() {
return self.emit_parallel(gather_call, expr);
}
}
}
// Bare task_fn() without await — validation error
if self.is_task_fn_call(expr) {
self.errors
.push(validation::error_missing_await(self.line_of_expr(expr)));
}
None
}
fn emit_step(&mut self, call: &ExprCall, expr: &Expr) -> Option<(String, String)> {
if self.in_try {
self.errors
.push(validation::error_step_in_try(self.line_of_expr(expr)));
return None;
}
if self.in_while {
self.errors
.push(validation::error_step_in_while(self.line_of_expr(expr)));
return None;
}
if self.in_nested_func {
self.errors.push(validation::error_step_in_nested_function(
self.line_of_expr(expr),
));
return None;
}
if self.in_comprehension {
self.errors.push(validation::error_step_in_comprehension(
self.line_of_expr(expr),
));
return None;
}
let (name, script) = self
.extract_step_info_from_task_call(call)
.unwrap_or(("unknown".into(), "unknown".into()));
let id = self.next_id();
let node_id = self.add_node(DagNode {
id: id.clone(),
node_type: DagNodeType::Step { name: name.clone(), script },
label: name,
line: self.line_of_expr(expr),
});
Some((node_id.clone(), node_id))
}
fn emit_parallel(&mut self, gather_call: &ExprCall, expr: &Expr) -> Option<(String, String)> {
if self.in_try {
self.errors
.push(validation::error_step_in_try(self.line_of_expr(expr)));
return None;
}
if self.in_while {
self.errors
.push(validation::error_step_in_while(self.line_of_expr(expr)));
return None;
}
let line = self.line_of_expr(expr);
let start_id = self.next_id();
let start_node_id = self.add_node(DagNode {
id: start_id.clone(),
node_type: DagNodeType::ParallelStart,
label: "parallel".to_string(),
line,
});
let mut step_ids = Vec::new();
for arg in &gather_call.args {
// Each arg should be task_fn(...)
if let Expr::Call(call) = arg {
if self.is_task_fn_call(&Expr::Call(call.clone())) {
let (name, script) = self
.extract_step_info_from_task_call(call)
.unwrap_or(("unknown".into(), "unknown".into()));
let step_id = self.next_id();
let node_id = self.add_node(DagNode {
id: step_id.clone(),
node_type: DagNodeType::Step { name: name.clone(), script },
label: name,
line: self.line_of_expr(arg),
});
self.add_edge(&start_node_id, &node_id, None);
step_ids.push(node_id);
}
}
}
let end_id = self.next_id();
let end_node_id = self.add_node(DagNode {
id: end_id.clone(),
node_type: DagNodeType::ParallelEnd,
label: "join".to_string(),
line,
});
for step_id in &step_ids {
self.add_edge(step_id, &end_node_id, None);
}
Some((start_node_id, end_node_id))
}
fn walk_if(&mut self, if_stmt: &StmtIf) -> Option<(String, String)> {
let has_steps_in_body = self.body_contains_step(&if_stmt.body);
let has_steps_in_else = self.body_contains_step(&if_stmt.orelse);
if !has_steps_in_body && !has_steps_in_else {
return None;
}
let line = self.line_index.line_of(if_stmt.range.start().to_usize());
let condition_source = Self::expr_to_source(&if_stmt.test);
let branch_id = self.next_id();
let branch_node_id = self.add_node(DagNode {
id: branch_id.clone(),
node_type: DagNodeType::Branch { condition_source },
label: "if".to_string(),
line,
});
let merge_id = format!("{branch_id}_merge");
let mut last_ids = Vec::new();
if let Some((true_first, true_last)) = self.walk_body(&if_stmt.body) {
self.add_edge(&branch_node_id, &true_first, Some("true".to_string()));
last_ids.push(true_last);
} else {
last_ids.push(branch_node_id.clone());
}
if !if_stmt.orelse.is_empty() {
if let Some((else_first, else_last)) = self.walk_body(&if_stmt.orelse) {
self.add_edge(&branch_node_id, &else_first, Some("false".to_string()));
last_ids.push(else_last);
} else {
last_ids.push(branch_node_id.clone());
}
}
if last_ids.len() == 1 {
Some((branch_node_id, last_ids.into_iter().next().unwrap()))
} else {
Some((branch_node_id, merge_id))
}
}
fn walk_for(&mut self, for_stmt: &StmtFor) -> Option<(String, String)> {
if !self.body_contains_step(&for_stmt.body) {
return None;
}
let line = self.line_index.line_of(for_stmt.range.start().to_usize());
let iter_source = Self::expr_to_source(&for_stmt.iter);
let start_id = self.next_id();
let start_node_id = self.add_node(DagNode {
id: start_id.clone(),
node_type: DagNodeType::LoopStart { iter_source },
label: "for".to_string(),
line,
});
if let Some((body_first, body_last)) = self.walk_body(&for_stmt.body) {
self.add_edge(&start_node_id, &body_first, None);
self.add_edge(&body_last, &start_node_id, Some("next".to_string()));
}
let end_id = self.next_id();
let end_node_id = self.add_node(DagNode {
id: end_id.clone(),
node_type: DagNodeType::LoopEnd,
label: "end for".to_string(),
line,
});
self.add_edge(&start_node_id, &end_node_id, Some("done".to_string()));
Some((start_node_id, end_node_id))
}
fn walk_while(&mut self, while_stmt: &StmtWhile) -> Option<(String, String)> {
if self.body_contains_step(&while_stmt.body) {
let line = self.line_index.line_of(while_stmt.range.start().to_usize());
self.errors.push(validation::error_step_in_while(line));
}
None
}
fn walk_try(&mut self, try_stmt: &StmtTry) -> Option<(String, String)> {
let has_steps = self.body_contains_step(&try_stmt.body)
|| self.body_contains_step(&try_stmt.orelse)
|| self.body_contains_step(&try_stmt.finalbody)
|| try_stmt.handlers.iter().any(|h| match h {
rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => {
self.body_contains_step(&eh.body)
}
});
if has_steps {
let line = self.line_index.line_of(try_stmt.range.start().to_usize());
self.errors.push(validation::error_step_in_try(line));
}
None
}
fn walk_try_star(&mut self, try_stmt: &StmtTryStar) -> Option<(String, String)> {
let has_steps = self.body_contains_step(&try_stmt.body)
|| self.body_contains_step(&try_stmt.orelse)
|| self.body_contains_step(&try_stmt.finalbody)
|| try_stmt.handlers.iter().any(|h| match h {
rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => {
self.body_contains_step(&eh.body)
}
});
if has_steps {
let line = self.line_index.line_of(try_stmt.range.start().to_usize());
self.errors.push(validation::error_step_in_try(line));
}
None
}
fn walk_return(&mut self, ret: &StmtReturn) -> Option<(String, String)> {
let line = self.line_index.line_of(ret.range.start().to_usize());
let id = self.next_id();
let node_id = self.add_node(DagNode {
id: id.clone(),
node_type: DagNodeType::Return,
label: "return".to_string(),
line,
});
Some((node_id.clone(), node_id))
}
}
/// Extract workflow function parameters (no longer skips ctx)
fn extract_params(args: &rustpython_parser::ast::Arguments) -> Vec<Param> {
let mut params = Vec::new();
for arg_with_default in args.args.iter().chain(args.posonlyargs.iter()) {
let name = arg_with_default.def.arg.to_string();
let typ = arg_with_default
.def
.annotation
.as_ref()
.map(|ann| WacWalker::expr_to_source(ann));
params.push(Param { name, typ });
}
params
}
pub fn parse_python_workflow(code: &str) -> Result<WorkflowDag, Vec<CompileError>> {
let ast = rustpython_parser::ast::Suite::parse(code, "<workflow>")
.map_err(|e| vec![CompileError { message: format!("Parse error: {e}"), line: 0 }])?;
// First pass: collect @task functions
let task_functions = collect_task_functions(&ast);
// Find the @workflow async def
let workflow_fn = ast.iter().find_map(|stmt| {
if let Stmt::AsyncFunctionDef(func) = stmt {
let has_workflow_decorator = func.decorator_list.iter().any(|dec| {
if let Expr::Name(ExprName { id, .. }) = dec {
id.as_str() == "workflow"
} else {
false
}
});
if has_workflow_decorator {
return Some(func);
}
}
// Also check non-async for error reporting
if let Stmt::FunctionDef(func) = stmt {
let has_workflow_decorator = func.decorator_list.iter().any(|dec| {
if let Expr::Name(ExprName { id, .. }) = dec {
id.as_str() == "workflow"
} else {
false
}
});
if has_workflow_decorator {
return None; // Will be reported as not-async below
}
}
None
});
// Check for non-async workflow function
let non_async_workflow = ast.iter().find_map(|stmt| {
if let Stmt::FunctionDef(func) = stmt {
let has_workflow_decorator = func.decorator_list.iter().any(|dec| {
if let Expr::Name(ExprName { id, .. }) = dec {
id.as_str() == "workflow"
} else {
false
}
});
if has_workflow_decorator {
let line_index = LineIndex::new(code);
return Some(line_index.line_of(func.range.start().to_usize()));
}
}
None
});
if let Some(line) = non_async_workflow {
if workflow_fn.is_none() {
return Err(vec![validation::error_not_async(line)]);
}
}
let workflow_fn = workflow_fn.ok_or_else(|| {
vec![CompileError { message: "No @workflow async function found.".to_string(), line: 0 }]
})?;
let params = extract_params(&workflow_fn.args);
let source_hash = compute_source_hash(code);
let mut walker = WacWalker::new(code, task_functions);
walker.walk_body(&workflow_fn.body);
if !walker.errors.is_empty() {
return Err(walker.errors);
}
Ok(WorkflowDag { nodes: walker.nodes, edges: walker.edges, params, source_hash })
}
fn compute_source_hash(code: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(code.as_bytes());
format!("{:x}", hasher.finalize())
}
trait ToUsize {
fn to_usize(self) -> usize;
}
impl ToUsize for rustpython_parser::text_size::TextSize {
fn to_usize(self) -> usize {
u32::from(self) as usize
}
}
@@ -0,0 +1,739 @@
use std::collections::HashMap;
use swc_common::{sync::Lrc, FileName, SourceMap, SourceMapper, Spanned};
use swc_ecma_ast::*;
use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax, TsSyntax};
use crate::dag::{DagEdge, DagNode, DagNodeType, Param, WorkflowDag};
use crate::validation::{self, CompileError};
/// Maps task function name → optional external path (from `task("f/path", ...)`)
type TaskFunctions = HashMap<String, Option<String>>;
/// First pass: scan top-level `const foo = task(async (...) => {})` or
/// `const foo = task("f/path", async (...) => {})` declarations.
fn collect_task_functions(module: &Module) -> TaskFunctions {
let mut tasks = HashMap::new();
for item in &module.body {
// const foo = task(async (...) => { ... })
// const foo = task("f/path", async (...) => { ... })
if let ModuleItem::Stmt(Stmt::Decl(Decl::Var(var_decl))) = item {
for decl in &var_decl.decls {
if let (Some(name), Some(init)) = (extract_var_name(&decl.name), &decl.init) {
if let Some(path) = extract_task_call_info(init) {
tasks.insert(name, path);
}
}
}
}
// export const foo = task(...)
if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item {
if let Decl::Var(var_decl) = &export.decl {
for decl in &var_decl.decls {
if let (Some(name), Some(init)) = (extract_var_name(&decl.name), &decl.init) {
if let Some(path) = extract_task_call_info(init) {
tasks.insert(name, path);
}
}
}
}
}
}
tasks
}
/// Extract variable name from a pattern (simple ident case)
fn extract_var_name(pat: &Pat) -> Option<String> {
if let Pat::Ident(BindingIdent { id, .. }) = pat {
Some(id.sym.to_string())
} else {
None
}
}
/// Check if expr is `task(async fn)` or `task("path", async fn)`.
/// Returns Some(optional_path) if it is a task() call.
fn extract_task_call_info(expr: &Expr) -> Option<Option<String>> {
if let Expr::Call(call) = expr {
if let Callee::Expr(callee) = &call.callee {
if let Expr::Ident(ident) = callee.as_ref() {
if ident.sym.as_ref() == "task" {
// task("f/path", async fn) or task(async fn)
if call.args.len() == 2 {
// task("f/path", async fn)
let path = extract_string_lit(&call.args[0].expr);
return Some(path);
} else if call.args.len() == 1 {
// task(async fn)
return Some(None);
}
}
}
}
}
None
}
struct TsWacWalker {
nodes: Vec<DagNode>,
edges: Vec<DagEdge>,
errors: Vec<CompileError>,
node_counter: usize,
cm: Lrc<SourceMap>,
task_functions: TaskFunctions,
in_try: bool,
in_while: bool,
in_nested_func: bool,
}
impl TsWacWalker {
fn new(cm: Lrc<SourceMap>, task_functions: TaskFunctions) -> Self {
Self {
nodes: Vec::new(),
edges: Vec::new(),
errors: Vec::new(),
node_counter: 0,
cm,
task_functions,
in_try: false,
in_while: false,
in_nested_func: false,
}
}
fn next_id(&mut self) -> String {
let id = format!("step_{}", self.node_counter);
self.node_counter += 1;
id
}
fn add_node(&mut self, node: DagNode) -> String {
let id = node.id.clone();
self.nodes.push(node);
id
}
fn add_edge(&mut self, from: &str, to: &str, label: Option<String>) {
self.edges
.push(DagEdge { from: from.to_string(), to: to.to_string(), label });
}
fn span_line(&self, span: swc_common::Span) -> usize {
let loc = self.cm.lookup_char_pos(span.lo);
loc.line
}
/// Check if expr is a call to a known task function
fn is_task_call(&self, expr: &Expr) -> bool {
if let Expr::Call(call) = expr {
if let Callee::Expr(callee) = &call.callee {
if let Expr::Ident(ident) = callee.as_ref() {
return self.task_functions.contains_key(ident.sym.as_ref());
}
}
}
false
}
/// Check if expr is `Promise.all([...])`
fn is_promise_all(expr: &Expr) -> bool {
if let Expr::Call(call) = expr {
if let Callee::Expr(callee) = &call.callee {
if let Expr::Member(MemberExpr { obj, prop: MemberProp::Ident(prop), .. }) =
callee.as_ref()
{
if prop.sym.as_ref() == "all" {
if let Expr::Ident(ident) = obj.as_ref() {
return ident.sym.as_ref() == "Promise";
}
}
}
}
}
false
}
/// Extract step name and script from a task function call.
/// Name = function name, script = task_path or function name.
fn extract_step_info_from_task_call(&self, call: &CallExpr) -> Option<(String, String)> {
if let Callee::Expr(callee) = &call.callee {
if let Expr::Ident(ident) = callee.as_ref() {
let name = ident.sym.to_string();
let script = self
.task_functions
.get(ident.sym.as_ref())
.and_then(|p| p.clone())
.unwrap_or_else(|| name.clone());
return Some((name, script));
}
}
None
}
fn expr_to_source(&self, expr: &Expr) -> String {
let span = expr.span();
self.cm
.span_to_snippet(span)
.unwrap_or_else(|_| "...".to_string())
}
fn body_contains_step(&self, stmts: &[Stmt]) -> bool {
stmts.iter().any(|s| self.stmt_contains_step(s))
}
fn stmt_contains_step(&self, stmt: &Stmt) -> bool {
match stmt {
Stmt::Expr(expr_stmt) => self.expr_contains_step(&expr_stmt.expr),
Stmt::Decl(Decl::Var(var_decl)) => var_decl.decls.iter().any(|d| {
d.init
.as_ref()
.map_or(false, |init| self.expr_contains_step(init))
}),
Stmt::If(if_stmt) => {
self.stmt_contains_step(&if_stmt.cons)
|| if_stmt
.alt
.as_ref()
.map_or(false, |alt| self.stmt_contains_step(alt))
}
Stmt::Block(block) => self.body_contains_step(&block.stmts),
Stmt::For(for_stmt) => self.stmt_contains_step(&for_stmt.body),
Stmt::ForIn(for_in) => self.stmt_contains_step(&for_in.body),
Stmt::ForOf(for_of) => self.stmt_contains_step(&for_of.body),
Stmt::While(while_stmt) => self.stmt_contains_step(&while_stmt.body),
Stmt::Try(try_stmt) => {
self.body_contains_step(&try_stmt.block.stmts)
|| try_stmt
.handler
.as_ref()
.map_or(false, |h| self.body_contains_step(&h.body.stmts))
|| try_stmt
.finalizer
.as_ref()
.map_or(false, |f| self.body_contains_step(&f.stmts))
}
Stmt::Return(ret) => ret
.arg
.as_ref()
.map_or(false, |arg| self.expr_contains_step(arg)),
_ => false,
}
}
fn expr_contains_step(&self, expr: &Expr) -> bool {
if self.is_task_call(expr) {
return true;
}
match expr {
Expr::Await(await_expr) => self.expr_contains_step(&await_expr.arg),
Expr::Call(call) => {
if Self::is_promise_all(&Expr::Call(call.clone())) {
return call.args.iter().any(|a| self.expr_contains_step(&a.expr));
}
false
}
Expr::Paren(p) => self.expr_contains_step(&p.expr),
_ => false,
}
}
fn walk_body(&mut self, stmts: &[Stmt]) -> Option<(String, String)> {
let mut first_id: Option<String> = None;
let mut prev_id: Option<String> = None;
for stmt in stmts {
if let Some((stmt_first, stmt_last)) = self.walk_stmt(stmt) {
if let Some(ref prev) = prev_id {
self.add_edge(prev, &stmt_first, None);
}
if first_id.is_none() {
first_id = Some(stmt_first);
}
prev_id = Some(stmt_last);
}
}
match (first_id, prev_id) {
(Some(f), Some(l)) => Some((f, l)),
_ => None,
}
}
fn walk_stmt(&mut self, stmt: &Stmt) -> Option<(String, String)> {
match stmt {
Stmt::Expr(expr_stmt) => self.walk_expr_stmt(&expr_stmt.expr),
Stmt::Decl(Decl::Var(var_decl)) => {
// const result = await task_fn(...)
for decl in &var_decl.decls {
if let Some(init) = &decl.init {
if let Some(result) = self.walk_expr_stmt(init) {
return Some(result);
}
}
}
None
}
Stmt::If(if_stmt) => self.walk_if(if_stmt),
Stmt::For(for_stmt) => self.walk_for_stmt(for_stmt),
Stmt::ForIn(for_in) => self.walk_for_in(for_in),
Stmt::ForOf(for_of) => self.walk_for_of(for_of),
Stmt::While(while_stmt) => self.walk_while(while_stmt),
Stmt::Try(try_stmt) => self.walk_try(try_stmt),
Stmt::Block(block) => self.walk_body(&block.stmts),
Stmt::Return(ret) => self.walk_return(ret),
Stmt::Decl(Decl::Fn(_)) => {
if self.stmt_contains_step(stmt) {
self.errors.push(validation::error_step_in_nested_function(
self.span_line(stmt.span()),
));
}
None
}
_ => None,
}
}
fn walk_expr_stmt(&mut self, expr: &Expr) -> Option<(String, String)> {
// await task_fn(...)
if let Expr::Await(await_expr) = expr {
if let Expr::Call(call) = await_expr.arg.as_ref() {
if self.is_task_call(&Expr::Call(call.clone())) {
return self.emit_step(call, expr);
}
}
// await Promise.all([task_fn(...), ...])
if Self::is_promise_all(&await_expr.arg) {
if let Expr::Call(promise_call) = await_expr.arg.as_ref() {
return self.emit_parallel(promise_call, expr);
}
}
}
// Bare task_fn() without await
if self.is_task_call(expr) {
self.errors
.push(validation::error_missing_await(self.span_line(expr.span())));
}
None
}
fn emit_step(&mut self, call: &CallExpr, expr: &Expr) -> Option<(String, String)> {
if self.in_try {
self.errors
.push(validation::error_step_in_catch(self.span_line(expr.span())));
return None;
}
if self.in_while {
self.errors
.push(validation::error_step_in_while(self.span_line(expr.span())));
return None;
}
if self.in_nested_func {
self.errors.push(validation::error_step_in_nested_function(
self.span_line(expr.span()),
));
return None;
}
let (name, script) = self
.extract_step_info_from_task_call(call)
.unwrap_or(("unknown".into(), "unknown".into()));
let id = self.next_id();
let node_id = self.add_node(DagNode {
id: id.clone(),
node_type: DagNodeType::Step { name: name.clone(), script },
label: name,
line: self.span_line(expr.span()),
});
Some((node_id.clone(), node_id))
}
fn emit_parallel(&mut self, promise_call: &CallExpr, expr: &Expr) -> Option<(String, String)> {
if self.in_try {
self.errors
.push(validation::error_step_in_catch(self.span_line(expr.span())));
return None;
}
if self.in_while {
self.errors
.push(validation::error_step_in_while(self.span_line(expr.span())));
return None;
}
let line = self.span_line(expr.span());
let start_id = self.next_id();
let start_node_id = self.add_node(DagNode {
id: start_id.clone(),
node_type: DagNodeType::ParallelStart,
label: "parallel".to_string(),
line,
});
let mut step_ids = Vec::new();
// Promise.all takes an array as first argument
if let Some(first_arg) = promise_call.args.first() {
if let Expr::Array(ArrayLit { elems, .. }) = first_arg.expr.as_ref() {
for elem in elems.iter().flatten() {
if let Expr::Call(call) = elem.expr.as_ref() {
if self.is_task_call(&Expr::Call(call.clone())) {
let (name, script) = self
.extract_step_info_from_task_call(call)
.unwrap_or(("unknown".into(), "unknown".into()));
let step_id = self.next_id();
let node_id = self.add_node(DagNode {
id: step_id.clone(),
node_type: DagNodeType::Step { name: name.clone(), script },
label: name,
line: self.span_line(elem.expr.span()),
});
self.add_edge(&start_node_id, &node_id, None);
step_ids.push(node_id);
}
}
}
}
}
let end_id = self.next_id();
let end_node_id = self.add_node(DagNode {
id: end_id.clone(),
node_type: DagNodeType::ParallelEnd,
label: "join".to_string(),
line,
});
for step_id in &step_ids {
self.add_edge(step_id, &end_node_id, None);
}
Some((start_node_id, end_node_id))
}
fn walk_if(&mut self, if_stmt: &IfStmt) -> Option<(String, String)> {
let has_steps_cons = self.stmt_contains_step(&if_stmt.cons);
let has_steps_alt = if_stmt
.alt
.as_ref()
.map_or(false, |a| self.stmt_contains_step(a));
if !has_steps_cons && !has_steps_alt {
return None;
}
let line = self.span_line(if_stmt.span);
let condition_source = self.expr_to_source(&if_stmt.test);
let branch_id = self.next_id();
let branch_node_id = self.add_node(DagNode {
id: branch_id.clone(),
node_type: DagNodeType::Branch { condition_source },
label: "if".to_string(),
line,
});
let mut last_ids = Vec::new();
// True branch
if let Some((true_first, true_last)) = self.walk_stmt(&if_stmt.cons) {
self.add_edge(&branch_node_id, &true_first, Some("true".to_string()));
last_ids.push(true_last);
} else {
last_ids.push(branch_node_id.clone());
}
// False branch
if let Some(alt) = &if_stmt.alt {
if let Some((else_first, else_last)) = self.walk_stmt(alt) {
self.add_edge(&branch_node_id, &else_first, Some("false".to_string()));
last_ids.push(else_last);
} else {
last_ids.push(branch_node_id.clone());
}
}
if last_ids.len() == 1 {
Some((branch_node_id, last_ids.into_iter().next().unwrap()))
} else {
let merge_id = format!("{branch_id}_merge");
Some((branch_node_id, merge_id))
}
}
fn walk_for_stmt(&mut self, for_stmt: &ForStmt) -> Option<(String, String)> {
if !self.stmt_contains_step(&for_stmt.body) {
return None;
}
self.walk_loop_body(&for_stmt.body, for_stmt.span, "for")
}
fn walk_for_in(&mut self, for_in: &ForInStmt) -> Option<(String, String)> {
if !self.stmt_contains_step(&for_in.body) {
return None;
}
let iter_source = self.expr_to_source(&for_in.right);
self.walk_loop_body_with_iter(&for_in.body, for_in.span, &iter_source)
}
fn walk_for_of(&mut self, for_of: &ForOfStmt) -> Option<(String, String)> {
if !self.stmt_contains_step(&for_of.body) {
return None;
}
let iter_source = self.expr_to_source(&for_of.right);
self.walk_loop_body_with_iter(&for_of.body, for_of.span, &iter_source)
}
fn walk_loop_body(
&mut self,
body: &Stmt,
span: swc_common::Span,
_label: &str,
) -> Option<(String, String)> {
self.walk_loop_body_with_iter(body, span, "...")
}
fn walk_loop_body_with_iter(
&mut self,
body: &Stmt,
span: swc_common::Span,
iter_source: &str,
) -> Option<(String, String)> {
let line = self.span_line(span);
let start_id = self.next_id();
let start_node_id = self.add_node(DagNode {
id: start_id.clone(),
node_type: DagNodeType::LoopStart { iter_source: iter_source.to_string() },
label: "for".to_string(),
line,
});
if let Some((body_first, body_last)) = self.walk_stmt(body) {
self.add_edge(&start_node_id, &body_first, None);
self.add_edge(&body_last, &start_node_id, Some("next".to_string()));
}
let end_id = self.next_id();
let end_node_id = self.add_node(DagNode {
id: end_id.clone(),
node_type: DagNodeType::LoopEnd,
label: "end for".to_string(),
line,
});
self.add_edge(&start_node_id, &end_node_id, Some("done".to_string()));
Some((start_node_id, end_node_id))
}
fn walk_while(&mut self, while_stmt: &WhileStmt) -> Option<(String, String)> {
if self.stmt_contains_step(&while_stmt.body) {
self.errors.push(validation::error_step_in_while(
self.span_line(while_stmt.span),
));
}
None
}
fn walk_try(&mut self, try_stmt: &TryStmt) -> Option<(String, String)> {
let has_steps = self.body_contains_step(&try_stmt.block.stmts)
|| try_stmt
.handler
.as_ref()
.map_or(false, |h| self.body_contains_step(&h.body.stmts))
|| try_stmt
.finalizer
.as_ref()
.map_or(false, |f| self.body_contains_step(&f.stmts));
if has_steps {
self.errors.push(validation::error_step_in_catch(
self.span_line(try_stmt.span),
));
}
None
}
fn walk_return(&mut self, ret: &ReturnStmt) -> Option<(String, String)> {
let line = self.span_line(ret.span);
let id = self.next_id();
let node_id = self.add_node(DagNode {
id: id.clone(),
node_type: DagNodeType::Return,
label: "return".to_string(),
line,
});
Some((node_id.clone(), node_id))
}
}
/// Extract workflow function params (no longer skips ctx)
fn extract_ts_params(params: &[swc_ecma_ast::Param], cm: &Lrc<SourceMap>) -> Vec<Param> {
let mut result = Vec::new();
for param in params {
let (name, typ) = match &param.pat {
Pat::Ident(BindingIdent { id, type_ann, .. }) => {
let name = id.sym.to_string();
let typ = type_ann.as_ref().map(|ann| {
cm.span_to_snippet(ann.type_ann.span())
.unwrap_or_else(|_| "unknown".to_string())
});
(name, typ)
}
_ => continue,
};
result.push(Param { name, typ });
}
result
}
pub fn parse_ts_workflow(code: &str) -> Result<WorkflowDag, Vec<CompileError>> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("workflow.ts".into()).into(), code.into());
let lexer = Lexer::new(
Syntax::Typescript(TsSyntax::default()),
Default::default(),
StringInput::from(&*fm),
None,
);
let mut parser = Parser::new_from(lexer);
let module = parser
.parse_module()
.map_err(|e| vec![CompileError { message: format!("Parse error: {e:?}"), line: 0 }])?;
// First pass: collect task functions
let task_functions = collect_task_functions(&module);
// Find: export default workflow(async (...) => { ... })
// or: export default workflow(async function(...) { ... })
let mut workflow_body: Option<(&[Stmt], Vec<Param>)> = None;
for item in &module.body {
// export default workflow(async (...) => { ... })
if let ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(export)) = item {
if let Some(result) = find_workflow_call(&export.expr, &cm) {
workflow_body = Some(result);
break;
}
}
// const wf = workflow(async (...) => { ... }); export default wf;
if let ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultDecl(export)) = item {
if let DefaultDecl::Fn(_) = &export.decl {
// `export default async function(...) { ... }` — not wrapped in workflow(), skip
}
}
if let ModuleItem::Stmt(Stmt::Decl(Decl::Var(var_decl))) = item {
for decl in &var_decl.decls {
if let Some(init) = &decl.init {
if let Some(result) = find_workflow_call(init, &cm) {
workflow_body = Some(result);
break;
}
}
}
}
}
let (stmts, params) = workflow_body.ok_or_else(|| {
vec![CompileError {
message: "No workflow() wrapped async function found.".to_string(),
line: 0,
}]
})?;
let source_hash = compute_source_hash(code);
let mut walker = TsWacWalker::new(cm, task_functions);
walker.walk_body(stmts);
if !walker.errors.is_empty() {
return Err(walker.errors);
}
Ok(WorkflowDag { nodes: walker.nodes, edges: walker.edges, params, source_hash })
}
/// Find workflow(async (...) => { ... }) or workflow(async function(...) { ... })
fn find_workflow_call<'a>(expr: &'a Expr, cm: &Lrc<SourceMap>) -> Option<(&'a [Stmt], Vec<Param>)> {
if let Expr::Call(call) = expr {
// Check if callee is `workflow`
let is_workflow = match &call.callee {
Callee::Expr(callee_expr) => {
if let Expr::Ident(ident) = callee_expr.as_ref() {
ident.sym.as_ref() == "workflow"
} else {
false
}
}
_ => false,
};
if is_workflow {
if let Some(first_arg) = call.args.first() {
return extract_async_fn_body(&first_arg.expr, cm);
}
}
}
None
}
fn extract_async_fn_body<'a>(
expr: &'a Expr,
cm: &Lrc<SourceMap>,
) -> Option<(&'a [Stmt], Vec<Param>)> {
match expr {
Expr::Arrow(arrow) if arrow.is_async => {
let params = extract_arrow_params(&arrow.params, cm);
match &*arrow.body {
BlockStmtOrExpr::BlockStmt(block) => Some((&block.stmts, params)),
_ => None,
}
}
Expr::Fn(fn_expr) if fn_expr.function.is_async => {
let params = extract_ts_params(&fn_expr.function.params, cm);
fn_expr
.function
.body
.as_ref()
.map(|body| (body.stmts.as_slice(), params))
}
Expr::Paren(p) => extract_async_fn_body(&p.expr, cm),
_ => None,
}
}
/// Extract arrow function params (no longer skips ctx)
fn extract_arrow_params(pats: &[Pat], cm: &Lrc<SourceMap>) -> Vec<Param> {
let mut result = Vec::new();
for pat in pats {
match pat {
Pat::Ident(BindingIdent { id, type_ann, .. }) => {
let name = id.sym.to_string();
let typ = type_ann.as_ref().map(|ann| {
cm.span_to_snippet(ann.type_ann.span())
.unwrap_or_else(|_| "unknown".to_string())
});
result.push(Param { name, typ });
}
_ => {}
}
}
result
}
fn extract_string_lit(expr: &Expr) -> Option<String> {
match expr {
Expr::Lit(Lit::Str(s)) => Some(s.value.to_string()),
Expr::Tpl(tpl) if tpl.exprs.is_empty() && tpl.quasis.len() == 1 => {
tpl.quasis.first().map(|q| q.raw.to_string())
}
_ => None,
}
}
fn compute_source_hash(code: &str) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(code.as_bytes());
format!("{:x}", hasher.finalize())
}
@@ -0,0 +1,64 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CompileError {
pub message: String,
pub line: usize,
}
impl std::fmt::Display for CompileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "line {}: {}", self.line, self.message)
}
}
pub fn error_step_in_try(line: usize) -> CompileError {
CompileError {
message:
"Task calls inside try/except are not allowed. Steps have built-in error handling."
.to_string(),
line,
}
}
pub fn error_step_in_while(line: usize) -> CompileError {
CompileError {
message: "Task calls inside while loops are not allowed. Use for loops instead."
.to_string(),
line,
}
}
pub fn error_step_in_nested_function(line: usize) -> CompileError {
CompileError {
message: "Task calls inside nested functions, closures, or lambdas are not allowed."
.to_string(),
line,
}
}
pub fn error_step_in_comprehension(line: usize) -> CompileError {
CompileError { message: "Task calls inside comprehensions are not allowed.".to_string(), line }
}
pub fn error_not_async(line: usize) -> CompileError {
CompileError { message: "Workflow function must be async.".to_string(), line }
}
pub fn error_missing_await(line: usize) -> CompileError {
CompileError {
message:
"Task calls must be awaited directly or used inside asyncio.gather()/Promise.all()."
.to_string(),
line,
}
}
pub fn error_step_in_catch(line: usize) -> CompileError {
CompileError {
message:
"Task calls inside catch blocks are not allowed. Steps have built-in error handling."
.to_string(),
line,
}
}
@@ -0,0 +1,266 @@
use windmill_parser_wac::dag::DagNodeType;
use windmill_parser_wac::python::parse_python_workflow;
#[test]
fn test_simple_sequential_workflow() {
let code = r#"
import asyncio
from wmill import workflow, task
@task
async def extract_data(url: str): ...
@task
async def load_data(data: list): ...
@workflow
async def my_etl(url: str):
raw = await extract_data(url=url)
await load_data(data=raw)
return {"status": "done"}
"#;
let dag = parse_python_workflow(code).expect("should parse");
assert_eq!(dag.nodes.len(), 3); // 2 steps + 1 return
assert_eq!(dag.edges.len(), 2); // step0->step1, step1->return
// Check params (url — no ctx to skip)
assert_eq!(dag.params.len(), 1);
assert_eq!(dag.params[0].name, "url");
assert_eq!(dag.params[0].typ.as_deref(), Some("str"));
// Check first step
match &dag.nodes[0].node_type {
DagNodeType::Step { name, script } => {
assert_eq!(name, "extract_data");
assert_eq!(script, "extract_data");
}
_ => panic!("expected Step node"),
}
// Check second step
match &dag.nodes[1].node_type {
DagNodeType::Step { name, script } => {
assert_eq!(name, "load_data");
assert_eq!(script, "load_data");
}
_ => panic!("expected Step node"),
}
// Check return
assert!(matches!(dag.nodes[2].node_type, DagNodeType::Return));
// Check source hash is non-empty
assert!(!dag.source_hash.is_empty());
}
#[test]
fn test_parallel_workflow() {
let code = r#"
import asyncio
from wmill import workflow, task
@task
async def extract_data(url: str): ...
@task
async def clean_data(data: list): ...
@task
async def compute_stats(data: list): ...
@task
async def load_to_warehouse(rows: list): ...
@workflow
async def my_etl(url: str):
raw = await extract_data(url=url)
cleaned, stats = await asyncio.gather(
clean_data(data=raw),
compute_stats(data=raw),
)
await load_to_warehouse(rows=cleaned)
return {"status": "done"}
"#;
let dag = parse_python_workflow(code).expect("should parse");
// extract, ParallelStart, clean, stats, ParallelEnd, load, return = 7
assert_eq!(dag.nodes.len(), 7);
assert!(matches!(dag.nodes[0].node_type, DagNodeType::Step { .. }));
assert!(matches!(dag.nodes[1].node_type, DagNodeType::ParallelStart));
assert!(matches!(dag.nodes[2].node_type, DagNodeType::Step { .. }));
assert!(matches!(dag.nodes[3].node_type, DagNodeType::Step { .. }));
assert!(matches!(dag.nodes[4].node_type, DagNodeType::ParallelEnd));
assert!(matches!(dag.nodes[5].node_type, DagNodeType::Step { .. }));
assert!(matches!(dag.nodes[6].node_type, DagNodeType::Return));
}
#[test]
fn test_conditional_workflow() {
let code = r#"
import asyncio
from wmill import workflow, task
@task
async def send_alert(msg: str): ...
@task
async def load_data(): ...
@workflow
async def my_etl(count: int):
if count > 100:
await send_alert(msg="large")
await load_data()
return {"done": True}
"#;
let dag = parse_python_workflow(code).expect("should parse");
// Branch, notify step, load step, return = 4
assert_eq!(dag.nodes.len(), 4);
assert!(matches!(dag.nodes[0].node_type, DagNodeType::Branch { .. }));
assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. }));
}
#[test]
fn test_for_loop_workflow() {
let code = r#"
import asyncio
from wmill import workflow, task
@task
async def process_item(item: str): ...
@workflow
async def my_etl(items: list):
for item in items:
await process_item(item=item)
return {"done": True}
"#;
let dag = parse_python_workflow(code).expect("should parse");
// LoopStart, step, LoopEnd, return = 4
assert_eq!(dag.nodes.len(), 4);
assert!(matches!(
dag.nodes[0].node_type,
DagNodeType::LoopStart { .. }
));
assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. }));
assert!(matches!(dag.nodes[2].node_type, DagNodeType::LoopEnd));
}
#[test]
fn test_reject_step_in_try() {
let code = r#"
import asyncio
from wmill import workflow, task
@task
async def extract_data(): ...
@workflow
async def my_etl():
try:
await extract_data()
except Exception:
pass
"#;
let result = parse_python_workflow(code);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors[0].message.contains("try/except"));
}
#[test]
fn test_reject_step_in_while() {
let code = r#"
import asyncio
from wmill import workflow, task
@task
async def extract_data(): ...
@workflow
async def my_etl():
while True:
await extract_data()
"#;
let result = parse_python_workflow(code);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors[0].message.contains("while"));
}
#[test]
fn test_reject_non_async() {
let code = r#"
from wmill import workflow
@workflow
def my_etl():
pass
"#;
let result = parse_python_workflow(code);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors[0].message.contains("async"));
}
#[test]
fn test_reject_missing_await() {
let code = r#"
import asyncio
from wmill import workflow, task
@task
async def extract_data(): ...
@workflow
async def my_etl():
extract_data()
"#;
let result = parse_python_workflow(code);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors[0].message.contains("awaited"));
}
#[test]
fn test_no_workflow_function() {
let code = r#"
async def my_func():
pass
"#;
let result = parse_python_workflow(code);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors[0].message.contains("No @workflow"));
}
#[test]
fn test_task_with_external_path() {
let code = r#"
import asyncio
from wmill import workflow, task
@task(path="f/external_script")
async def run_external(x: int): ...
@workflow
async def my_wf(x: int):
result = await run_external(x=x)
return result
"#;
let dag = parse_python_workflow(code).expect("should parse");
assert_eq!(dag.nodes.len(), 2); // 1 step + 1 return (bare `return` is not a step node but walk_return creates one)
match &dag.nodes[0].node_type {
DagNodeType::Step { name, script } => {
assert_eq!(name, "run_external");
assert_eq!(script, "f/external_script");
}
_ => panic!("expected Step node"),
}
}
@@ -0,0 +1,245 @@
use windmill_parser_wac::dag::DagNodeType;
use windmill_parser_wac::typescript::parse_ts_workflow;
#[test]
fn test_simple_sequential_ts_workflow() {
let code = r#"
import { workflow, task } from "windmill-client";
const extract_data = task(async (url: string) => {});
const load_data = task(async (data: any) => {});
export default workflow(async (url: string) => {
const raw = await extract_data(url);
await load_data(raw);
return { status: "done" };
});
"#;
let dag = parse_ts_workflow(code).expect("should parse");
assert_eq!(dag.nodes.len(), 3); // 2 steps + 1 return
assert_eq!(dag.edges.len(), 2);
// Check params (url — no ctx to skip)
assert_eq!(dag.params.len(), 1);
assert_eq!(dag.params[0].name, "url");
assert_eq!(dag.params[0].typ.as_deref(), Some("string"));
match &dag.nodes[0].node_type {
DagNodeType::Step { name, script } => {
assert_eq!(name, "extract_data");
assert_eq!(script, "extract_data");
}
_ => panic!("expected Step node"),
}
match &dag.nodes[1].node_type {
DagNodeType::Step { name, script } => {
assert_eq!(name, "load_data");
assert_eq!(script, "load_data");
}
_ => panic!("expected Step node"),
}
assert!(matches!(dag.nodes[2].node_type, DagNodeType::Return));
assert!(!dag.source_hash.is_empty());
}
#[test]
fn test_parallel_ts_workflow() {
let code = r#"
import { workflow, task } from "windmill-client";
const extract_data = task(async (url: string) => {});
const clean_data = task(async (data: any) => {});
const compute_stats = task(async (data: any) => {});
const load_to_warehouse = task(async (rows: any) => {});
export default workflow(async (url: string) => {
const raw = await extract_data(url);
const [cleaned, stats] = await Promise.all([
clean_data(raw),
compute_stats(raw),
]);
await load_to_warehouse(cleaned);
return { status: "done", rows: stats.rowCount };
});
"#;
let dag = parse_ts_workflow(code).expect("should parse");
// extract, ParallelStart, clean, stats, ParallelEnd, load, return = 7
assert_eq!(dag.nodes.len(), 7);
assert!(matches!(dag.nodes[0].node_type, DagNodeType::Step { .. }));
assert!(matches!(dag.nodes[1].node_type, DagNodeType::ParallelStart));
assert!(matches!(dag.nodes[2].node_type, DagNodeType::Step { .. }));
assert!(matches!(dag.nodes[3].node_type, DagNodeType::Step { .. }));
assert!(matches!(dag.nodes[4].node_type, DagNodeType::ParallelEnd));
assert!(matches!(dag.nodes[5].node_type, DagNodeType::Step { .. }));
assert!(matches!(dag.nodes[6].node_type, DagNodeType::Return));
}
#[test]
fn test_conditional_ts_workflow() {
let code = r#"
import { workflow, task } from "windmill-client";
const send_alert = task(async (msg: string) => {});
const load_data = task(async () => {});
export default workflow(async (count: number) => {
if (count > 100) {
await send_alert("large");
}
await load_data();
return { done: true };
});
"#;
let dag = parse_ts_workflow(code).expect("should parse");
// Branch, notify, load, return = 4
assert_eq!(dag.nodes.len(), 4);
assert!(matches!(dag.nodes[0].node_type, DagNodeType::Branch { .. }));
}
#[test]
fn test_for_of_ts_workflow() {
let code = r#"
import { workflow, task } from "windmill-client";
const process_item = task(async (item: string) => {});
export default workflow(async (items: string[]) => {
for (const item of items) {
await process_item(item);
}
return { done: true };
});
"#;
let dag = parse_ts_workflow(code).expect("should parse");
// LoopStart, step, LoopEnd, return = 4
assert_eq!(dag.nodes.len(), 4);
assert!(matches!(
dag.nodes[0].node_type,
DagNodeType::LoopStart { .. }
));
}
#[test]
fn test_reject_step_in_try_catch() {
let code = r#"
import { workflow, task } from "windmill-client";
const extract_data = task(async () => {});
export default workflow(async () => {
try {
await extract_data();
} catch (e) {
console.log(e);
}
});
"#;
let result = parse_ts_workflow(code);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors[0].message.contains("catch"));
}
#[test]
fn test_reject_step_in_while_ts() {
let code = r#"
import { workflow, task } from "windmill-client";
const extract_data = task(async () => {});
export default workflow(async () => {
while (true) {
await extract_data();
}
});
"#;
let result = parse_ts_workflow(code);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors[0].message.contains("while"));
}
#[test]
fn test_reject_missing_await_ts() {
let code = r#"
import { workflow, task } from "windmill-client";
const extract_data = task(async () => {});
export default workflow(async () => {
extract_data();
});
"#;
let result = parse_ts_workflow(code);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors[0].message.contains("awaited"));
}
#[test]
fn test_no_workflow_wrapper() {
let code = r#"
export default async function main(ctx: any) {
return {};
}
"#;
let result = parse_ts_workflow(code);
assert!(result.is_err());
let errors = result.unwrap_err();
assert!(errors[0].message.contains("No workflow()"));
}
#[test]
fn test_variable_declaration_with_step() {
let code = r#"
import { workflow, task } from "windmill-client";
const compute = task(async () => {});
export default workflow(async () => {
const result = await compute();
return result;
});
"#;
let dag = parse_ts_workflow(code).expect("should parse");
assert_eq!(dag.nodes.len(), 2); // step + return
assert!(matches!(dag.nodes[0].node_type, DagNodeType::Step { .. }));
}
#[test]
fn test_task_with_external_path() {
let code = r#"
import { workflow, task } from "windmill-client";
const run_external = task("f/external_script", async (x: number) => {});
export default workflow(async (x: number) => {
const result = await run_external(x);
return result;
});
"#;
let dag = parse_ts_workflow(code).expect("should parse");
assert_eq!(dag.nodes.len(), 2); // step + return
match &dag.nodes[0].node_type {
DagNodeType::Step { name, script } => {
assert_eq!(name, "run_external");
assert_eq!(script, "f/external_script");
}
_ => panic!("expected Step node"),
}
}
@@ -38,6 +38,7 @@ csharp-parser = [ "dep:windmill-parser-csharp"]
nu-parser = [ "dep:windmill-parser-nu"]
java-parser = [ "dep:windmill-parser-java"]
ruby-parser = [ "dep:windmill-parser-ruby"]
wac-parser = [ "dep:windmill-parser-wac"]
[dependencies]
anyhow.workspace = true
@@ -55,6 +56,7 @@ windmill-parser-csharp = { workspace = true, optional = true }
windmill-parser-nu = { workspace = true, optional = true }
windmill-parser-java = { workspace = true, optional = true }
windmill-parser-ruby = { workspace = true, optional = true }
windmill-parser-wac = { workspace = true, optional = true }
wasm-bindgen.workspace = true
serde_json.workspace = true
@@ -56,6 +56,12 @@ const targets = [
features: "ruby-parser",
env: "tree-sitter",
},
{
ident: "wac",
desc: "Workflow-as-Code",
features: "wac-parser",
env: "default",
},
# ^^^ Add new entry here ^^^
];
# NOTE: This is legacy command for building all, but it is not more used
@@ -223,4 +223,11 @@ pub fn parse_assets_ansible(code: &str) -> String {
}
}
#[cfg(feature = "wac-parser")]
#[wasm_bindgen]
pub fn parse_workflow_as_code(code: &str, language: &str) -> String {
let result = windmill_parser_wac::parse_workflow(code, language);
serde_json::to_string(&result).unwrap_or_else(|_| "{\"type\": \"error\"}".to_string())
}
// for related places search: ADD_NEW_LANG
@@ -18,6 +18,7 @@ pub enum AssetKind {
Resource,
Ducklake,
DataTable,
Volume,
}
#[derive(Serialize, Debug, PartialEq, Clone)]
@@ -148,4 +149,5 @@ pub const ASSET_KINDS: &[(&str, AssetKind)] = &[
("$res:", AssetKind::Resource),
("ducklake://", AssetKind::Ducklake),
("datatable://", AssetKind::DataTable),
("volume://", AssetKind::Volume),
];
+64 -9
View File
@@ -38,11 +38,11 @@ use windmill_common::{
agent_workers::AgentConfig,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING,
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING,
DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING,
ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING,
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING,
MAVEN_SETTINGS_XML_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
@@ -99,10 +99,10 @@ use crate::monitor::{
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, monitor_db,
reload_app_workspaced_route_setting, reload_base_url_setting,
reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting,
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_job_default_timeout_setting,
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting,
reload_extra_pip_index_url_setting, reload_hub_api_secret_setting, reload_hub_base_url_setting,
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration,
};
@@ -517,6 +517,51 @@ fn print_help() {
println!("- At startup, Windmill logs currently set configuration keys for visibility.");
}
async fn resync_custom_instance_user_pwd_if_needed(db: &Pool<Postgres>) {
use windmill_common::utils::get_custom_pg_instance_password;
use windmill_common::{get_database_url, PgDatabase};
let user_pwd = match get_custom_pg_instance_password(db).await {
Ok(pwd) => pwd,
Err(_) => {
// Setting doesn't exist yet (fresh install or pre-migration), skip check
return;
}
};
let mut pg_creds = match get_database_url().await {
Ok(url) => match PgDatabase::parse_uri(&url.as_str().await) {
Ok(creds) => creds,
Err(e) => {
tracing::warn!("Failed to parse database URL for custom_instance_user check: {e}");
return;
}
},
Err(e) => {
tracing::warn!("Failed to get database URL for custom_instance_user check: {e}");
return;
}
};
pg_creds.user = Some("custom_instance_user".to_string());
pg_creds.password = Some(user_pwd);
match pg_creds.connect().await {
Ok(_) => {
tracing::info!("custom_instance_user password is in sync");
}
Err(e) => {
tracing::warn!("custom_instance_user password is out of sync ({e}), refreshing...");
if let Err(e) = windmill_api_settings::refresh_custom_instance_user_pwd_inner(db).await
{
tracing::error!("Failed to refresh custom_instance_user password: {e}");
} else {
tracing::info!("Successfully refreshed custom_instance_user password");
}
}
}
}
async fn windmill_main() -> anyhow::Result<()> {
let (killpill_tx, mut killpill_rx) = KillpillSender::new(2);
let mut monitor_killpill_rx = killpill_tx.subscribe();
@@ -838,6 +883,11 @@ async fn windmill_main() -> anyhow::Result<()> {
// NOTE: Variable/resource cache initialization moved to API server in windmill-api
// Check if custom_instance_user password is in sync
if server_mode {
resync_custom_instance_user_pwd_if_needed(&db).await;
}
Connection::Sql(db)
};
@@ -1717,6 +1767,11 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload critical alert UI setting");
}
}
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING => {
if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
tracing::error!(error = %e, "Could not reload critical alerts on token expiry setting");
}
}
"workspace_telemetry_enabled" => {
// Read the new value from the database and log it
let enabled = sqlx::query_scalar!(
+149 -18
View File
@@ -44,19 +44,20 @@ use windmill_common::{
apps::APP_WORKSPACED_ROUTE,
auth::create_token_for_owner,
ee_oss::CriticalErrorChannel,
email_oss::send_email_if_possible,
error,
flow_status::{FlowStatus, FlowStatusModule},
global_settings::{
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING,
DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING,
INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING,
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
UV_INDEX_STRATEGY_SETTING,
@@ -76,10 +77,11 @@ use windmill_common::{
DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR,
WORKER_CONFIG, WORKER_GROUP,
},
KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERT_MUTE_UI_ENABLED,
CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS,
METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED,
OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS,
KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY,
CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL,
HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED,
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
SERVICE_LOG_RETENTION_SECS,
};
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
#[cfg(feature = "parquet")]
@@ -207,6 +209,10 @@ pub async fn initial_load(
tracing::error!("Error loading critical alert mute ui setting: {e:#}");
}
if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
tracing::error!("Error loading critical alerts on token expiry setting: {e:#}");
}
if let Some(db) = conn.as_sql() {
if let Err(e) = load_tag_per_workspace_enabled(db).await {
tracing::error!("Error loading default tag per workpsace: {e:#}");
@@ -477,6 +483,21 @@ pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::
Ok(())
}
pub async fn reload_critical_alerts_on_token_expiry_setting(
conn: &Connection,
) -> error::Result<()> {
if let Ok(Some(serde_json::Value::Bool(t))) = load_value_from_global_settings_with_conn(
conn,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
true,
)
.await
{
CRITICAL_ALERTS_ON_TOKEN_EXPIRY.store(t, Ordering::Relaxed);
}
Ok(())
}
pub async fn load_metrics_debug_enabled(conn: &Connection) -> error::Result<()> {
let metrics_enabled =
load_value_from_global_settings_with_conn(conn, EXPOSE_DEBUG_METRICS_SETTING, true).await;
@@ -845,18 +866,82 @@ struct LogFile {
hostname: String,
}
struct TokenRow {
token_prefix: Option<String>,
label: Option<String>,
email: Option<String>,
workspace_id: Option<String>,
}
fn is_user_token(label: Option<&str>) -> bool {
match label {
None => true,
Some(l) => l != "session" && !l.starts_with("ephemeral") && !l.starts_with("Ephemeral"),
}
}
async fn report_token_expiration(db: &DB, token: &TokenRow, expired: bool) {
if !is_user_token(token.label.as_deref()) {
return;
}
let prefix = token.token_prefix.as_deref().unwrap_or("??????????");
let email_addr = token.email.as_deref().unwrap_or("unknown");
let token_desc = match token.label.as_deref() {
Some(l) if !l.is_empty() => format!("'{l}' ({prefix}****)"),
_ => format!("{prefix}****"),
};
let (alert_message, email_subject, email_body) = if expired {
(
format!(
"API token {token_desc} of '{email_addr}' has expired and been deleted"
),
"Windmill: Your API token has expired",
format!(
"Your API token {token_desc} has expired and been deleted.\n\nPlease create a new token if you still need API access."
),
)
} else {
(
format!("API token {token_desc} of '{email_addr}' is expiring soon"),
"Windmill: Your API token is expiring soon",
format!(
"Your API token {token_desc} is expiring soon.\n\nPlease rotate or renew your token to avoid service disruption."
),
)
};
tracing::info!("{}", alert_message);
if CRITICAL_ALERTS_ON_TOKEN_EXPIRY.load(Ordering::Relaxed) {
report_critical_error(
alert_message,
db.clone(),
token.workspace_id.as_deref(),
None,
)
.await;
}
if let Some(email) = &token.email {
send_email_if_possible(email_subject, &email_body, email);
}
}
pub async fn delete_expired_items(db: &DB) -> () {
let tokens_deleted_r: std::result::Result<Vec<String>, _> = sqlx::query_scalar(
let expired_tokens_r = sqlx::query_as!(
TokenRow,
"DELETE FROM token WHERE expiration <= now()
RETURNING concat(substring(token for 10), '*****')",
RETURNING substring(token for 10) as token_prefix, label, email, workspace_id",
)
.fetch_all(db)
.await;
match tokens_deleted_r {
match expired_tokens_r {
Ok(tokens) => {
if tokens.len() > 0 {
tracing::info!("deleted {} tokens: {:?}", tokens.len(), tokens)
if !tokens.is_empty() {
tracing::info!("deleted {} expired tokens", tokens.len());
for t in &tokens {
report_token_expiration(db, t, true).await;
}
}
}
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
@@ -1065,6 +1150,41 @@ pub async fn delete_expired_items(db: &DB) -> () {
}
}
pub async fn check_expiring_tokens(db: &DB) {
// Find tokens expiring within 7 days that still have a pending notification row
let expiring_tokens_r = sqlx::query_as!(
TokenRow,
"DELETE FROM token_expiry_notification n
USING token t
WHERE n.token = t.token
AND n.expiration > now()
AND n.expiration <= now() + interval '7 days'
RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id",
)
.fetch_all(db)
.await;
match expiring_tokens_r {
Ok(tokens) => {
for t in &tokens {
report_token_expiration(db, t, false).await;
}
if !tokens.is_empty() {
tracing::info!("Sent expiration warnings for {} token(s)", tokens.len());
}
}
Err(e) => tracing::error!("Error checking expiring tokens: {}", e),
}
// Clean up notification rows whose expiration has passed
if let Err(e) = sqlx::query!("DELETE FROM token_expiry_notification WHERE expiration <= now()")
.execute(db)
.await
{
tracing::error!("Error cleaning up expired token notifications: {}", e);
}
}
/// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments.
/// Uses a single transaction per batch to minimize lock duration.
/// Returns the number of jobs deleted in this batch.
@@ -2052,6 +2172,16 @@ pub async fn monitor_db(
}
};
// Run every hour (10 iterations * 30s = 5 minutes)
// Check for tokens expiring within 7 days and send alerts
let check_expiring_tokens_f = async {
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
if let Some(db) = conn.as_sql() {
check_expiring_tokens(&db).await;
}
}
};
join!(
expired_items_f,
zombie_jobs_f,
@@ -2073,6 +2203,7 @@ pub async fn monitor_db(
cleanup_worker_group_stats_f,
native_triggers_sync_f,
cleanup_notify_events_f,
check_expiring_tokens_f,
);
}
+2
View File
@@ -151,6 +151,8 @@ sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attri
FK: (workspace_id) -> workspace(id)
token: token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid)
FK: (workspace_id) -> workspace(id)
token_expiry_notification: token(char), expiration(ts)
INDEX: idx_token_expiry_notification_expiration (expiration)
tutorial_progress: email(char), progress(bit64), skipped_all(bool)
unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts)
usage: id(char), is_workspace(bool), month_(int), usage(int)
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# E2E test for WAC v2 workflow-as-code suspend/resume lifecycle
set -euo pipefail
BASE_URL="${BASE_URL:-http://localhost:8070}"
TOKEN="${WM_TOKEN:-}"
WORKSPACE="dev"
TIMEOUT=60 # seconds
# Get auth token if not set
if [ -z "$TOKEN" ]; then
TOKEN=$(curl -s "${BASE_URL}/api/auth/login" \
-H "Content-Type: application/json" \
-d '{"email":"admin@windmill.dev","password":"changeme"}' | tr -d '"')
fi
echo "=== WAC v2 E2E Test ==="
echo "Base URL: $BASE_URL"
echo ""
WAC_CODE='import { task, workflow } from "windmill-client@1.999.19";
const double = task(async (x: number): Promise<number> => {
console.log("[double] START at " + new Date().toISOString());
await new Promise(r => setTimeout(r, 2000));
console.log("[double] END at " + new Date().toISOString());
return x * 2;
});
const increment = task(async (x: number): Promise<number> => {
console.log("[increment] START at " + new Date().toISOString());
await new Promise(r => setTimeout(r, 2000));
console.log("[increment] END at " + new Date().toISOString());
return x + 1;
});
export const main = workflow(async (x: number = 10) => {
const [doubled, incremented] = await Promise.all([
double(x),
increment(x),
]);
const final_result = await double(incremented);
return { doubled, incremented, final_result };
});'
echo "Step 1: Submitting preview job..."
JOB_ID=$(curl -s "${BASE_URL}/api/w/${WORKSPACE}/jobs/run/preview" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d "$(jq -n --arg code "$WAC_CODE" '{
content: $code,
language: "bun",
args: {"x": 10}
}')" | tr -d '"')
echo "Job ID: $JOB_ID"
if [ -z "$JOB_ID" ] || [ "$JOB_ID" = "null" ]; then
echo "FAIL: Could not create job"
exit 1
fi
echo ""
echo "Step 2: Polling for completion (timeout: ${TIMEOUT}s)..."
START=$SECONDS
LAST_STATUS=""
while true; do
ELAPSED=$((SECONDS - START))
if [ $ELAPSED -gt $TIMEOUT ]; then
echo "FAIL: Timed out after ${TIMEOUT}s"
# Dump job state for debugging
echo ""
echo "=== Debug info ==="
echo "Parent job queue state:"
source /home/rfiszel/windmill__worktrees/workflows-as-code-v2/.env.local
psql "$DATABASE_URL" -c "SELECT id, running, suspend, suspend_until, canceled_by FROM v2_job_queue WHERE id = '$JOB_ID'::uuid" 2>/dev/null
echo "Child jobs:"
psql "$DATABASE_URL" -c "SELECT id, running, suspend, created_at FROM v2_job_queue WHERE parent_job = '$JOB_ID'::uuid ORDER BY created_at" 2>/dev/null
echo "Completed children:"
psql "$DATABASE_URL" -c "SELECT id FROM completed_job WHERE parent_job = '$JOB_ID'::uuid" 2>/dev/null
echo "Checkpoint:"
psql "$DATABASE_URL" -c "SELECT workflow_as_code_status->'_checkpoint' FROM v2_job_status WHERE id = '$JOB_ID'::uuid" 2>/dev/null
echo "Total child count:"
psql "$DATABASE_URL" -c "SELECT count(*) FROM v2_job WHERE parent_job = '$JOB_ID'::uuid" 2>/dev/null
exit 1
fi
# Check completed job
RESULT=$(curl -s "${BASE_URL}/api/w/${WORKSPACE}/jobs_u/completed/get_result/${JOB_ID}" \
-H "Authorization: Bearer $TOKEN" 2>/dev/null)
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "${BASE_URL}/api/w/${WORKSPACE}/jobs_u/completed/get_result/${JOB_ID}" \
-H "Authorization: Bearer $TOKEN" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
echo "Job completed in ${ELAPSED}s!"
echo ""
echo "Step 3: Checking result..."
echo "Result: $RESULT"
# Validate
DOUBLED=$(echo "$RESULT" | jq -r '.doubled // empty')
INCREMENTED=$(echo "$RESULT" | jq -r '.incremented // empty')
FINAL=$(echo "$RESULT" | jq -r '.final_result // empty')
PASS=true
if [ "$DOUBLED" != "20" ]; then
echo "FAIL: doubled = $DOUBLED, expected 20"
PASS=false
fi
if [ "$INCREMENTED" != "11" ]; then
echo "FAIL: incremented = $INCREMENTED, expected 11"
PASS=false
fi
if [ "$FINAL" != "22" ]; then
echo "FAIL: final_result = $FINAL, expected 22"
PASS=false
fi
if $PASS; then
echo "PASS: All values correct!"
# Check no excessive child jobs
source /home/rfiszel/windmill__worktrees/workflows-as-code-v2/.env.local 2>/dev/null
CHILD_COUNT=$(psql -tA "$DATABASE_URL" -c "SELECT count(*) FROM v2_job WHERE parent_job = '$JOB_ID'::uuid" 2>/dev/null)
echo "Total child jobs created: $CHILD_COUNT (expected: 3)"
if [ "$CHILD_COUNT" -gt "3" ]; then
echo "WARN: More children than expected ($CHILD_COUNT > 3)"
fi
exit 0
else
exit 1
fi
fi
# Show progress
STATUS=$(curl -s "${BASE_URL}/api/w/${WORKSPACE}/jobs_u/get/${JOB_ID}" \
-H "Authorization: Bearer $TOKEN" 2>/dev/null | jq -r '.type // empty')
if [ "$STATUS" != "$LAST_STATUS" ]; then
echo " [${ELAPSED}s] Status: $STATUS"
LAST_STATUS="$STATUS"
fi
# Check for runaway child creation
source /home/rfiszel/windmill__worktrees/workflows-as-code-v2/.env.local 2>/dev/null
CHILD_COUNT=$(psql -tA "$DATABASE_URL" -c "SELECT count(*) FROM v2_job WHERE parent_job = '$JOB_ID'::uuid" 2>/dev/null)
if [ "$CHILD_COUNT" -gt "10" ]; then
echo "FAIL: Runaway child creation detected! $CHILD_COUNT children (expected 3)"
echo ""
echo "=== Debug info ==="
psql "$DATABASE_URL" -c "SELECT id, running, suspend, suspend_until FROM v2_job_queue WHERE id = '$JOB_ID'::uuid" 2>/dev/null
psql "$DATABASE_URL" -c "SELECT id, running, suspend, created_at FROM v2_job_queue WHERE parent_job = '$JOB_ID'::uuid ORDER BY created_at LIMIT 20" 2>/dev/null
psql "$DATABASE_URL" -c "SELECT workflow_as_code_status->'_checkpoint' FROM v2_job_status WHERE id = '$JOB_ID'::uuid" 2>/dev/null
exit 1
fi
sleep 1
done

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