diff --git a/.claude/hooks/format-backend.sh b/.claude/hooks/format-backend.sh index d6077d7482..2b77f71432 100755 --- a/.claude/hooks/format-backend.sh +++ b/.claude/hooks/format-backend.sh @@ -13,8 +13,10 @@ fi # Check if the file is in the backend directory and is a Rust file if [[ "$FILE_PATH" == *"/backend/"* ]] && [[ "$FILE_PATH" =~ \.rs$ ]]; then cd "$CLAUDE_PROJECT_DIR/backend" || exit 0 - # Run rustfmt with config from rustfmt.toml (edition=2021) - rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>/dev/null || true + # Run rustfmt, surface errors as context but don't block Claude + if rustfmt --config-path rustfmt.toml "$FILE_PATH" 2>&1; then + echo "Formatted $(basename "$FILE_PATH")" + fi fi exit 0 diff --git a/.claude/hooks/format-frontend.sh b/.claude/hooks/format-frontend.sh index d0b4f0559b..37c3f8d4ec 100755 --- a/.claude/hooks/format-frontend.sh +++ b/.claude/hooks/format-frontend.sh @@ -15,8 +15,10 @@ if [[ "$FILE_PATH" == *"/frontend/"* ]]; then # Check if it's a formattable file type if [[ "$FILE_PATH" =~ \.(ts|js|svelte|json|css|html|md)$ ]]; then cd "$CLAUDE_PROJECT_DIR/frontend" || exit 0 - # Run prettier silently, don't fail the hook if prettier fails - npx prettier --write "$FILE_PATH" 2>/dev/null || true + # Run prettier, surface errors as context but don't block Claude + if ./node_modules/.bin/prettier --plugin prettier-plugin-svelte --write "$FILE_PATH" 2>&1; then + echo "Formatted $(basename "$FILE_PATH")" + fi fi fi diff --git a/.claude/settings.json b/.claude/settings.json index cf8bfdd284..0596b17e91 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -28,6 +28,12 @@ "Bash(git show:*)", "Bash(git blame:*)", "Bash(cargo check:*)", + "Bash(cargo build --release:*)", + "Bash(sh wm-ts-nav/nav:*)", + "Bash(wm-ts-nav/nav:*)", + "Bash(./wm-ts-nav/nav:*)", + "Bash(wm-ts-nav/target/release/wm-ts-nav:*)", + "Bash(./wm-ts-nav/target/release/wm-ts-nav:*)", "mcp__ide__getDiagnostics", "Bash(npm run generate-backend-client:*)", "Bash(npm run check:*)", diff --git a/.claude/skills/local-review/SKILL.md b/.claude/skills/local-review/SKILL.md new file mode 100644 index 0000000000..f14f5608db --- /dev/null +++ b/.claude/skills/local-review/SKILL.md @@ -0,0 +1,98 @@ +--- +name: local-review +user_invocable: true +description: Code review a pull request for bugs and CLAUDE.md compliance. MUST use when asked to review code. +--- + +# Local Code Review Skill + +Review a pull request for real bugs and CLAUDE.md compliance violations. This review targets HIGH SIGNAL issues only. + +## Review Philosophy + +- **Only flag issues you are certain about.** If you are not sure an issue is real, do not flag it. False positives erode trust and waste reviewer time. +- Think like a senior engineer doing a final review — flag things that would cause incidents, not things that are merely imperfect. + +## What to Flag + +- Code that won't compile or parse (syntax errors, type errors, missing imports) +- Code that will definitely produce wrong results regardless of inputs +- Clear, unambiguous CLAUDE.md violations (quote the exact rule being violated) +- Security issues in introduced code (injection, auth bypass, data exposure) +- Incorrect logic that will fail in production + +## What NOT to Flag + +- Code style or quality concerns +- Potential issues that depend on specific inputs or runtime state +- Subjective suggestions or improvements +- Pre-existing issues not introduced by this PR +- Pedantic nitpicks a senior engineer wouldn't flag +- Issues a linter or type checker will catch +- General quality concerns unless explicitly prohibited in CLAUDE.md +- Issues silenced via lint ignore comments + +## Execution Steps + +1. **Determine the PR scope**: + - If an argument is provided, use it as the PR number or branch + - Otherwise, detect from the current branch vs main + - Run `gh pr view` if a PR exists, or use `git diff main...HEAD` + +2. **Find relevant CLAUDE.md files**: + - Read the root `CLAUDE.md` + - Check for CLAUDE.md files in directories containing changed files + +3. **Get the diff and metadata**: + - `gh pr diff` or `git diff main...HEAD` for the full diff + - `gh pr view` or `git log main..HEAD --oneline` for context + +4. **Read changed files** where the diff alone is insufficient to understand context + +5. **Review for**: + - CLAUDE.md compliance — check each rule against the changed code + - Bugs and logic errors — will this code work correctly? + - Security issues — injection, auth, data exposure in new code + +6. **Self-validate each finding**: Before reporting, ask yourself: + - "Is this definitely a real issue, not a false positive?" + - "Would a senior engineer flag this in review?" + - If the answer to either is no, discard the finding + +7. **Output findings** to the terminal (default) or post as PR comments (with `--comment` flag) + +## Output Format + +``` +## Code review + +Found N issues: + +1. () + + +2. () + +``` + +If no issues are found: + +``` +## Code review + +No issues found. Checked for bugs and CLAUDE.md compliance. +``` + +## Posting Comments (--comment flag) + +If the user passes `--comment`, post findings as inline PR comments using: + +```bash +gh pr review --comment --body "" +``` + +Or for inline comments on specific lines: + +```bash +gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="" -f event="COMMENT" -f comments="[...]" +``` diff --git a/.claude/skills/pr/SKILL.md b/.claude/skills/pr/SKILL.md index f0c4e822e4..ab67b58748 100644 --- a/.claude/skills/pr/SKILL.md +++ b/.claude/skills/pr/SKILL.md @@ -33,6 +33,7 @@ Follow conventional commit format for the PR title: - Keep under 70 characters - Use lowercase, imperative mood - No period at the end +- If `*_ee.rs` files were modified, prefix with `[ee]`: `[ee] : ` ## PR Body Format @@ -85,3 +86,25 @@ Generated with [Claude Code](https://claude.com/claude-code) )" ``` 7. Return the PR URL to the user + +## EE Companion PR (when `*_ee.rs` files were modified) + +The `*_ee.rs` files in the windmill repo are **symlinks** to `windmill-ee-private` — changes won't appear in `git diff` of the windmill repo. Instead, check the EE repo for uncommitted or unpushed changes. + +Follow the full EE PR workflow in `docs/enterprise.md`. The key PR-specific details: + +1. Find the EE repo/worktree: see "Finding the EE Repo" in `docs/enterprise.md` +2. Check for changes: `git -C status --short` + - If there are no changes in the EE repo, skip this entire section +3. Follow steps 1–5 from the "EE PR Workflow" in `docs/enterprise.md` +4. Create the companion PR (title does NOT get the `[ee]` prefix): + ```bash + gh pr create --draft --repo windmill-labs/windmill-ee-private --title ": " --body "$(cat <<'EOF' + Companion PR for windmill-labs/windmill# + + --- + Generated with [Claude Code](https://claude.com/claude-code) + EOF + )" + ``` +5. Commit `ee-repo-ref.txt` and push the updated windmill branch diff --git a/.envrc b/.envrc index 3550a30f2d..f905841649 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,7 @@ use flake + +# Per-worktree overrides (ports, DATABASE_URL, etc.) written by webmux/workmux +# post-create hooks. Must come after `use flake` so they take precedence over +# the flake's defaults. +# shellcheck source=/dev/null +[ -f .env.local ] && source .env.local diff --git a/.github/workflows/backend-check.yml b/.github/workflows/backend-check.yml index fd2f135579..d5c6ed6856 100644 --- a/.github/workflows/backend-check.yml +++ b/.github/workflows/backend-check.yml @@ -119,6 +119,18 @@ jobs: with: cache-workspaces: backend toolchain: 1.93.0 + - name: Fix stale v8 build cache + working-directory: ./backend + run: | + # Cargo cache may preserve v8 build fingerprints without the actual + # librusty_v8.a library. Since fingerprints look valid, cargo skips + # build.rs re-run, causing "could not find native static library rusty_v8". + for profile in debug release; do + if [ -d "target/$profile/.fingerprint" ] && [ ! -f "target/$profile/gn_out/obj/librusty_v8.a" ]; then + echo "Cleaning stale v8 build artifacts in target/$profile" + rm -rf "target/$profile/build/v8-"* "target/$profile/.fingerprint/v8-"* + fi + done - name: cargo check timeout-minutes: 16 working-directory: ./backend diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index ca9ce2aaac..864693ebda 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -5,6 +5,8 @@ on: push: branches: - "ci-windows-tests" + tags: + - "v*" env: CARGO_INCREMENTAL: 0 diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index be537abb11..cd6651fc7e 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -89,6 +89,18 @@ jobs: with: cache-workspaces: backend toolchain: 1.93.0 + - name: Fix stale v8 build cache + working-directory: ./backend + run: | + # Cargo cache may preserve v8 build fingerprints without the actual + # librusty_v8.a library. Since fingerprints look valid, cargo skips + # build.rs re-run, causing "could not find native static library rusty_v8". + for profile in debug release; do + if [ -d "target/$profile/.fingerprint" ] && [ ! -f "target/$profile/gn_out/obj/librusty_v8.a" ]; then + echo "Cleaning stale v8 build artifacts in target/$profile" + rm -rf "target/$profile/build/v8-"* "target/$profile/.fingerprint/v8-"* + fi + done - name: Read EE repo commit hash run: | echo "ee_repo_ref=$(cat ./ee-repo-ref.txt)" >> "$GITHUB_ENV" diff --git a/.github/workflows/check-system-prompts.yml b/.github/workflows/check-system-prompts.yml new file mode 100644 index 0000000000..4392e4c5b0 --- /dev/null +++ b/.github/workflows/check-system-prompts.yml @@ -0,0 +1,37 @@ +name: Check system prompts freshness + +on: + push: + paths: + - "system_prompts/**" + - "typescript-client/**" + - "python-client/wmill/wmill/client.py" + - "openflow.openapi.yaml" + - "backend/windmill-api/openapi.yaml" + - "cli/src/main.ts" + - "cli/src/commands/**" + pull_request: + paths: + - "system_prompts/**" + - "typescript-client/**" + - "python-client/wmill/wmill/client.py" + - "openflow.openapi.yaml" + - "backend/windmill-api/openapi.yaml" + - "cli/src/main.ts" + - "cli/src/commands/**" + +jobs: + check-freshness: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install pyyaml + + - name: Check auto-generated files are up-to-date + run: bash system_prompts/check-freshness.sh diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index 20a24d2091..9c87a249a3 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -5,11 +5,13 @@ on: branches: [main] paths: - "cli/**" + - "backend/migrations/**" - ".github/workflows/cli-tests.yml" pull_request: branches: [main] paths: - "cli/**" + - "backend/migrations/**" - ".github/workflows/cli-tests.yml" env: diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 09d3728fba..bdc53a6f22 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -212,6 +212,59 @@ jobs: ${{ steps.extract-ee.outputs.destination }}/* ${{ steps.extract-duckdb-ffi-internal.outputs.destination }}/* + attach_ee_debug_to_release: + needs: [build_ee] + runs-on: ubicloud + if: ${{ startsWith(github.ref, 'refs/tags/v') }} + strategy: + matrix: + platform: [linux/amd64, linux/arm64] + include: + - platform: linux/amd64 + arch: amd64 + - platform: linux/arm64 + arch: arm64 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + + - name: Read EE repo commit hash + run: | + echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_ENV" + + - 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 }} + + - name: Substitute EE code + run: | + ./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private + + - uses: depot/setup-action@v1 + + - name: Extract EE debug info from builder stage (depot cache hit) + uses: depot/build-push-action@v1 + with: + context: . + platforms: ${{ matrix.platform }} + target: debuginfo + build-args: | + features=ee + outputs: type=local,dest=./debuginfo + + - name: Rename debug file with corresponding architecture + run: | + mv ./debuginfo/windmill.debug ./debuginfo/windmill-ee-${{ matrix.arch }}.debug + + - name: Attach debug file to release + uses: softprops/action-gh-release@v2 + with: + files: ./debuginfo/windmill-ee-${{ matrix.arch }}.debug + # attach_arm64_binary_to_release: # needs: [build, build_ee] # runs-on: ubicoud diff --git a/.github/workflows/git-commands.yaml b/.github/workflows/git-commands.yaml index 4e93cd8e9b..f1cc380563 100644 --- a/.github/workflows/git-commands.yaml +++ b/.github/workflows/git-commands.yaml @@ -106,6 +106,19 @@ jobs: git config --local user.name "windmill-internal-app[bot]" git config pull.rebase true git pull origin $BRANCH_NAME + + # Checkout the correct windmill-ee-private commit from ee-repo-ref.txt + if [ -f backend/ee-repo-ref.txt ]; then + EE_REF=$(cat backend/ee-repo-ref.txt | tr -d '[:space:]') + echo "Checking out windmill-ee-private at commit: $EE_REF" + cd windmill-ee-private + git fetch origin $EE_REF + git checkout $EE_REF + cd .. + else + echo "Warning: ee-repo-ref.txt not found, using default branch" + fi + mkdir -p frontend/build cd backend cargo install sqlx-cli --version 0.8.5 diff --git a/.github/workflows/git-sync-test.yml b/.github/workflows/git-sync-test.yml new file mode 100644 index 0000000000..09f69bb701 --- /dev/null +++ b/.github/workflows/git-sync-test.yml @@ -0,0 +1,209 @@ +name: Git Sync Integration Tests + +on: + workflow_dispatch: + push: + branches: [main] + paths: + - "backend/windmill-git-sync/**" + - "backend/windmill-api-integration-tests/tests/git_sync*" + - "backend/ee-repo-ref.txt" + - "integration_tests/test/git_sync_test.py" + - ".github/workflows/git-sync-test.yml" + pull_request: + types: [opened, synchronize, reopened] + paths: + - "backend/windmill-git-sync/**" + - "backend/windmill-api-integration-tests/tests/git_sync*" + - "backend/ee-repo-ref.txt" + - "integration_tests/test/git_sync_test.py" + - ".github/workflows/git-sync-test.yml" + +concurrency: + group: git-sync-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-relevance: + runs-on: ubuntu-latest + outputs: + should_run: ${{ steps.check.outputs.should_run }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check if git sync related files changed + id: check + env: + WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }} + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + BASE=${{ github.event.pull_request.base.sha }} + else + BASE=${{ github.event.before }} + fi + + CHANGED_FILES=$(git diff --name-only "$BASE"..HEAD 2>/dev/null || echo "") + echo "Changed files:" + echo "$CHANGED_FILES" + + # Direct git sync file changes — always relevant + if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "Relevant: direct git sync file changes" + exit 0 + fi + + # If ee-repo-ref.txt changed, check if the EE diff touches windmill-git-sync/ + if echo "$CHANGED_FILES" | grep -q '^backend/ee-repo-ref.txt$'; then + NEW_REF=$(cat backend/ee-repo-ref.txt) + OLD_REF=$(git show "$BASE:backend/ee-repo-ref.txt" 2>/dev/null || echo "") + + if [ -n "$OLD_REF" ] && [ "$OLD_REF" != "$NEW_REF" ]; then + # Clone EE repo and check diff + git clone --bare "https://x-access-token:${WINDMILL_EE_PRIVATE_ACCESS}@github.com/windmill-labs/windmill-ee-private.git" /tmp/ee-repo 2>/dev/null + EE_CHANGED=$(git -C /tmp/ee-repo diff --name-only "$OLD_REF".."$NEW_REF" 2>/dev/null || echo "") + echo "EE changed files:" + echo "$EE_CHANGED" + + if echo "$EE_CHANGED" | grep -q '^windmill-git-sync/'; then + echo "should_run=true" >> "$GITHUB_OUTPUT" + echo "Relevant: EE git sync files changed" + exit 0 + fi + fi + fi + + echo "should_run=false" >> "$GITHUB_OUTPUT" + echo "No git sync relevant changes detected, skipping tests" + + git_sync_e2e: + needs: [check-relevance] + if: needs.check-relevance.outputs.should_run == 'true' + runs-on: ubicloud-standard-16 + services: + postgres: + image: postgres:14 + ports: + - 5432:5432 + env: + POSTGRES_DB: windmill + POSTGRES_PASSWORD: changeme + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.ref }} + fetch-depth: 0 + + - name: Read EE repo commit hash + run: | + echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_ENV" + + - 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 + run: | + cd backend && ./substitute_ee_code.sh --copy --dir ./windmill-ee-private + + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + cache-workspaces: backend + toolchain: 1.93.0 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + + - uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install wmill CLI + 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: Build Windmill + working-directory: ./backend + env: + SQLX_OFFLINE: true + CARGO_BUILD_JOBS: 12 + RUSTFLAGS: "" + run: | + cargo build --features enterprise,private,license,zip + + - name: Start Gitea + run: | + docker run -d --name gitea \ + -e GITEA__database__DB_TYPE=sqlite3 \ + -e GITEA__security__INSTALL_LOCK=true \ + -e GITEA__server__HTTP_PORT=3000 \ + -e GITEA__server__ROOT_URL=http://localhost:3000 \ + -e GITEA__service__DISABLE_REGISTRATION=false \ + -p 3000:3000 \ + gitea/gitea:1.22-rootless + echo "Waiting for Gitea to be ready..." + for i in $(seq 1 30); do + if curl -sf http://localhost:3000/api/v1/version > /dev/null 2>&1; then + echo "Gitea is ready" + break + fi + sleep 2 + done + curl -sf http://localhost:3000/api/v1/version > /dev/null || { echo "Gitea failed to start"; exit 1; } + + - name: Start Windmill + working-directory: ./backend + env: + DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill + LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} + DENO_PATH: deno + BUN_PATH: bun + NODE_BIN_PATH: node + run: | + ./target/debug/windmill & + echo "Waiting for Windmill to be ready..." + for i in $(seq 1 60); do + if curl -sf http://localhost:8000/api/version > /dev/null 2>&1; then + echo "Windmill is ready" + break + fi + sleep 2 + done + curl -sf http://localhost:8000/api/version > /dev/null || { echo "Windmill failed to start"; exit 1; } + + - name: Run git sync E2E tests + timeout-minutes: 10 + env: + GITEA_DOCKER_URL: http://localhost:3000 + LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }} + run: | + python3 -m venv .venv + .venv/bin/pip install -r integration_tests/requirements.txt + cd integration_tests && ../.venv/bin/python -m unittest -v test.git_sync_test + + - name: Archive logs + uses: actions/upload-artifact@v4 + if: always() + with: + name: Git Sync Integration Tests Logs + path: | + integration_tests/logs diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 6aa537060e..a41bd80854 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -14,7 +14,7 @@ jobs: with: node-version: "20.x" registry-url: "https://registry.npmjs.org" - - run: cd typescript-client && ./publish.sh && cd .. + - run: cd typescript-client && ./publish.sh --access public && cd .. env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_cli: @@ -28,6 +28,6 @@ jobs: - uses: oven-sh/setup-bun@v2 with: bun-version: latest - - run: cd cli && ./build.sh && cd npm && npm publish + - run: cd cli && ./build.sh && cd npm && npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 8cad7e4df2..63b59b8afb 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ rust-client/Cargo.toml # Worktree-generated port isolation .env.local +.webmux.local.yaml # Worktree-specific Claude Code settings (generated by scripts/worktree-env) .claude/settings.local.json @@ -27,3 +28,4 @@ typescript-client/node_modules frontend/.svelte-kit backend/chrome_profiler.json .fast-check/ +__pycache__/ diff --git a/.workmux.yaml b/.workmux.yaml index 46049109c0..b36a8f16bf 100644 --- a/.workmux.yaml +++ b/.workmux.yaml @@ -67,6 +67,7 @@ files: copy: - backend/.env - scripts/ + - wm-ts-nav/target/release/wm-ts-nav sandbox: enabled: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 61457b258d..f3522f9c43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,92 @@ # Changelog +## [1.658.0](https://github.com/windmill-labs/windmill/compare/v1.657.2...v1.658.0) (2026-03-16) + + +### Features + +* add GET /api/saml/metadata endpoint ([#8394](https://github.com/windmill-labs/windmill/issues/8394)) ([50b24cf](https://github.com/windmill-labs/windmill/commit/50b24cfdc8bf54656adbdc3315037aa773632076)) +* support custom headers in customai resource type ([#8364](https://github.com/windmill-labs/windmill/issues/8364)) ([5acb367](https://github.com/windmill-labs/windmill/commit/5acb367cf9b4b96ac7129c91df229d1a25258f5b)) +* support multiple secret variables during resource creation ([#8386](https://github.com/windmill-labs/windmill/issues/8386)) ([54841b7](https://github.com/windmill-labs/windmill/commit/54841b7549d5c9719d4dc3cb43e282ba057cd0f3)) + + +### Bug Fixes + +* /updatesqlx now uses ee-repo-ref.txt commit hash ([#8387](https://github.com/windmill-labs/windmill/issues/8387)) ([a519d41](https://github.com/windmill-labs/windmill/commit/a519d4113086430ace1d7ac8795bd2c2a8cf99e9)) +* **native-triggers:** preserve API error response body in HttpRequestError ([#8392](https://github.com/windmill-labs/windmill/issues/8392)) ([1eee89d](https://github.com/windmill-labs/windmill/commit/1eee89d99fbf31751d6257a4015e0b22e3871372)) +* OutputPicker shows stale result after 'Test up to here' ([#8390](https://github.com/windmill-labs/windmill/issues/8390)) ([2907084](https://github.com/windmill-labs/windmill/commit/2907084ca653fc5540bb04a409d2789ddaeec05b)) +* propagate enterprise feature to windmill-api-schedule ([#8391](https://github.com/windmill-labs/windmill/issues/8391)) ([50ef9e7](https://github.com/windmill-labs/windmill/commit/50ef9e79fcef8ee2cccd789b5eb1aacf5647365f)) +* set nsjail time_limit from job timeout so configured defaults are respected ([#8389](https://github.com/windmill-labs/windmill/issues/8389)) ([65a92d9](https://github.com/windmill-labs/windmill/commit/65a92d98994dbe4ae90a5e554e55b3ab44463f86)) +* soft error on AI agent max iterations + rename retries tab to error handling ([#8366](https://github.com/windmill-labs/windmill/issues/8366)) ([1a1e8a1](https://github.com/windmill-labs/windmill/commit/1a1e8a164cccbfcc663b963cb062af9208ff51be)) +* use bookworm-based php image to fix glibc 2.38 incompatibility ([#8381](https://github.com/windmill-labs/windmill/issues/8381)) ([68fd900](https://github.com/windmill-labs/windmill/commit/68fd900076ecf8b20f6622cd5794f1b52c0f5cab)) + +## [1.657.2](https://github.com/windmill-labs/windmill/compare/v1.657.1...v1.657.2) (2026-03-15) + + +### Bug Fixes + +* **cli:** Fix nonDottedPaths handling in cli flow lock generation ([#8375](https://github.com/windmill-labs/windmill/issues/8375)) ([eb03ebb](https://github.com/windmill-labs/windmill/commit/eb03ebbb0486b33c290fba3c34ea959e6e82fd13)) + +## [1.657.1](https://github.com/windmill-labs/windmill/compare/v1.657.0...v1.657.1) (2026-03-14) + + +### Bug Fixes + +* powershell WindmillClient module loading on Windows workers ([#8370](https://github.com/windmill-labs/windmill/issues/8370)) ([3a268a9](https://github.com/windmill-labs/windmill/commit/3a268a9cf16add2ea2530e6eab247120a4d4754e)) + +## [1.657.0](https://github.com/windmill-labs/windmill/compare/v1.656.0...v1.657.0) (2026-03-14) + + +### Features + +* add datatable config support to CLI settings sync and backend export ([#8024](https://github.com/windmill-labs/windmill/issues/8024)) ([5df37fb](https://github.com/windmill-labs/windmill/commit/5df37fb0dbf9190a430f066cf2d3c48914782e53)) + +## [1.656.0](https://github.com/windmill-labs/windmill/compare/v1.655.0...v1.656.0) (2026-03-13) + + +### Features + +* add GitHub Enterprise Server (GHES) support for GitHub App git sync ([#8344](https://github.com/windmill-labs/windmill/issues/8344)) ([2e430c4](https://github.com/windmill-labs/windmill/commit/2e430c4c0b8540df7b6997434a7a9f9134858026)) +* **cli:** add unified generate-metadata command ([#8335](https://github.com/windmill-labs/windmill/issues/8335)) ([4c2c165](https://github.com/windmill-labs/windmill/commit/4c2c165a5b757bd5f2f49074bb290407bce3b2fb)) + + +### Bug Fixes + +* **ci:** add NODE_AUTH_TOKEN for npm publish authentication ([2a8e276](https://github.com/windmill-labs/windmill/commit/2a8e276b6d2761bb2798b6bc5f8d90ab34fbb403)) +* **ci:** remove provenance flag and use NPM_TOKEN for npm publish ([44dd3ee](https://github.com/windmill-labs/windmill/commit/44dd3ee8cd05d288828d1d46c84cbcdf40f8fa78)) +* **cli:** exclude raw app backend files from script metadata generation ([#8362](https://github.com/windmill-labs/windmill/issues/8362)) ([060687b](https://github.com/windmill-labs/windmill/commit/060687b1fa6b627a7b06fbdc4b3f4eb0b63411c0)) +* **cli:** normalize path separators in generate-metadata folder filter for Windows ([#8358](https://github.com/windmill-labs/windmill/issues/8358)) ([404ae09](https://github.com/windmill-labs/windmill/commit/404ae09d429fb545610ba17d747e1903c542d4a3)) +* **cli:** suppress verbose lock generation messages in generate-metadata ([#8357](https://github.com/windmill-labs/windmill/issues/8357)) ([51933be](https://github.com/windmill-labs/windmill/commit/51933be3cabd853960d384cd358c7bcaef6bfa86)) +* **frontend:** collapse flow topbar buttons to icon-only in narrow panes ([#8322](https://github.com/windmill-labs/windmill/issues/8322)) ([b585dee](https://github.com/windmill-labs/windmill/commit/b585dee64dfd63d20812ca969b17ff9ee9989493)) +* **frontend:** filter webhook/email tokens by scope instead of label ([#8363](https://github.com/windmill-labs/windmill/issues/8363)) ([0d31c35](https://github.com/windmill-labs/windmill/commit/0d31c35f3e12d637c757a95fe350294002cbf640)) +* **frontend:** improve native mode alert message and fix workspaced tag detection ([#8361](https://github.com/windmill-labs/windmill/issues/8361)) ([fb12b31](https://github.com/windmill-labs/windmill/commit/fb12b31df081b2f1ac63becea6e6538ca80f8c46)) +* **frontend:** prevent duplicate and reserved agent tool names ([#8367](https://github.com/windmill-labs/windmill/issues/8367)) ([c431053](https://github.com/windmill-labs/windmill/commit/c431053a1e24ef29cd551a86de4d013fd7f158be)) +* graceful shutdown instead of panic on job completion channel failure ([#8345](https://github.com/windmill-labs/windmill/issues/8345)) ([724d135](https://github.com/windmill-labs/windmill/commit/724d1350d070fcf078034a52166d3048fb74e6f3)) +* Linked resources and vars not triggering both sync jobs on delete ([#8342](https://github.com/windmill-labs/windmill/issues/8342)) ([8e3b8bd](https://github.com/windmill-labs/windmill/commit/8e3b8bdfd2ded9652bc7e876c6bcd0ac2cfae148)) +* lower default indexer memory/batch settings to prevent OOM ([#8347](https://github.com/windmill-labs/windmill/issues/8347)) ([d9d45cf](https://github.com/windmill-labs/windmill/commit/d9d45cf2f9235b0e7118d0fc97ccdc0776ca9726)) + +## [1.655.0](https://github.com/windmill-labs/windmill/compare/v1.654.0...v1.655.0) (2026-03-12) + + +### Features + +* add auto_commit option to Kafka triggers with advanced UI badges ([#8317](https://github.com/windmill-labs/windmill/issues/8317)) ([ec20d76](https://github.com/windmill-labs/windmill/commit/ec20d76216492086842c4f5e4e3b36727a5631e9)) +* partition audit log table by day with configurable retention ([#8292](https://github.com/windmill-labs/windmill/issues/8292)) ([2aef01d](https://github.com/windmill-labs/windmill/commit/2aef01d18c0723aedcc626f4f3991195620774ab)) +* support minimal telemetry mode ([#8243](https://github.com/windmill-labs/windmill/issues/8243)) ([fe1519f](https://github.com/windmill-labs/windmill/commit/fe1519f1284aadd67d5dce46cf0cb52ab351f789)) + + +### Bug Fixes + +* **cli:** instruct agent to tell user about generate-metadata and sync push instead of running them ([#8318](https://github.com/windmill-labs/windmill/issues/8318)) ([7fb729c](https://github.com/windmill-labs/windmill/commit/7fb729cc8483a2e6966a8e8995678929f4d451a0)) +* fix saved inputs popover infinite loop ([#8311](https://github.com/windmill-labs/windmill/issues/8311)) ([425a75e](https://github.com/windmill-labs/windmill/commit/425a75e030b15fe65676169f9069fbb7da19828e)) +* native mode now properly sets DB pool size and sleep queue ([#8332](https://github.com/windmill-labs/windmill/issues/8332)) ([d8b4132](https://github.com/windmill-labs/windmill/commit/d8b4132b9ae90af759c6655f4f69479f6738e60a)) +* prevent zombie jobs from looping forever ([#8313](https://github.com/windmill-labs/windmill/issues/8313)) ([48bc3e2](https://github.com/windmill-labs/windmill/commit/48bc3e244558dccb1f08f455b299600861788b0d)) +* set min_connections(0) to prevent sqlx pool spin loop ([#8334](https://github.com/windmill-labs/windmill/issues/8334)) ([bf4340f](https://github.com/windmill-labs/windmill/commit/bf4340f40c1eb9cacee4c32e07ba44f2c92bf7c4)) +* show diff editor content for resources without a language ([#8331](https://github.com/windmill-labs/windmill/issues/8331)) ([cbc7e78](https://github.com/windmill-labs/windmill/commit/cbc7e78f8a60bff1d8730a6183cdbc9125d8e2b1)) +* skip python preinstall on native workers ([#8329](https://github.com/windmill-labs/windmill/issues/8329)) ([4306c9e](https://github.com/windmill-labs/windmill/commit/4306c9e4fef317e298a76924edb4f20aa7ced105)) +* skip token expiry notifications for debugger and mcp-oauth tokens ([#8316](https://github.com/windmill-labs/windmill/issues/8316)) ([8667329](https://github.com/windmill-labs/windmill/commit/86673291100fd16aaf216ed33ca9b648b8a2b7a5)) +* use !inline ref for scripts inside flows (preproc, error, ai tool) ([#8319](https://github.com/windmill-labs/windmill/issues/8319)) ([ca8a627](https://github.com/windmill-labs/windmill/commit/ca8a6274bc81ad49fa0c6166694ae4d65a4048cb)) + ## [1.654.0](https://github.com/windmill-labs/windmill/compare/v1.653.0...v1.654.0) (2026-03-10) diff --git a/CLAUDE.md b/CLAUDE.md index fe22fae0f7..0f01e67c57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ Open-source platform for internal tools, workflows, API integrations, background ## Workflow -1. **Understand**: Before coding, read relevant docs from `docs/` to understand the area you're changing +1. **Understand**: Before coding, explore the codebase (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code, `Grep` to find usages. Read `docs/` for domain context. 2. **Plan**: For non-trivial changes, use plan mode. For large features, break into reviewable stages 3. **Execute**: Follow coding patterns from skills (`rust-backend`, `svelte-frontend`) 4. **Validate**: After every change, run the appropriate checks per `docs/validation.md` @@ -15,6 +15,7 @@ Open-source platform for internal tools, workflows, API integrations, background - **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow - **Backend patterns**: use the `rust-backend` skill when writing Rust code - **Frontend patterns**: use the `svelte-frontend` skill when writing Svelte code. Do NOT edit svelte files unless you have read that skill. +- **Code review**: use `/local-review` to review a PR for bugs and CLAUDE.md compliance - **Domain guides**: `.claude/skills/native-trigger/` and `frontend/tutorial-system-guide.mdc` - **Brand/UI guidelines**: `frontend/brand-guidelines.md` @@ -49,8 +50,37 @@ let { my_prop = $bindable(default_value) }: { my_prop?: string } = $props() 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. +## Code Navigation + +`wm-ts-nav` is an AST-aware code navigator. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries. + +**MUST use `outline` before `Read`** on unfamiliar files — a 500-line file costs ~500 lines of context, while `outline` costs ~20. Then **MUST use `body "X"`** instead of reading a full file to see one function/struct. Use `Read` with offset/limit only when you need surrounding context that `body` doesn't capture. +- `refs "X" --caller` instead of reading files to find which function contains each reference +- `callers "X"` / `callees "X"` for call-graph questions + +EE files (`*_ee.rs`, `*_ee.ts`, `*_ee.svelte`) are indexed — you can `outline`, `def`, `body`, `refs` etc. on them just like regular files. + +```bash +NAV="sh wm-ts-nav/nav" +# Use --root backend for Rust, --root frontend/src for TS/Svelte +$NAV --root backend outline backend/path/to/file.rs # file structure +$NAV --root backend def "ServiceName" # find definition +$NAV --root backend body "decrypt_oauth_data" # extract source code +$NAV --root backend search "%" --parent ServiceName # methods on a type +$NAV --root backend search "Trigger" --kind struct # find by kind +$NAV --root backend refs "X" --file handler.rs --caller # scoped refs with caller +$NAV --root backend callers "X" # who calls X? +$NAV --root backend callees "X" # what does X call? +``` + +**Limitations** — syntax-level analysis, no type inference. Use **Grep** instead when completeness matters (finding all usages, exhaustiveness checks): +- `refs`/`callers`/`callees` can't follow re-exports, glob imports, or different import paths to the same symbol +- Trait impls, macro-generated symbols (`sqlx::FromRow`), and namespace member access (`ns.X`) are invisible +- `callees` shows all identifiers in a function body, not just actual calls + ## Core Principles +- **MUST `outline` before `Read`** on unfamiliar files — then `body` or `Read` with offset/limit for specifics - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked diff --git a/Dockerfile b/Dockerfile index 70a28b2f96..d312d3a823 100644 --- a/Dockerfile +++ b/Dockerfile @@ -118,6 +118,18 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=$SCCACHE_DIR,sharing=locked \ CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release --features "$features" +# Split debug info into a separate file, then strip the binary. +# The .debug file can be extracted as a CI artifact for production debugging. +# The debuglink allows gdb to auto-discover the debug file when placed next to the binary. +RUN objcopy --only-keep-debug /windmill/target/release/windmill /windmill/target/release/windmill.debug \ + && strip /windmill/target/release/windmill \ + && objcopy --add-gnu-debuglink=/windmill/target/release/windmill.debug /windmill/target/release/windmill + +# Standalone stage for extracting the .debug file without including it in the final image. +# Build with: docker build --target debuginfo --output type=local,dest=./out . +FROM scratch AS debuginfo +COPY --from=builder /windmill/target/release/windmill.debug /windmill.debug + FROM ${DEBIAN_IMAGE} ARG TARGETPLATFORM @@ -268,7 +280,7 @@ RUN bun install -g windmill-cli \ RUN curl -fsSL https://claude.ai/install.sh | bash \ && cp /root/.local/share/claude/versions/* /usr/bin/claude -COPY --from=php:8.3.30-cli /usr/local/bin/php /usr/bin/php +COPY --from=php:8.3.30-cli-bookworm /usr/local/bin/php /usr/bin/php COPY --from=composer:2.9.5 /usr/bin/composer /usr/bin/composer # add the docker client to call docker from a worker if enabled diff --git a/README_WORKMUX_DEV.md b/README_WORKMUX_DEV.md index 0b113e47ee..20d16d0352 100644 --- a/README_WORKMUX_DEV.md +++ b/README_WORKMUX_DEV.md @@ -192,70 +192,6 @@ sandbox: This mounts both the main EE repo (used by the main worktree) and the EE worktrees directory (used by feature worktrees) into every sandbox container. -## Cursor SSH Integration (`wmc`) - -`wm-cursor` (aliased as `wmc`) gives each worktree its own Cursor SSH remote window with an independently-focused tmux session. All windows are visible in the status bar across all Cursor terminals, but each one is focused on its own worktree. - -This uses **grouped tmux sessions** — multiple sessions that share the same window list but track focus independently: - -``` -tmux session: main <-- your main Cursor terminal -tmux session: cursor-feat-a <-- Cursor window for feat-a (focused on wm-feat-a) -tmux session: cursor-feat-b <-- Cursor window for feat-b (focused on wm-feat-b) - \__ all three share the same windows in the status bar -``` - -### Setup - -Run once from inside tmux on the remote: - -```bash -./scripts/wm-cursor setup /home/hugo/projects/windmill -``` - -This: - -1. **Merges `.vscode/settings.json`** — adds the `wm-tmux` terminal profile (auto-attaches to the `main` tmux session), disables auto port forwarding, configures forwarding for ports 8000/3000/5432, and stops rust-analyzer from auto-starting. Existing settings are preserved. -2. **Creates `.vscode/tasks.json`** — auto-starts the dev database (`start-dev-db.sh`) when the folder opens. -3. **Adds `wmc` alias to `~/.zshrc`** — so you can use `wmc` from any tmux window. -4. **Adds `eval "$(wmc completions)"`** to `~/.zshrc` — provides tab-completion for subcommands and worktree names (for `open`, `open-ee`, and `close`). - -After setup, reopen Cursor's terminal to pick up the new profile. - -### Usage - -All commands run from inside a tmux session (i.e., from Cursor's integrated terminal after setup). - -**Create a new worktree + open Cursor:** - -```bash -wmc add -A -p "implement feature X" -``` - -This runs `workmux add`, creates a grouped tmux session, writes `.vscode/settings.json` in the worktree (with port forwarding matching the worktree's assigned ports), and opens a new Cursor window. - -**Open Cursor for an existing worktree:** - -```bash -wmc open my-feature -``` - -**Open the EE worktree in Cursor (no tmux session):** - -```bash -wmc open-ee my-feature -``` - -This finds the matching `windmill-ee-private__worktrees/` directory and opens it in a new Cursor window. - -**Close a worktree's Cursor window and tmux window (keeps the worktree):** - -```bash -wmc close my-feature -``` - -This kills the grouped tmux session and calls `workmux close` to close the tmux window. The worktree and branch are preserved. Grouped sessions are also automatically cleaned up when you `workmux rm` a worktree (via `scripts/worktree-cleanup`). - ## Cargo Features To build the backend with specific Cargo features (e.g., `enterprise`, `parquet`), pass them via `CARGO_FEATURES`. The backend pane reads this from `.env.local` and appends `--features ` to the `cargo watch` command. @@ -270,20 +206,6 @@ CARGO_FEATURES="enterprise,parquet" wm add my-feature This gets written to `.env.local` by the `post_create` hook (`scripts/worktree-env`), and the backend pane picks it up automatically. -**With `wmc` (wm-cursor):** - -Use the `--features` flag: - -```bash -# Create a new worktree with features -wmc add --features "enterprise,parquet" -A -p "implement feature X" - -# Open an existing worktree with different features -wmc open my-feature --features "enterprise,parquet" -``` - -The `--features` flag exports `CARGO_FEATURES` so the `post_create` hook writes it to `.env.local`. When using `wmc open`, it updates the existing `.env.local` with the new features. - ## Login Default credentials: `admin@windmill.dev` / `changeme` diff --git a/backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json b/backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json new file mode 100644 index 0000000000..5dcf8e0792 --- /dev/null +++ b/backend/.sqlx/query-038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, topic, partition, \"offset\" FROM kafka_pending_commits\n WHERE workspace_id = $1 AND kafka_trigger_path = $2\n ORDER BY id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "topic", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "partition", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "offset", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "038d2fde90fa9e99e30d15161777fa3ab402e33edfca46daa95b52e525424586" +} diff --git a/backend/.sqlx/query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json b/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json similarity index 82% rename from backend/.sqlx/query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json rename to backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json index 7857ca1b4e..53a3863587 100644 --- a/backend/.sqlx/query-b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b.json +++ b/backend/.sqlx/query-05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", + "query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "first_time_user", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" } ], "parameters": { @@ -70,8 +75,9 @@ true, true, null, + false, false ] }, - "hash": "b5ade857a358f2fee4bb7d005e5fef1cabea003419c891f8b1e52bc2c0156b0b" + "hash": "05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab" } diff --git a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json index e7ed0aee65..d29a18c691 100644 --- a/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json +++ b/backend/.sqlx/query-07168aaf14cb6beff0ad4274b441f7f387f5055c47f493271d26731336257384.json @@ -46,11 +46,11 @@ ] }, "nullable": [ - false, - false, - false, - false, - false, + true, + true, + true, + true, + true, true, true ] diff --git a/backend/.sqlx/query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json b/backend/.sqlx/query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json similarity index 54% rename from backend/.sqlx/query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json rename to backend/.sqlx/query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json index c452c33018..812c323e74 100644 --- a/backend/.sqlx/query-12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46.json +++ b/backend/.sqlx/query-072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n filters = $4,\n auto_offset_reset = $5,\n script_path = $6,\n path = $7,\n is_flow = $8,\n edited_by = $9,\n email = $10,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $13,\n error_handler_args = $14,\n retry = $15\n WHERE\n workspace_id = $11 AND path = $12\n ", + "query": "\n UPDATE kafka_trigger\n SET\n kafka_resource_path = $1,\n group_id = $2,\n topics = $3,\n filters = $4,\n auto_offset_reset = $5,\n auto_commit = $6,\n script_path = $7,\n path = $8,\n is_flow = $9,\n edited_by = $10,\n email = $11,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $14,\n error_handler_args = $15,\n retry = $16\n WHERE\n workspace_id = $12 AND path = $13\n ", "describe": { "columns": [], "parameters": { @@ -10,6 +10,7 @@ "VarcharArray", "JsonbArray", "Varchar", + "Bool", "Varchar", "Varchar", "Bool", @@ -24,5 +25,5 @@ }, "nullable": [] }, - "hash": "12631fecee6aa11a45cf5c8d101c0dd8de50ac9b57e68198f637038d344fdd46" + "hash": "072e5ab78f929c6b7264f98c1588cb24cc635836276ee6faa2438f494bfbce04" } diff --git a/backend/.sqlx/query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json b/backend/.sqlx/query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json similarity index 57% rename from backend/.sqlx/query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json rename to backend/.sqlx/query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json index 98bafc734c..39104e0251 100644 --- a/backend/.sqlx/query-10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4.json +++ b/backend/.sqlx/query-0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary, instance_role", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "emails", "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, true, - null + null, + true ] }, - "hash": "10f6d3ffd7406146572b1becdce5c8da5242b58f6ce46ab10296cff9d6a3a6c4" + "hash": "0aef85e3dc8910d7243f9e5a26795d3488e0969f2c334ab7c0bfcd5235e3dd82" } diff --git a/backend/.sqlx/query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json b/backend/.sqlx/query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json similarity index 58% rename from backend/.sqlx/query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json rename to backend/.sqlx/query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json index b703a00e96..b15885016f 100644 --- a/backend/.sqlx/query-a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1.json +++ b/backend/.sqlx/query-0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, instance_role", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "emails", "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -25,8 +30,9 @@ "nullable": [ false, true, - null + null, + true ] }, - "hash": "a00f3f18087326432c9114998e47cff4f78d1b28cdb8adc6b18b937e1cf142d1" + "hash": "0b0f601716c6713f8b521a65dba01303a7756f654d1a2c04bd47c0f2d1122155" } diff --git a/backend/.sqlx/query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json b/backend/.sqlx/query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json new file mode 100644 index 0000000000..734f76ba0a --- /dev/null +++ b/backend/.sqlx/query-0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token_hash FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0f5a31f328e59befb7dd3c3cb44439a0405d479e02ac79c2f4ec9a97636bd80d" +} diff --git a/backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json b/backend/.sqlx/query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json similarity index 75% rename from backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json rename to backend/.sqlx/query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json index 9085383617..a59afbf3ef 100644 --- a/backend/.sqlx/query-bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f.json +++ b/backend/.sqlx/query-104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE expiration <= now()\n RETURNING substring(token for 10) as token_prefix, label, email, workspace_id", + "query": "DELETE FROM token WHERE expiration <= now()\n RETURNING token_prefix, label, email, workspace_id", "describe": { "columns": [ { "ordinal": 0, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 1, @@ -28,11 +28,11 @@ "Left": [] }, "nullable": [ - null, + false, true, true, true ] }, - "hash": "bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f" + "hash": "104fc7e5433abd7247323c5ef76b85f937776a6b47cd99c648bb4d819d3cfe57" } diff --git a/backend/.sqlx/query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json b/backend/.sqlx/query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json similarity index 50% rename from backend/.sqlx/query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json rename to backend/.sqlx/query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json index 56edad4b2c..df0c67b88e 100644 --- a/backend/.sqlx/query-411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824.json +++ b/backend/.sqlx/query-11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE password SET devops = $1 WHERE email = $2", + "query": "UPDATE password SET super_admin = $1, role_source = 'manual' WHERE email = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "411788111afccd826ce78b266153600939c65c75be8894322b90d9da18dcb824" + "hash": "11d89b437b9fe5d493e1806438dd56ef8c427aa1bbcfe2a11ba34cc8eab9fb4e" } diff --git a/backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json b/backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json deleted file mode 100644 index 5552400f28..0000000000 --- a/backend/.sqlx/query-15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, label, super_admin, email)\n VALUES ($1, $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Bool", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "15ef5759a2ccd7b7f9fd3f2ce0d54d01fe0a2c7e9692ac4ce29a86eb509e1a1d" -} diff --git a/backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json b/backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json new file mode 100644 index 0000000000..85eaed8eda --- /dev/null +++ b/backend/.sqlx/query-1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)\n VALUES ($1, $2, $3, 'test@windmill.dev', 'webhook-test', false)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1a2470da1015634d15952819f482749ef04e1a8c944c0fb7696e387d10370217" +} diff --git a/backend/.sqlx/query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json b/backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json similarity index 80% rename from backend/.sqlx/query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json rename to backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json index 536175599d..492fffe8be 100644 --- a/backend/.sqlx/query-ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c.json +++ b/backend/.sqlx/query-1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_prefix,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 5, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" }, { @@ -95,5 +95,5 @@ false ] }, - "hash": "ee537def1ead8bee48bb9f5c1f57d42e7add6011c34d91761ba23e2c74c4032c" + "hash": "1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce" } diff --git a/backend/.sqlx/query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json b/backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json similarity index 68% rename from backend/.sqlx/query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json rename to backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json index 191b010aec..ae055e1b5f 100644 --- a/backend/.sqlx/query-8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513.json +++ b/backend/.sqlx/query-1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -43,12 +43,12 @@ }, "nullable": [ true, - null, + false, true, false, false, true ] }, - "hash": "8be2919c3511575c89b882b112b987fd5724c299cb285f819a2561260404e513" + "hash": "1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb" } diff --git a/backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json b/backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json new file mode 100644 index 0000000000..babf3ffbcc --- /dev/null +++ b/backend/.sqlx/query-1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT reset_offset FROM kafka_trigger WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "reset_offset", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1df610a583e86edb70c374fd66c68554a6a4291426c09dd5b04fd832f9d31208" +} diff --git a/backend/.sqlx/query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json b/backend/.sqlx/query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json similarity index 61% rename from backend/.sqlx/query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json rename to backend/.sqlx/query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json index 3e7972dbd7..e1cdb6416d 100644 --- a/backend/.sqlx/query-6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163.json +++ b/backend/.sqlx/query-207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT label,\n concat(substring(token for 10)) AS token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:flows:' || $2]::text[]\n OR scopes @> ARRAY['run:flow/' || $2]::text[]\n )\n ", + "query": "\n SELECT label,\n token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:scripts:' || $2]::text[]\n OR scopes @> ARRAY['run:script/' || $2]::text[]\n )\n ", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -47,7 +47,7 @@ }, "nullable": [ true, - null, + false, true, false, false, @@ -55,5 +55,5 @@ true ] }, - "hash": "6a254de9005594dc75a59a545546417c8a5aa7635be1dc0b37dc29d0f9e7c163" + "hash": "207106aa8267fe756989f3ee1eadb7e169d07463f67f1da79c8bc23c1079c185" } diff --git a/backend/.sqlx/query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json b/backend/.sqlx/query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json similarity index 55% rename from backend/.sqlx/query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json rename to backend/.sqlx/query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json index b58e3bf8d8..54a7a25b68 100644 --- a/backend/.sqlx/query-1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf.json +++ b/backend/.sqlx/query-215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH email_lookup AS (\n SELECT email FROM token WHERE token = $1\n )\n DELETE FROM token\n WHERE email = (SELECT email FROM email_lookup) AND label = 'session'\n RETURNING email", + "query": "WITH email_lookup AS (\n SELECT email FROM token WHERE token_hash = $1\n )\n DELETE FROM token\n WHERE email = (SELECT email FROM email_lookup) AND label = 'session'\n RETURNING email", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "1bdf186d3b99bbd913cbf95150105470cd5f1d4ddbb147cb8ce46f9d1da5dfaf" + "hash": "215163b5a2791c51f9b28681c1ca1a47475dcf1a388c613a9e0154aef6582a23" } diff --git a/backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json b/backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json new file mode 100644 index 0000000000..8595b12ec6 --- /dev/null +++ b/backend/.sqlx/query-223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id)\n SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar\n WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "223fbd972728d5b3ec5b1708e3f2e1f4901b0382fca50704c9544cdec5f9352c" +} diff --git a/backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json b/backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json new file mode 100644 index 0000000000..f2acda445d --- /dev/null +++ b/backend/.sqlx/query-27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT encode(sha256('SECRET_TOKEN'::bytea), 'hex') AS hash", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "27cafd840e5f2c85d1c1e02d84a1b372e9d40dee29a10fb8fec89492fc501556" +} diff --git a/backend/.sqlx/query-2c256552a430877c42224055aeb81df33d88ff295483cb28369eda42ce58afec.json b/backend/.sqlx/query-2c256552a430877c42224055aeb81df33d88ff295483cb28369eda42ce58afec.json new file mode 100644 index 0000000000..a7b1b3793b --- /dev/null +++ b/backend/.sqlx/query-2c256552a430877c42224055aeb81df33d88ff295483cb28369eda42ce58afec.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (\n workspace_id, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n )\n SELECT\n $1, hash, path, parent_hashes, summary, description, content,\n created_by, created_at, archived, schema, deleted, is_template,\n extra_perms, lock, lock_error_logs, language, kind, tag, draft_only,\n envs, concurrent_limit, concurrency_time_window_s, cache_ttl,\n dedicated_worker, ws_error_handler_muted, priority, timeout,\n delete_after_use, restart_unless_cancelled, concurrency_key,\n visible_to_runner_only, auto_kind, codebase, has_preprocessor,\n on_behalf_of_email, assets, modules\n FROM script\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2c256552a430877c42224055aeb81df33d88ff295483cb28369eda42ce58afec" +} diff --git a/backend/.sqlx/query-36b556a1c8630547cb7f5f88a1a0f02effb9e62409cd61fa4de60d11d50ee206.json b/backend/.sqlx/query-36b556a1c8630547cb7f5f88a1a0f02effb9e62409cd61fa4de60d11d50ee206.json new file mode 100644 index 0000000000..66ce2799fd --- /dev/null +++ b/backend/.sqlx/query-36b556a1c8630547cb7f5f88a1a0f02effb9e62409cd61fa4de60d11d50ee206.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT j.id\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n WHERE r.ping < now() - ($1 || ' seconds')::interval\n AND q.running = true AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') AND j.same_worker = false AND q.suspend_until IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "36b556a1c8630547cb7f5f88a1a0f02effb9e62409cd61fa4de60d11d50ee206" +} diff --git a/backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json b/backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json new file mode 100644 index 0000000000..e625a747f7 --- /dev/null +++ b/backend/.sqlx/query-36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT (config->>'native_mode')::boolean FROM config WHERE name = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "bool", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "36b95bc7956eb7bba7cd6fa9cd829980a0bf4970b919cabad1daab16627404fc" +} diff --git a/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json b/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json new file mode 100644 index 0000000000..2b5b68dfae --- /dev/null +++ b/backend/.sqlx/query-406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "label", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 3, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "owner", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "expiration", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true, + true, + false, + true, + true + ] + }, + "hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026" +} diff --git a/backend/.sqlx/query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json b/backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json similarity index 63% rename from backend/.sqlx/query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json rename to backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json index 1199e9441f..706fa0c9ea 100644 --- a/backend/.sqlx/query-27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b.json +++ b/backend/.sqlx/query-40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_prefix = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ", + "query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ", "describe": { "columns": [], "parameters": { @@ -26,5 +26,5 @@ }, "nullable": [] }, - "hash": "27ada97cb533c8595f1d73987c7823d8e54c96889e06895c57cafae9ca27bf8b" + "hash": "40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e" } diff --git a/backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json b/backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json new file mode 100644 index 0000000000..49c3763688 --- /dev/null +++ b/backend/.sqlx/query-449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ig.instance_role FROM email_to_igroup eig\n JOIN instance_group ig ON ig.name = eig.igroup\n WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "instance_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "449934711f09b700fca46be3e165d37d14d51d5c95776e2d5491b5c5ab3e25b7" +} diff --git a/backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json b/backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json new file mode 100644 index 0000000000..b5873760fc --- /dev/null +++ b/backend/.sqlx/query-45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id, script_path,\n is_flow, workspace_id, edited_by, email, auto_commit\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "VarcharArray", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Varchar", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "45fc21026fa76e5d69f00a68a7be81abb3ec627578f2d14f0ce33896dc6ab4cf" +} diff --git a/backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json b/backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json new file mode 100644 index 0000000000..18a4671616 --- /dev/null +++ b/backend/.sqlx/query-48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE workspace_settings\n SET git_app_installations = (\n SELECT jsonb_agg(\n CASE\n WHEN (elem->>'installation_id')::bigint = $2 THEN $1::jsonb\n ELSE elem\n END\n )\n FROM jsonb_array_elements(git_app_installations) AS elem\n )\n WHERE workspace_id = $3\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "48b394bd9ca63d33a7ea97113b0096bd0777da52c05e23262572089e0c3c6c46" +} diff --git a/backend/.sqlx/query-49b18e987e2dfa3c7ab915757ff3b9c0e6e371136b565f9b0f5a3393ef8d8d57.json b/backend/.sqlx/query-49b18e987e2dfa3c7ab915757ff3b9c0e6e371136b565f9b0f5a3393ef8d8d57.json new file mode 100644 index 0000000000..8d86ff3db6 --- /dev/null +++ b/backend/.sqlx/query-49b18e987e2dfa3c7ab915757ff3b9c0e6e371136b565f9b0f5a3393ef8d8d57.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH update_lock AS (\n UPDATE script SET lock = $1, modules = COALESCE($6, modules) WHERE hash = $2 AND workspace_id = $3\n )\n INSERT INTO lock_hash (workspace_id, path, lockfile_hash)\n VALUES ($3, $4, $5)\n ON CONFLICT (workspace_id, path) DO UPDATE SET lockfile_hash = $5", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8", + "Text", + "Varchar", + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "49b18e987e2dfa3c7ab915757ff3b9c0e6e371136b565f9b0f5a3393ef8d8d57" +} diff --git a/backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json b/backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json new file mode 100644 index 0000000000..db78e7dc89 --- /dev/null +++ b/backend/.sqlx/query-4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT super_admin, devops FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "devops", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "4afabac265755dd90c33193260eea8be1f62e4607fcd8f401701ab66f6d20cae" +} diff --git a/backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json b/backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json new file mode 100644 index 0000000000..6dd5c799e7 --- /dev/null +++ b/backend/.sqlx/query-4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT auto_commit FROM kafka_trigger WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "auto_commit", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4b2a29b3ef7ec4802d81ec4b706623b991c938e40d0db25290b03dc0577c2740" +} diff --git a/backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json b/backend/.sqlx/query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json similarity index 54% rename from backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json rename to backend/.sqlx/query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json index 3d63bbcfbf..8fc87ce587 100644 --- a/backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json +++ b/backend/.sqlx/query-4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE token = $1", + "query": "DELETE FROM token WHERE token_hash = $1", "describe": { "columns": [], "parameters": { @@ -10,5 +10,5 @@ }, "nullable": [] }, - "hash": "66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a" + "hash": "4b76c4a387786bc5bb69e4c684c34b936c3ffff44ae58f0709d05ba3ff534f79" } diff --git a/backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json b/backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json new file mode 100644 index 0000000000..10c513f276 --- /dev/null +++ b/backend/.sqlx/query-4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token_hash FROM token WHERE email = 'test@windmill.dev' AND label = 'test token'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token_hash", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "4bf2f3c6771ab4a15b94ba713ebaab2b35961f750600500e3736edcff1c191fe" +} diff --git a/backend/.sqlx/query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json b/backend/.sqlx/query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json similarity index 61% rename from backend/.sqlx/query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json rename to backend/.sqlx/query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json index 251fce2637..5cf5699f2a 100644 --- a/backend/.sqlx/query-29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1.json +++ b/backend/.sqlx/query-4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT label,\n concat(substring(token for 10)) AS token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:scripts:' || $2]::text[]\n OR scopes @> ARRAY['run:script/' || $2]::text[]\n )\n ", + "query": "\n SELECT label,\n token_prefix,\n expiration,\n created_at,\n last_used_at,\n scopes,\n email\n FROM token\n WHERE workspace_id = $1\n AND (\n scopes @> ARRAY['jobs:run:flows:' || $2]::text[]\n OR scopes @> ARRAY['run:flow/' || $2]::text[]\n )\n ", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -47,7 +47,7 @@ }, "nullable": [ true, - null, + false, true, false, false, @@ -55,5 +55,5 @@ true ] }, - "hash": "29673d489fbf45fc249da04c1a2fd60e2364ba87263f962ed7d4329c916620a1" + "hash": "4c7231f24fd0bcc99004c5bd4065697cd321b397422a7c689b85216bbb1fd525" } diff --git a/backend/.sqlx/query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json b/backend/.sqlx/query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json similarity index 61% rename from backend/.sqlx/query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json rename to backend/.sqlx/query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json index 23652a2571..9b76b7f048 100644 --- a/backend/.sqlx/query-7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df.json +++ b/backend/.sqlx/query-4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT kafka_resource_path, topics, group_id, mode AS \"mode: String\"\n FROM kafka_trigger\n WHERE workspace_id = $1 AND path = $2\n ", + "query": "\n SELECT kafka_resource_path, topics, group_id, mode AS \"mode: String\",\n auto_offset_reset, auto_commit, reset_offset\n FROM kafka_trigger\n WHERE workspace_id = $1 AND path = $2\n ", "describe": { "columns": [ { @@ -33,6 +33,21 @@ } } } + }, + { + "ordinal": 4, + "name": "auto_offset_reset", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "auto_commit", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "reset_offset", + "type_info": "Bool" } ], "parameters": { @@ -42,11 +57,14 @@ ] }, "nullable": [ + false, + false, + false, false, false, false, false ] }, - "hash": "7e3bfb33fb771aec39b43a7550091ce7c9b1261b52d10f4a7f3273fed3c916df" + "hash": "4cf4be7a981173d3f242887d9313c7e60d23e9827f23c0de5b546ed56697d54a" } diff --git a/backend/.sqlx/query-4d983f1e3e63a1a70edf5d867d9f23f2069a7a4ba1dcc1331ecccdf1c6a95cb8.json b/backend/.sqlx/query-4d983f1e3e63a1a70edf5d867d9f23f2069a7a4ba1dcc1331ecccdf1c6a95cb8.json new file mode 100644 index 0000000000..33ba98705d --- /dev/null +++ b/backend/.sqlx/query-4d983f1e3e63a1a70edf5d867d9f23f2069a7a4ba1dcc1331ecccdf1c6a95cb8.json @@ -0,0 +1,97 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO script (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Varchar", + "Int8Array", + "Text", + "Text", + "Text", + "Varchar", + "Text", + "Bool", + "Jsonb", + "Text", + { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby" + ] + } + } + }, + { + "Custom": { + "name": "script_kind", + "kind": { + "Enum": [ + "script", + "trigger", + "failure", + "command", + "approval", + "preprocessor" + ] + } + } + }, + "Varchar", + "Bool", + "VarcharArray", + "Int4", + "Int4", + "Int4", + "Bool", + "Bool", + "Int2", + "Bool", + "Bool", + "Int4", + "Varchar", + "Bool", + "Varchar", + "Varchar", + "Bool", + "Text", + "Bool", + "Jsonb", + "Varchar", + "Int4", + "Bool", + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "4d983f1e3e63a1a70edf5d867d9f23f2069a7a4ba1dcc1331ecccdf1c6a95cb8" +} diff --git a/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json new file mode 100644 index 0000000000..27d46b27ed --- /dev/null +++ b/backend/.sqlx/query-4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)\n VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90" +} diff --git a/backend/.sqlx/query-4fdb9dc38c0a8e882a1dee39e42664b4c85fd43edbd7ebd8fd5ad380e5a8e3cc.json b/backend/.sqlx/query-4fdb9dc38c0a8e882a1dee39e42664b4c85fd43edbd7ebd8fd5ad380e5a8e3cc.json new file mode 100644 index 0000000000..06fc58d7de --- /dev/null +++ b/backend/.sqlx/query-4fdb9dc38c0a8e882a1dee39e42664b4c85fd43edbd7ebd8fd5ad380e5a8e3cc.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT worker, array_agg(v2_job_queue.id) as ids FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id LEFT JOIN v2_job_runtime ON v2_job_queue.id = v2_job_runtime.id WHERE v2_job_queue.created_at < now() - ('60 seconds')::interval\n AND running = true AND (ping IS NULL OR ping < now() - ('60 seconds')::interval) AND same_worker = true AND worker IS NOT NULL AND v2_job_queue.suspend_until IS NULL GROUP BY worker", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "worker", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "ids", + "type_info": "UuidArray" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + true, + null + ] + }, + "hash": "4fdb9dc38c0a8e882a1dee39e42664b4c85fd43edbd7ebd8fd5ad380e5a8e3cc" +} diff --git a/backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json b/backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json new file mode 100644 index 0000000000..ec667519e4 --- /dev/null +++ b/backend/.sqlx/query-50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT auto_commit\n FROM kafka_trigger\n WHERE workspace_id = $1 AND path = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "auto_commit", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "50807b807bb901a380926798be655c13a18dfd26e237a8218d3006e2898b5aa3" +} diff --git a/backend/.sqlx/query-51f09f073842a6990535b887d8267fab305c21e4d7703bedbadf405b5c2d7582.json b/backend/.sqlx/query-51f09f073842a6990535b887d8267fab305c21e4d7703bedbadf405b5c2d7582.json new file mode 100644 index 0000000000..89bb7c33ca --- /dev/null +++ b/backend/.sqlx/query-51f09f073842a6990535b887d8267fab305c21e4d7703bedbadf405b5c2d7582.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO script\n (workspace_id, hash, path, parent_hashes, summary, description, content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules)\n\n SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules\n\n FROM script WHERE hash = $2 AND workspace_id = $3;\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "51f09f073842a6990535b887d8267fab305c21e4d7703bedbadf405b5c2d7582" +} diff --git a/backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json b/backend/.sqlx/query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json similarity index 65% rename from backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json rename to backend/.sqlx/query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json index da0ce60709..50a30bd742 100644 --- a/backend/.sqlx/query-2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91.json +++ b/backend/.sqlx/query-52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf.json @@ -1,11 +1,11 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token", + "query": "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token_prefix", "describe": { "columns": [ { "ordinal": 0, - "name": "token", + "name": "token_prefix", "type_info": "Varchar" } ], @@ -18,5 +18,5 @@ false ] }, - "hash": "2d6607b3c38fe72b5663c32de58dacbabed4c5ae28101e3ae2694f96fd055a91" + "hash": "52379713a1f7312127bcd13c9a8027a85270c25c5a0f0d4d7670bd602bd3cebf" } diff --git a/backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json b/backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json deleted file mode 100644 index 39355ffc0c..0000000000 --- a/backend/.sqlx/query-54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "54756c6c39888feb2206b056df1c84c3bb44adc490309954359845c06b6e607c" -} diff --git a/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json b/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json new file mode 100644 index 0000000000..de1b71a1e6 --- /dev/null +++ b/backend/.sqlx/query-54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "54c0c20fe025d4fb45f04ff3389b25915f671e7c52426fc54b2fd533b90596e2" +} diff --git a/backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json b/backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json new file mode 100644 index 0000000000..cc515a4e8f --- /dev/null +++ b/backend/.sqlx/query-56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT token FROM token WHERE token_hash = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "56031289603fbf9c60ff2c04750fa0e94550eb617612c2bba81b9ce150d355b5" +} diff --git a/backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json b/backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json deleted file mode 100644 index f1346579a2..0000000000 --- a/backend/.sqlx/query-58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n DELETE FROM token\n WHERE token LIKE concat($1::text, '%')\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "58dc872520beaa914fef8b7f30e578261fb9ebd92a81e1f2c8edaf93cece0819" -} diff --git a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json index 36ddb8ab9f..713ccb9dd3 100644 --- a/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json +++ b/backend/.sqlx/query-5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55.json @@ -15,7 +15,7 @@ ] }, "nullable": [ - true + null ] }, "hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55" diff --git a/backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json b/backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json new file mode 100644 index 0000000000..25bbd4f835 --- /dev/null +++ b/backend/.sqlx/query-5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "5c09c2ffb28f6eee3d7e48bd6373c0bcddc0943346f02315b962db3b13590d30" +} diff --git a/backend/.sqlx/query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json b/backend/.sqlx/query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json similarity index 63% rename from backend/.sqlx/query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json rename to backend/.sqlx/query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json index d90a467380..936a4650f6 100644 --- a/backend/.sqlx/query-4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f.json +++ b/backend/.sqlx/query-5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n auto_offset_reset,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, now(), $13, $14, $15\n )\n ", + "query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n auto_offset_reset,\n auto_commit,\n script_path,\n is_flow,\n mode,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, now(), $14, $15, $16\n )\n ", "describe": { "columns": [], "parameters": { @@ -12,6 +12,7 @@ "VarcharArray", "JsonbArray", "Varchar", + "Bool", "Varchar", "Bool", { @@ -35,5 +36,5 @@ }, "nullable": [] }, - "hash": "4b5a711986017654bdd495893a16ddc6ab09c98cd8723865cbd341404bc6a02f" + "hash": "5dd6315ec270c268e905262e4b0a920837354d91a0ae16b1236c1267da71765f" } diff --git a/backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json b/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json similarity index 81% rename from backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json rename to backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json index 9eb47caf8a..6d2382f494 100644 --- a/backend/.sqlx/query-9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59.json +++ b/backend/.sqlx/query-60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", + "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "first_time_user", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" } ], "parameters": { @@ -70,8 +75,9 @@ true, true, true, + false, false ] }, - "hash": "9229d9a9ff389cf26e480b604b83900e2d362ee934ef27284ef39f4eed440e59" + "hash": "60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce" } diff --git a/backend/.sqlx/query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json b/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json similarity index 84% rename from backend/.sqlx/query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json rename to backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json index 9474727ace..2ccf7bfdc8 100644 --- a/backend/.sqlx/query-37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330.json +++ b/backend/.sqlx/query-65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user FROM password WHERE email = $1", + "query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE email = $1", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "first_time_user", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "role_source", + "type_info": "Varchar" } ], "parameters": { @@ -69,8 +74,9 @@ true, true, null, + false, false ] }, - "hash": "37e23397905e25bbbf5a7047c790967a97cc8f6948beef706b0c053621882330" + "hash": "65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9" } diff --git a/backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json b/backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json new file mode 100644 index 0000000000..53999f934a --- /dev/null +++ b/backend/.sqlx/query-6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n ws.workspace_id,\n w.name as workspace_name,\n ws.auto_invite->'instance_groups_roles'->$1 as role\n FROM workspace_settings ws\n INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n WHERE ws.auto_invite->'instance_groups' ? $1\n ORDER BY ws.workspace_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "workspace_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "role", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "6a1cc753157c51481932ad8079f8e719ec455acd53e30984087d5c3a4a03cd25" +} diff --git a/backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json b/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json similarity index 71% rename from backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json rename to backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json index d6240c6b3a..bf734e5861 100644 --- a/backend/.sqlx/query-83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75.json +++ b/backend/.sqlx/query-6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "query": "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", "describe": { "columns": [ { @@ -10,7 +10,7 @@ }, { "ordinal": 1, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" } ], @@ -35,5 +35,5 @@ false ] }, - "hash": "83d6e371ca84903e9f487afc065353a9f7be86ff752612909587ec3cb770cb75" + "hash": "6c75c89fb215c646f54f2036c40a86a4f4ea8880cc5d0b511aa70b6fd50072c5" } diff --git a/backend/.sqlx/query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json b/backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json similarity index 62% rename from backend/.sqlx/query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json rename to backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json index d3f1c39c7a..69af249a3f 100644 --- a/backend/.sqlx/query-023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75.json +++ b/backend/.sqlx/query-6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_prefix,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_prefix = $6, service_config = $7, error = NULL, updated_at = NOW()\n ", + "query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW()\n ", "describe": { "columns": [], "parameters": { @@ -26,5 +26,5 @@ }, "nullable": [] }, - "hash": "023cdbc77ea9e2c17a1aa92a5b9001f29e58e81b3f782887db6e0a627dd8ad75" + "hash": "6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7" } diff --git a/backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json b/backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json new file mode 100644 index 0000000000..b3f0d62ca8 --- /dev/null +++ b/backend/.sqlx/query-722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE token_hash = $1 RETURNING email", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "722f9da2b3ad1e1129928c52498b994db0dc1728945f90fd23b707ee355d0472" +} diff --git a/backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json b/backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json new file mode 100644 index 0000000000..e006a18f4b --- /dev/null +++ b/backend/.sqlx/query-741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT end_user_email FROM job_perms WHERE job_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "end_user_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "741e92197bdaeab62b5b0cff86e629a3e94247c696ce1e222a0e830630141c13" +} diff --git a/backend/.sqlx/query-77e15dee033788972b6e09ea59fb3771928d04be926821e8b764e8af9cff03bb.json b/backend/.sqlx/query-77e15dee033788972b6e09ea59fb3771928d04be926821e8b764e8af9cff03bb.json new file mode 100644 index 0000000000..db803a8b0c --- /dev/null +++ b/backend/.sqlx/query-77e15dee033788972b6e09ea59fb3771928d04be926821e8b764e8af9cff03bb.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH to_update AS (\n SELECT q.id, q.workspace_id, r.ping, COALESCE(zjc.counter, 0) as counter\n FROM v2_job_queue q\n JOIN v2_job j ON j.id = q.id\n JOIN v2_job_runtime r ON r.id = j.id\n LEFT JOIN zombie_job_counter zjc ON zjc.job_id = q.id\n WHERE ping < now() - ($1 || ' seconds')::interval\n AND running = true\n AND kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow')\n AND same_worker = false\n AND q.suspend_until IS NULL\n AND (zjc.counter IS NULL OR zjc.counter <= $2)\n FOR UPDATE of q SKIP LOCKED\n ),\n zombie_jobs AS (\n UPDATE v2_job_queue q\n SET running = false, started_at = null\n FROM to_update tu\n WHERE q.id = tu.id AND (tu.counter IS NULL OR tu.counter < $2)\n RETURNING q.id, q.workspace_id, ping, tu.counter\n ),\n update_ping AS (\n UPDATE v2_job_runtime r\n SET ping = null\n FROM zombie_jobs zj\n WHERE r.id = zj.id\n ),\n increment_counter AS (\n INSERT INTO zombie_job_counter (job_id, counter)\n SELECT id, 1 FROM to_update WHERE counter < $2\n ON CONFLICT (job_id) DO UPDATE\n SET counter = zombie_job_counter.counter + 1\n ),\n update_concurrency AS (\n UPDATE concurrency_counter cc\n SET job_uuids = job_uuids - zj.id::text\n FROM zombie_jobs zj\n INNER JOIN concurrency_key ck ON ck.job_id = zj.id\n WHERE cc.concurrency_id = ck.key\n )\n SELECT id AS \"id!\", workspace_id AS \"workspace_id!\", ping, counter + 1 AS counter FROM to_update", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "ping", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "counter", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + null + ] + }, + "hash": "77e15dee033788972b6e09ea59fb3771928d04be926821e8b764e8af9cff03bb" +} diff --git a/backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json b/backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json new file mode 100644 index 0000000000..90ee071042 --- /dev/null +++ b/backend/.sqlx/query-7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT super_admin, devops, role_source FROM password WHERE email = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "super_admin", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "devops", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "role_source", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "7bdee54a36cc873c611f8a41c0470226ceb1b94a53dd540cdc27a0e1060aff12" +} diff --git a/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json b/backend/.sqlx/query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json similarity index 57% rename from backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json rename to backend/.sqlx/query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json index 4843d959c1..fef6764ba9 100644 --- a/backend/.sqlx/query-bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0.json +++ b/backend/.sqlx/query-8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT true FROM token WHERE token = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", + "query": "SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "bfff3d8df18db198d6ebba8a049b00147fc8bcd42f3df37ef81b9ded80974bd0" + "hash": "8065ed67770101e30eea456c1c682e1900d97721d931cea80a4fe240901b3604" } diff --git a/backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json b/backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json new file mode 100644 index 0000000000..31f2767fe8 --- /dev/null +++ b/backend/.sqlx/query-80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM kafka_pending_commits WHERE id = ANY($1)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "80bad96cbec6b5eca57a6380e7515565490a271050dcc4b5aac2b730ae3a55b9" +} diff --git a/backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json b/backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json deleted file mode 100644 index b03708c813..0000000000 --- a/backend/.sqlx/query-8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token (token, email, label, super_admin, owner, workspace_id)\n VALUES ('CHARLIE_TOKEN_NEW', 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", - "describe": { - "columns": [], - "parameters": { - "Left": [] - }, - "nullable": [] - }, - "hash": "8515537f73c132e62c4dafad1e8d8e56f0c70dccd0edbc9667f550b31ab54c18" -} diff --git a/backend/.sqlx/query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json b/backend/.sqlx/query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json similarity index 63% rename from backend/.sqlx/query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json rename to backend/.sqlx/query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json index b62dba4bbe..84833c9718 100644 --- a/backend/.sqlx/query-234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7.json +++ b/backend/.sqlx/query-88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id) VALUES ($1, $2, $3, $4, $5)", + "query": "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id, instance_role) VALUES ($1, $2, $3, $4, $5, $6)", "describe": { "columns": [], "parameters": { @@ -9,10 +9,11 @@ "Varchar", "Varchar", "Varchar", + "Varchar", "Varchar" ] }, "nullable": [] }, - "hash": "234a278f20cb73f8ce10d2bfb67af58e5dd888581467c976e76f140b2c00f6d7" + "hash": "88b5d7e6806b1a6f4e6ef5af5fc3fa81e8134f25b1dfcedb765a701f0dae8564" } diff --git a/backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json b/backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json new file mode 100644 index 0000000000..f96090bad5 --- /dev/null +++ b/backend/.sqlx/query-88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes, workspace_id, owner, expiration)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "TextArray", + "Varchar", + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "88b6a76134a822d4b706c361a7c71f5a0ab04cc4ac9f236ffd056d3acdb79711" +} diff --git a/backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json b/backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json deleted file mode 100644 index 90a2f78d2c..0000000000 --- a/backend/.sqlx/query-8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Text", - "Bool" - ] - }, - "nullable": [] - }, - "hash": "8aebd7f7fd1374f1c3d5389e953ebf080df3f76ac3e6e6373a89c8d46388125d" -} diff --git a/backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json b/backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json new file mode 100644 index 0000000000..c711fdfc59 --- /dev/null +++ b/backend/.sqlx/query-9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE password SET super_admin = $1, devops = $2, role_source = 'instance_group' WHERE email = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9330c172624e5a4ac9d3b6c465d37dbe2e92de1b45dfc9f2656fea9d08b354a0" +} diff --git a/backend/.sqlx/query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json b/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json similarity index 70% rename from backend/.sqlx/query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json rename to backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json index a3d8c2502f..c9faa982ba 100644 --- a/backend/.sqlx/query-c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3.json +++ b/backend/.sqlx/query-93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE token SET last_used_at = now() WHERE\n token = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label", + "query": "UPDATE token SET last_used_at = now() WHERE\n token_hash = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label", "describe": { "columns": [ { @@ -43,5 +43,5 @@ true ] }, - "hash": "c7d595d2a12228c49359440ca3a9622f1de5f5ee4bbe5d2b23f6fdb6379cebf3" + "hash": "93aa569329a85799594606a4f77fe955820f7b2761df6b38a6a6615b518188f9" } diff --git a/backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json b/backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json similarity index 62% rename from backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json rename to backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json index 015aa7b05a..d54260b4f7 100644 --- a/backend/.sqlx/query-d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6.json +++ b/backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json @@ -1,12 +1,12 @@ { "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", + "query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id", "describe": { "columns": [ { "ordinal": 0, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 1, @@ -28,11 +28,11 @@ "Left": [] }, "nullable": [ - null, + false, true, true, true ] }, - "hash": "d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6" + "hash": "94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3" } diff --git a/backend/.sqlx/query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json b/backend/.sqlx/query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json similarity index 57% rename from backend/.sqlx/query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json rename to backend/.sqlx/query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json index afd0f503bf..a8fbb473d8 100644 --- a/backend/.sqlx/query-2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1.json +++ b/backend/.sqlx/query-95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE native_trigger\n SET service_config = $1, updated_at = NOW()\n WHERE\n workspace_id = $2\n AND service_name = $3\n AND external_id = $4\n ", + "query": "\n UPDATE native_trigger\n SET service_config = $1,\n webhook_token_hash = COALESCE($5, webhook_token_hash),\n updated_at = NOW()\n WHERE\n workspace_id = $2\n AND service_name = $3\n AND external_id = $4\n ", "describe": { "columns": [], "parameters": { @@ -18,10 +18,11 @@ } } }, - "Text" + "Text", + "Varchar" ] }, "nullable": [] }, - "hash": "2fd22c4ffa2d222bb116260994a748e0639c2f73cbc1d8be66420c70b14c96e1" + "hash": "95e77019bca83ce43b629e7aac429b09a60d732099a2de9e001d2b40a8e919a9" } diff --git a/backend/.sqlx/query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json b/backend/.sqlx/query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json similarity index 51% rename from backend/.sqlx/query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json rename to backend/.sqlx/query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json index a9df312f5e..f03e49e330 100644 --- a/backend/.sqlx/query-6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c.json +++ b/backend/.sqlx/query-96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE password SET super_admin = $1 WHERE email = $2", + "query": "UPDATE password SET devops = $1, role_source = 'manual' WHERE email = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "6c7186de56bcd9983a64de0c01a733e818ebc30af2377158c8a92ec66c06464c" + "hash": "96ee5b8253ee54bcea68f0b5256a973bd9d2d7818320ae4d6c02d68a29298e63" } diff --git a/backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json b/backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json new file mode 100644 index 0000000000..959438f582 --- /dev/null +++ b/backend/.sqlx/query-983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "983c21be4341a7ff9eb647041aa3642a89b16701e71d624c6adacb652e231a1a" +} diff --git a/backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json b/backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json new file mode 100644 index 0000000000..519fb4f367 --- /dev/null +++ b/backend/.sqlx/query-98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email FROM password\n WHERE role_source = 'instance_group' AND (super_admin = true OR devops = true)\n AND email NOT IN (\n SELECT eig.email FROM email_to_igroup eig\n JOIN instance_group ig ON ig.name = eig.igroup\n WHERE ig.instance_role IS NOT NULL\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "98c512c011d176366f3c4a0633bf60634f06fa6a2b5c65f654f4da87e3724c25" +} diff --git a/backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json b/backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json new file mode 100644 index 0000000000..69cd713502 --- /dev/null +++ b/backend/.sqlx/query-98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin)\n VALUES ($1, $2, $3, $4, $5, $6, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "98d929e1c12a41933f5cfbd79175c57375fdb528a30de49adbe5305fc237b2c5" +} diff --git a/backend/.sqlx/query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json b/backend/.sqlx/query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json similarity index 58% rename from backend/.sqlx/query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json rename to backend/.sqlx/query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json index 8e10da3f7b..eb34d76f61 100644 --- a/backend/.sqlx/query-ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478.json +++ b/backend/.sqlx/query-9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name, instance_role", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "emails", "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -27,8 +32,9 @@ "nullable": [ false, true, - null + null, + true ] }, - "hash": "ef0f4447498a117e4495ed9335d803403ad0055efb6da4bdd467b4ac9bf4e478" + "hash": "9dbd6cbae01ae05f4b56a8e3ad563e26ec51302e8916e3b186123c024546ff4a" } diff --git a/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json b/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json new file mode 100644 index 0000000000..163dc2285b --- /dev/null +++ b/backend/.sqlx/query-9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9\n WHERE $9::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $9 AND deleted = true\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Bool", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "9f86d16016ddbed5ff2a87c113a675a2a05eaf30237e21359c52f31bb1bddc73" +} diff --git a/backend/.sqlx/query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json b/backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json similarity index 64% rename from backend/.sqlx/query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json rename to backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json index 16ccdd10e9..660c855622 100644 --- a/backend/.sqlx/query-ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee.json +++ b/backend/.sqlx/query-a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_prefix,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ", + "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 5, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" }, { @@ -97,5 +97,5 @@ false ] }, - "hash": "ecab1af12a7afa685c056b9d0e526275203fc8ecddf83ca6d05c9fb77e46e7ee" + "hash": "a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e" } diff --git a/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json b/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json deleted file mode 100644 index af35d619fa..0000000000 --- a/backend/.sqlx/query-a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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" -} diff --git a/backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json b/backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json new file mode 100644 index 0000000000..33ed0b518b --- /dev/null +++ b/backend/.sqlx/query-ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'github_base_url' as github_base_url\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "installation_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "github_base_url", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "ad5fc9212a123a8328397496ef3b5eea1780226698e419ac59ba55012296913d" +} diff --git a/backend/.sqlx/query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json b/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json similarity index 55% rename from backend/.sqlx/query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json rename to backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json index ac882aec64..4bb689c519 100644 --- a/backend/.sqlx/query-0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8.json +++ b/backend/.sqlx/query-ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", + "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", "describe": { "columns": [ { @@ -12,6 +12,11 @@ "ordinal": 1, "name": "account_id", "type_info": "Text" + }, + { + "ordinal": 2, + "name": "github_base_url", + "type_info": "Text" } ], "parameters": { @@ -20,9 +25,10 @@ ] }, "nullable": [ + null, null, null ] }, - "hash": "0ee14619dd81df460b2b8cc6df2b89646279f77469c35deffca8e17a11d7f6c8" + "hash": "ae7adc583cdd3f876164ed60569ed531b05eaa17fccc599306eb1a96a65ee761" } diff --git a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json index 54e94cfb8f..99269c9851 100644 --- a/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json +++ b/backend/.sqlx/query-b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76.json @@ -18,8 +18,8 @@ "Left": [] }, "nullable": [ - false, - true + true, + false ] }, "hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76" diff --git a/backend/.sqlx/query-b44afcf1b9c047ac525638f0952c2cb01d65b1b46693331ac157dfca0dab6824.json b/backend/.sqlx/query-b44afcf1b9c047ac525638f0952c2cb01d65b1b46693331ac157dfca0dab6824.json new file mode 100644 index 0000000000..8dd4e1ea28 --- /dev/null +++ b/backend/.sqlx/query-b44afcf1b9c047ac525638f0952c2cb01d65b1b46693331ac157dfca0dab6824.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT content AS \"content!: String\",\n lock AS \"lock: String\", language AS \"language: Option\", envs AS \"envs: Vec\", schema AS \"schema: String\", schema_validation AS \"schema_validation: bool\", codebase LIKE '%.tar' as use_tar, codebase LIKE '%.esm%' as is_esm, modules AS \"modules: serde_json::Value\" FROM script WHERE hash = $1 LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "content!: String", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "lock: String", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "language: Option", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby" + ] + } + } + } + }, + { + "ordinal": 3, + "name": "envs: Vec", + "type_info": "VarcharArray" + }, + { + "ordinal": 4, + "name": "schema: String", + "type_info": "Json" + }, + { + "ordinal": 5, + "name": "schema_validation: bool", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "use_tar", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "is_esm", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "modules: serde_json::Value", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + true, + false, + true, + true, + false, + null, + null, + true + ] + }, + "hash": "b44afcf1b9c047ac525638f0952c2cb01d65b1b46693331ac157dfca0dab6824" +} diff --git a/backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json b/backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json deleted file mode 100644 index 0efca7e867..0000000000 --- a/backend/.sqlx/query-b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET git_app_installations = (\n SELECT jsonb_agg(\n CASE\n WHEN (elem->>'installation_id')::bigint = $2 THEN $1::jsonb\n ELSE elem\n END\n )\n FROM jsonb_array_elements(git_app_installations) AS elem\n )\n WHERE workspace_id = $3\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Jsonb", - "Int8", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b4d48c820bf41619bffa8f62367e98369e1d93514e1618723a34bf96080d4ebc" -} diff --git a/backend/.sqlx/query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json b/backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json similarity index 82% rename from backend/.sqlx/query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json rename to backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json index e6ef13f492..a90b2b8398 100644 --- a/backend/.sqlx/query-b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d.json +++ b/backend/.sqlx/query-bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_prefix,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", + "query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 5, - "name": "webhook_token_prefix", + "name": "webhook_token_hash", "type_info": "Varchar" }, { @@ -94,5 +94,5 @@ false ] }, - "hash": "b615d73ddb43e9d655b86a0cf98f892bf40e629ee11ee4845199481755f2789d" + "hash": "bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb" } diff --git a/backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json b/backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json deleted file mode 100644 index 182fa4e788..0000000000 --- a/backend/.sqlx/query-be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "installation_id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "be00ac55e8668a0ed3befda7d8595c7cda0cba0b119d4fdb8a0dea1b28a1d560" -} diff --git a/backend/.sqlx/query-bf2aeb9a1e649106d2a084c1d628690a44573c1869a206474811215714ba97c2.json b/backend/.sqlx/query-bf2aeb9a1e649106d2a084c1d628690a44573c1869a206474811215714ba97c2.json deleted file mode 100644 index 91b4f2786a..0000000000 --- a/backend/.sqlx/query-bf2aeb9a1e649106d2a084c1d628690a44573c1869a206474811215714ba97c2.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM resource WHERE path = $1 AND workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "bf2aeb9a1e649106d2a084c1d628690a44573c1869a206474811215714ba97c2" -} diff --git a/backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json similarity index 86% rename from backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json rename to backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json index 63478d0e66..ee946012b2 100644 --- a/backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json +++ b/backend/.sqlx/query-c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE mcp_oauth_refresh_token\n SET used_at = now()\n WHERE refresh_token = $1\n AND client_id = $2\n AND used_at IS NULL\n AND NOT revoked\n AND expires_at > now()\n RETURNING id, refresh_token, access_token, client_id, user_email, workspace_id,\n scopes, token_family, created_at, expires_at, used_at, revoked", + "query": "UPDATE mcp_oauth_refresh_token\n SET used_at = now()\n WHERE refresh_token = $1\n AND client_id = $2\n AND used_at IS NULL\n AND NOT revoked\n AND expires_at > now()\n RETURNING id, refresh_token, access_token_hash, client_id, user_email, workspace_id,\n scopes, token_family, created_at, expires_at, used_at, revoked", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "access_token", + "name": "access_token_hash", "type_info": "Varchar" }, { @@ -85,5 +85,5 @@ false ] }, - "hash": "5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd" + "hash": "c2efefded4eaea858c41c32ef20e2c11ed88327cf033e1abfd7c0458b71f53da" } diff --git a/backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json b/backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json new file mode 100644 index 0000000000..7415b311f1 --- /dev/null +++ b/backend/.sqlx/query-c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE kafka_trigger SET reset_offset = true, server_id = NULL WHERE workspace_id = $1 AND path = $2 RETURNING true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c2f38c9e09aac73d10e8f327715927c07832badb2c9145d5996b829163bdf7d9" +} diff --git a/backend/.sqlx/query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json b/backend/.sqlx/query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json similarity index 66% rename from backend/.sqlx/query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json rename to backend/.sqlx/query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json index e68f25d6ab..86175b0dc4 100644 --- a/backend/.sqlx/query-55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b.json +++ b/backend/.sqlx/query-ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT job FROM token WHERE token = $1", + "query": "SELECT job FROM token WHERE token_hash = $1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "55cf43cb9219b43f8e9f94b23b62846cd0b1ef5f64d20b0d975d0058730f427b" + "hash": "ca8997323e27f99bfc5ad8c4a54224d43eaab99b9f1b7d55eff25b0225bb1504" } diff --git a/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json b/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json new file mode 100644 index 0000000000..e778c17bf6 --- /dev/null +++ b/backend/.sqlx/query-d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90" +} diff --git a/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json b/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json deleted file mode 100644 index 73ffec7e48..0000000000 --- a/backend/.sqlx/query-d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token (token, email, label, expiration, scopes, workspace_id)\n SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar\n WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Text", - "TextArray", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "d32448f6b329cf98dad42b218a630c0cf40a99edb4ae9fe3e9be485ab1077b3a" -} diff --git a/backend/.sqlx/query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json b/backend/.sqlx/query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json similarity index 65% rename from backend/.sqlx/query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json rename to backend/.sqlx/query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json index 34ff650daf..eadb48cbb5 100644 --- a/backend/.sqlx/query-97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c.json +++ b/backend/.sqlx/query-db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token WHERE token = $1 RETURNING email", + "query": "SELECT email FROM token WHERE token = $1", "describe": { "columns": [ { @@ -18,5 +18,5 @@ true ] }, - "hash": "97e364c703bdcdfb5345810659cbe0477a28b8199ef0b297f9a22c88a43b6b5c" + "hash": "db2d2f67c785f790a1a2bd7181a69945b6baeb1f1e9e36c9949b9d5fe1f78431" } diff --git a/backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json b/backend/.sqlx/query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json similarity index 57% rename from backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json rename to backend/.sqlx/query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json index 32e0372293..37055880ae 100644 --- a/backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json +++ b/backend/.sqlx/query-dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO mcp_oauth_refresh_token\n (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", + "query": "INSERT INTO mcp_oauth_refresh_token\n (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", "describe": { "columns": [], "parameters": { @@ -17,5 +17,5 @@ }, "nullable": [] }, - "hash": "2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8" + "hash": "dd8c63ac04e33e2863ff3712fc6a5209e1ff2c235df1e39ddc3dd21f60f66ef4" } diff --git a/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json b/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json deleted file mode 100644 index 14757923d1..0000000000 --- a/backend/.sqlx/query-e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO token\n (token, email, label, expiration, super_admin, scopes, workspace_id)\n SELECT $1, $2, $3, $4, $5, $6, $7\n WHERE $7::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $7 AND deleted = true\n )", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Varchar", - "Varchar", - "Timestamptz", - "Bool", - "TextArray", - "Varchar" - ] - }, - "nullable": [] - }, - "hash": "e33be0991702ae3a295db7defc6d19d914307a95d72bb0fb447e5b367d52f6a0" -} diff --git a/backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json b/backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json new file mode 100644 index 0000000000..f4e8f0106c --- /dev/null +++ b/backend/.sqlx/query-e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "e4b5ea8c2a5644471c103463e79030140350134ed4f42478daba17655802f238" +} diff --git a/backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json b/backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json new file mode 100644 index 0000000000..7bf373db2d --- /dev/null +++ b/backend/.sqlx/query-e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT instance_role FROM instance_group WHERE name = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "instance_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e8717d0197b51a0837f85a2615d1d4a0dc97741055d83d4102395f1a05824d6b" +} diff --git a/backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json b/backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json new file mode 100644 index 0000000000..a73a1a8444 --- /dev/null +++ b/backend/.sqlx/query-e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ig.instance_role FROM email_to_igroup eig\n JOIN instance_group ig ON ig.name = eig.igroup\n WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "instance_role", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e959176da22a76c43c63c26a993120eb8e28c823f9e04fb54b7a545a353495c1" +} diff --git a/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json b/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json deleted file mode 100644 index c96961eac4..0000000000 --- a/backend/.sqlx/query-eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "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" -} diff --git a/backend/.sqlx/query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json b/backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json similarity index 72% rename from backend/.sqlx/query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json rename to backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json index 3f0749f7bf..657c660228 100644 --- a/backend/.sqlx/query-3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905.json +++ b/backend/.sqlx/query-ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", + "query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3", "describe": { "columns": [ { @@ -11,7 +11,7 @@ { "ordinal": 1, "name": "token_prefix", - "type_info": "Text" + "type_info": "Varchar" }, { "ordinal": 2, @@ -43,12 +43,12 @@ }, "nullable": [ true, - null, + false, true, false, false, true ] }, - "hash": "3b746f73abbaea3570b9c79af21d4d0f60232098d69b71c21fd3da985f7a5905" + "hash": "ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633" } diff --git a/backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json b/backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json new file mode 100644 index 0000000000..238977d563 --- /dev/null +++ b/backend/.sqlx/query-ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE kafka_trigger SET reset_offset = false WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ef15599f532fab2cbb487542ffec047cf3b7ce22ce868db1b1a63e6c10d0d12b" +} diff --git a/backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json b/backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json new file mode 100644 index 0000000000..5797d761e9 --- /dev/null +++ b/backend/.sqlx/query-f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO kafka_pending_commits (workspace_id, kafka_trigger_path, topic, partition, \"offset\")\n VALUES ($1, $2, $3, $4, $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f67e5c96eb9cb35953d4c3e83e0fcbb5b647737e0366529a2f418218b1a74679" +} diff --git a/backend/.sqlx/query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json b/backend/.sqlx/query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json similarity index 74% rename from backend/.sqlx/query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json rename to backend/.sqlx/query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json index ed63942f01..eee7213e44 100644 --- a/backend/.sqlx/query-9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f.json +++ b/backend/.sqlx/query-fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + "query": "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", "describe": { "columns": [ { @@ -32,6 +32,11 @@ "ordinal": 5, "name": "external_id", "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "instance_role", + "type_info": "Varchar" } ], "parameters": { @@ -43,8 +48,9 @@ null, true, true, + true, true ] }, - "hash": "9cd6e964ba933de247ba7ddce0acef0b70b784c2410f3a5f51288aaea6904b8f" + "hash": "fd61a081912cd70edbee757891319ee04f062c989bd1c7abfc0098b408a216cc" } diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 7f10019e62..1343e441ad 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -169,9 +169,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -184,15 +184,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -1860,9 +1860,9 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d13a61f2963b88eef9c1be03df65d42f6996dfeac1054870d950fcf66686f83" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" dependencies = [ "bon-macros", "rustversion", @@ -1870,9 +1870,9 @@ dependencies = [ [[package]] name = "bon-macros" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d314cc62af2b6b0c65780555abb4d02a03dd3b799cd42419044f0c38d99738c0" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ "darling 0.23.0", "ident_case", @@ -2208,9 +2208,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -2323,9 +2323,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -2333,9 +2333,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -2345,9 +2345,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2357,9 +2357,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "clipboard-win" @@ -2418,9 +2418,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" @@ -8208,9 +8208,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" +checksum = "373f5eceeeab7925e0c1098212f2fbc4d416adec9d35051a6ab251e824c1854a" dependencies = [ "twox-hash 2.1.2", ] @@ -9247,9 +9247,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -9257,9 +9257,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -9383,9 +9383,9 @@ checksum = "80adb31078122c880307e9cdfd4e3361e6545c319f9b9dcafcb03acd3b51a575" [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -9460,9 +9460,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" dependencies = [ "bitflags 2.9.4", "cfg-if", @@ -9507,9 +9507,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.112" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" dependencies = [ "cc", "libc", @@ -10641,9 +10641,9 @@ dependencies = [ [[package]] name = "quick_cache" -version = "0.6.18" +version = "0.6.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3" +checksum = "530e84778a55de0f52645a51d4e3b9554978acd6a1e7cd50b6a6784692b3029e" dependencies = [ "ahash 0.8.12", "equivalent", @@ -13854,9 +13854,9 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -14087,9 +14087,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -14697,9 +14697,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -15741,7 +15741,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-nats", @@ -15808,7 +15808,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15821,7 +15821,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "argon2", @@ -15962,7 +15962,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15985,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -15998,7 +15998,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16024,7 +16024,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.654.0" +version = "1.658.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -16034,7 +16034,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16051,7 +16051,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "base64 0.22.1", @@ -16074,7 +16074,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16097,7 +16097,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16113,7 +16113,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16133,7 +16133,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16153,7 +16153,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16167,7 +16167,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-nats", @@ -16187,6 +16187,7 @@ dependencies = [ "windmill-api-auth", "windmill-api-client", "windmill-common", + "windmill-git-sync", "windmill-native-triggers", "windmill-test-utils", "windmill-worker", @@ -16194,7 +16195,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16219,7 +16220,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "flate2", @@ -16237,7 +16238,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16258,7 +16259,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16278,7 +16279,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16308,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16335,7 +16336,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.654.0" +version = "1.658.0" dependencies = [ "lazy_static", "serde", @@ -16347,7 +16348,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.654.0" +version = "1.658.0" dependencies = [ "argon2", "axum 0.7.9", @@ -16370,7 +16371,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16384,7 +16385,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.654.0" +version = "1.658.0" dependencies = [ "axum 0.7.9", "chrono", @@ -16415,7 +16416,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.654.0" +version = "1.658.0" dependencies = [ "chrono", "lazy_static", @@ -16429,7 +16430,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -16448,7 +16449,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.654.0" +version = "1.658.0" dependencies = [ "aes-gcm", "anyhow", @@ -16547,7 +16548,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.654.0" +version = "1.658.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -16566,7 +16567,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.654.0" +version = "1.658.0" dependencies = [ "regex", "serde", @@ -16581,7 +16582,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16605,7 +16606,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "futures", @@ -16622,7 +16623,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.654.0" +version = "1.658.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16638,7 +16639,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -16659,7 +16660,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -16690,7 +16691,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-oauth2", @@ -16714,7 +16715,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-stream", @@ -16748,7 +16749,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "futures", @@ -16766,7 +16767,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.654.0" +version = "1.658.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16775,7 +16776,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16787,7 +16788,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "serde_json", @@ -16799,7 +16800,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "gosyn", @@ -16811,7 +16812,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16823,7 +16824,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "serde_json", @@ -16835,7 +16836,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "nu-parser", @@ -16846,7 +16847,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16857,7 +16858,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16869,7 +16870,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.653.0" +version = "1.658.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16880,7 +16881,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-recursion", @@ -16904,7 +16905,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16918,7 +16919,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16935,7 +16936,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16949,7 +16950,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.653.0" +version = "1.658.0" dependencies = [ "anyhow", "serde", @@ -16961,7 +16962,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "lazy_static", @@ -16979,7 +16980,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.653.0" +version = "1.658.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16995,7 +16996,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "rustpython-ast", @@ -17011,7 +17012,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "serde", @@ -17022,7 +17023,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-recursion", @@ -17059,7 +17060,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "const_format", @@ -17097,7 +17098,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.654.0" +version = "1.658.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17108,7 +17109,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-recursion", @@ -17137,7 +17138,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "axum 0.7.9", @@ -17160,7 +17161,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17193,7 +17194,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17213,7 +17214,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17247,7 +17248,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17282,7 +17283,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17305,7 +17306,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17329,7 +17330,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-nats", @@ -17353,7 +17354,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17388,7 +17389,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17416,7 +17417,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-trait", @@ -17439,7 +17440,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "bitflags 2.9.4", @@ -17457,7 +17458,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.654.0" +version = "1.658.0" dependencies = [ "anyhow", "async-once-cell", @@ -17520,6 +17521,7 @@ dependencies = [ "sha2 0.10.9", "sqlx", "tar", + "tempfile", "tiberius", "tokio", "tokio-postgres 0.7.13", @@ -17563,7 +17565,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.654.0" +version = "1.658.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 1b0924c2fa..2cabf47d41 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.654.0" +version = "1.658.0" authors.workspace = true edition.workspace = true @@ -82,7 +82,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal"] [workspace.package] -version = "1.654.0" +version = "1.658.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -100,6 +100,8 @@ debug = false [profile.release] lto = "thin" +debug = "line-tables-only" +strip = "none" [features] default = [] diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 06277182aa..648a079815 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -2f52c015bc6c81391234fa87b27ee1d4cd3a48a3 \ No newline at end of file +278a3887f759f9d1146554baa0765518d5bc70f2 diff --git a/backend/migrations/20260307000000_add_script_modules.up.sql b/backend/migrations/20260307000000_add_script_modules.up.sql new file mode 100644 index 0000000000..c8bd0ea3bb --- /dev/null +++ b/backend/migrations/20260307000000_add_script_modules.up.sql @@ -0,0 +1 @@ +ALTER TABLE script ADD COLUMN IF NOT EXISTS modules JSONB; diff --git a/backend/migrations/20260312000000_kafka_auto_commit.down.sql b/backend/migrations/20260312000000_kafka_auto_commit.down.sql new file mode 100644 index 0000000000..58d1ad2659 --- /dev/null +++ b/backend/migrations/20260312000000_kafka_auto_commit.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS kafka_pending_commits; +ALTER TABLE kafka_trigger DROP COLUMN auto_commit; diff --git a/backend/migrations/20260312000000_kafka_auto_commit.up.sql b/backend/migrations/20260312000000_kafka_auto_commit.up.sql new file mode 100644 index 0000000000..643e2ce1f3 --- /dev/null +++ b/backend/migrations/20260312000000_kafka_auto_commit.up.sql @@ -0,0 +1,14 @@ +ALTER TABLE kafka_trigger ADD COLUMN auto_commit BOOLEAN NOT NULL DEFAULT TRUE; + +CREATE TABLE kafka_pending_commits ( + id BIGSERIAL PRIMARY KEY, + workspace_id VARCHAR(50) NOT NULL, + kafka_trigger_path VARCHAR(255) NOT NULL, + topic VARCHAR(255) NOT NULL, + partition INTEGER NOT NULL, + "offset" BIGINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + FOREIGN KEY (workspace_id, kafka_trigger_path) REFERENCES kafka_trigger(workspace_id, path) ON DELETE CASCADE +); + +CREATE INDEX idx_kafka_pending_commits_trigger ON kafka_pending_commits (workspace_id, kafka_trigger_path); diff --git a/backend/migrations/20260313000000_script_auto_kind.up.sql b/backend/migrations/20260313000000_script_auto_kind.up.sql new file mode 100644 index 0000000000..5f962f00d0 --- /dev/null +++ b/backend/migrations/20260313000000_script_auto_kind.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE script ADD COLUMN auto_kind VARCHAR(20); +UPDATE script SET auto_kind = 'lib' WHERE no_main_func = true; +ALTER TABLE script DROP COLUMN no_main_func; diff --git a/backend/migrations/20260316000000_token_hash.down.sql b/backend/migrations/20260316000000_token_hash.down.sql new file mode 100644 index 0000000000..02725da65d --- /dev/null +++ b/backend/migrations/20260316000000_token_hash.down.sql @@ -0,0 +1,8 @@ +-- Reverse of step 1: drop indexes and columns + +DROP INDEX IF EXISTS idx_token_plaintext; +DROP INDEX IF EXISTS idx_token_prefix; +DROP INDEX IF EXISTS token_hash_unique; + +ALTER TABLE token DROP COLUMN token_hash; +ALTER TABLE token DROP COLUMN token_prefix; diff --git a/backend/migrations/20260316000000_token_hash.up.sql b/backend/migrations/20260316000000_token_hash.up.sql new file mode 100644 index 0000000000..c0c3bb7375 --- /dev/null +++ b/backend/migrations/20260316000000_token_hash.up.sql @@ -0,0 +1,31 @@ +-- Step 1: Add columns, backfill, build indexes. +-- This migration does the heavy work but avoids ACCESS EXCLUSIVE during index build +-- by creating the unique index first, then using it for the PK swap in the next migration. + +-- Add new columns (instant metadata change) +ALTER TABLE token ADD COLUMN token_hash VARCHAR(64); +ALTER TABLE token ADD COLUMN token_prefix VARCHAR(10); + +-- Backfill existing tokens using built-in sha256() (no extension needed). +-- Takes ROW EXCLUSIVE lock — concurrent reads and non-token writes proceed normally. +UPDATE token +SET token_hash = encode(sha256(token::bytea), 'hex'), + token_prefix = substring(token for 10) +WHERE token_hash IS NULL; + +-- Mark NOT NULL (instant on PG 12+ when all rows already satisfy the constraint) +ALTER TABLE token ALTER COLUMN token_hash SET NOT NULL; +ALTER TABLE token ALTER COLUMN token_prefix SET NOT NULL; + +-- Build the unique index that the next migration will promote to PK. +-- Takes SHARE lock (reads OK, writes wait) but only for the duration of the build, +-- which is fast since token tables are typically small. +CREATE UNIQUE INDEX token_hash_unique ON token (token_hash); + +-- Index on prefix for deletion/listing +CREATE INDEX idx_token_prefix ON token (token_prefix); + +-- Keep old workers fast during rolling upgrades: they query WHERE token = $1 +-- after the PK swap drops the old primary key index on token. +-- Can be dropped once all workers are past MIN_VERSION_SUPPORTS_TOKEN_HASH. +CREATE INDEX idx_token_plaintext ON token (token) WHERE token IS NOT NULL; diff --git a/backend/migrations/20260316000001_token_hash_pk_swap.down.sql b/backend/migrations/20260316000001_token_hash_pk_swap.down.sql new file mode 100644 index 0000000000..ab938e3422 --- /dev/null +++ b/backend/migrations/20260316000001_token_hash_pk_swap.down.sql @@ -0,0 +1,26 @@ +-- Reverse step 2: restore old PK and trigger + +-- Restore the original trigger +CREATE OR REPLACE FUNCTION notify_token_invalidation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.label = 'session' AND OLD.email IS NOT NULL THEN + INSERT INTO notify_event (channel, payload) + VALUES ('notify_token_invalidation', OLD.token); + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +-- Delete tokens created after migration that have no plaintext (cannot be restored) +DELETE FROM token WHERE token IS NULL; + +-- Make token NOT NULL again +ALTER TABLE token ALTER COLUMN token SET NOT NULL; + +-- Swap PK back: drop token_hash PK, restore token PK +ALTER TABLE token DROP CONSTRAINT token_pkey; +ALTER TABLE token ADD PRIMARY KEY (token); + +-- Re-create the unique index on token_hash (was consumed by ADD CONSTRAINT ... USING INDEX) +CREATE UNIQUE INDEX token_hash_unique ON token (token_hash); diff --git a/backend/migrations/20260316000001_token_hash_pk_swap.up.sql b/backend/migrations/20260316000001_token_hash_pk_swap.up.sql new file mode 100644 index 0000000000..38e93fb3ba --- /dev/null +++ b/backend/migrations/20260316000001_token_hash_pk_swap.up.sql @@ -0,0 +1,22 @@ +-- Step 2: Swap PK and update trigger. +-- All operations here are instant metadata changes (no data/index rebuild). +-- The ACCESS EXCLUSIVE lock is held for only milliseconds. + +-- Swap primary key: drop old, promote existing unique index (instant) +ALTER TABLE token DROP CONSTRAINT token_pkey; +ALTER TABLE token ADD CONSTRAINT token_pkey PRIMARY KEY USING INDEX token_hash_unique; + +-- Make old token column nullable (no longer written for new tokens) +ALTER TABLE token ALTER COLUMN token DROP NOT NULL; + +-- Update the cache invalidation trigger to send prefix instead of plaintext +CREATE OR REPLACE FUNCTION notify_token_invalidation() +RETURNS TRIGGER AS $$ +BEGIN + IF OLD.label = 'session' AND OLD.email IS NOT NULL THEN + INSERT INTO notify_event (channel, payload) + VALUES ('notify_token_invalidation', OLD.token_prefix); + END IF; + RETURN OLD; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; diff --git a/backend/migrations/20260316000002_native_trigger_token_hash.down.sql b/backend/migrations/20260316000002_native_trigger_token_hash.down.sql new file mode 100644 index 0000000000..c45ef7fd01 --- /dev/null +++ b/backend/migrations/20260316000002_native_trigger_token_hash.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE native_trigger ADD COLUMN webhook_token_prefix VARCHAR(10) NOT NULL DEFAULT ''; + +-- Backfill prefix from the token table +UPDATE native_trigger nt +SET webhook_token_prefix = t.token_prefix +FROM token t +WHERE t.token_hash = nt.webhook_token_hash; + +ALTER TABLE native_trigger DROP COLUMN IF EXISTS webhook_token_hash; diff --git a/backend/migrations/20260316000002_native_trigger_token_hash.up.sql b/backend/migrations/20260316000002_native_trigger_token_hash.up.sql new file mode 100644 index 0000000000..fd643ebaa1 --- /dev/null +++ b/backend/migrations/20260316000002_native_trigger_token_hash.up.sql @@ -0,0 +1,22 @@ +-- Add webhook_token_hash to native_trigger for safe token lookups/deletes, +-- and drop webhook_token_prefix which is no longer needed. + +ALTER TABLE native_trigger ADD COLUMN webhook_token_hash VARCHAR(64); + +-- Backfill from the token table +UPDATE native_trigger nt +SET webhook_token_hash = t.token_hash +FROM token t +WHERE t.token_prefix = nt.webhook_token_prefix; + +-- Mark orphaned triggers (whose tokens no longer exist) with an error +-- instead of deleting them, so they remain visible in the UI. +-- Use a placeholder hash (sha256 of empty string) that won't match any real token. +UPDATE native_trigger +SET webhook_token_hash = encode(sha256(''::bytea), 'hex'), + error = 'Webhook token not found during migration — re-create this trigger to fix' +WHERE webhook_token_hash IS NULL; + +ALTER TABLE native_trigger ALTER COLUMN webhook_token_hash SET NOT NULL; + +ALTER TABLE native_trigger DROP COLUMN webhook_token_prefix; diff --git a/backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql b/backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql new file mode 100644 index 0000000000..1b4895e88f --- /dev/null +++ b/backend/migrations/20260316000003_rename_token_expiry_notification_column.down.sql @@ -0,0 +1,5 @@ +-- Hashed values cannot be reversed; truncate to avoid silent join mismatches +-- with the old code that compared plaintext token_expiry_notification.token against token.token. +TRUNCATE token_expiry_notification; + +ALTER TABLE token_expiry_notification RENAME COLUMN token_hash TO token; diff --git a/backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql b/backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql new file mode 100644 index 0000000000..fd4186e725 --- /dev/null +++ b/backend/migrations/20260316000003_rename_token_expiry_notification_column.up.sql @@ -0,0 +1,5 @@ +-- Convert existing plaintext token values to SHA-256 hashes, then rename the column. +UPDATE token_expiry_notification +SET token = encode(sha256(token::bytea), 'hex'); + +ALTER TABLE token_expiry_notification RENAME COLUMN token TO token_hash; diff --git a/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql new file mode 100644 index 0000000000..9e3c94eea1 --- /dev/null +++ b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.down.sql @@ -0,0 +1,4 @@ +-- Hashing is irreversible so the values will be stale hashes, but the old +-- code's DELETE FROM token WHERE token = $1 is non-fatal — refresh tokens +-- keep working, only old access token cleanup silently fails. +ALTER TABLE mcp_oauth_refresh_token RENAME COLUMN access_token_hash TO access_token; diff --git a/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql new file mode 100644 index 0000000000..e845eb6527 --- /dev/null +++ b/backend/migrations/20260316000004_mcp_oauth_rename_access_token_hash.up.sql @@ -0,0 +1,6 @@ +-- Hash existing plaintext access_token values in mcp_oauth_refresh_token. +-- Only hash rows that are not already 64-char hex strings (safety guard for re-runs). +UPDATE mcp_oauth_refresh_token +SET access_token = encode(sha256(access_token::bytea), 'hex'); + +ALTER TABLE mcp_oauth_refresh_token RENAME COLUMN access_token TO access_token_hash; diff --git a/backend/migrations/20260316000005_instance_group_role.down.sql b/backend/migrations/20260316000005_instance_group_role.down.sql new file mode 100644 index 0000000000..80af3b5006 --- /dev/null +++ b/backend/migrations/20260316000005_instance_group_role.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE instance_group DROP COLUMN instance_role; +ALTER TABLE password DROP COLUMN role_source; diff --git a/backend/migrations/20260316000005_instance_group_role.up.sql b/backend/migrations/20260316000005_instance_group_role.up.sql new file mode 100644 index 0000000000..ef1c8ee3db --- /dev/null +++ b/backend/migrations/20260316000005_instance_group_role.up.sql @@ -0,0 +1,9 @@ +-- instance_group: add instance-level role (NULL = none, 'devops', 'superadmin') +ALTER TABLE instance_group ADD COLUMN instance_role VARCHAR(20) DEFAULT NULL; +ALTER TABLE instance_group ADD CONSTRAINT check_instance_role + CHECK (instance_role IN ('devops', 'superadmin')); + +-- password: track whether elevated role was set manually or by instance group +ALTER TABLE password ADD COLUMN role_source VARCHAR(20) NOT NULL DEFAULT 'manual'; +ALTER TABLE password ADD CONSTRAINT check_role_source + CHECK (role_source IN ('manual', 'instance_group')); diff --git a/backend/parsers/windmill-parser-bash/src/lib.rs b/backend/parsers/windmill-parser-bash/src/lib.rs index 023e41a902..3629ee1e79 100644 --- a/backend/parsers/windmill-parser-bash/src/lib.rs +++ b/backend/parsers/windmill-parser-bash/src/lib.rs @@ -20,7 +20,7 @@ pub fn parse_bash_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }) } else { @@ -36,7 +36,7 @@ pub fn parse_powershell_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }) } else { @@ -721,7 +721,7 @@ non_required="${5:-}" oidx: None } ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -819,7 +819,7 @@ non_required="${5:-}" oidx: None } ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1391,7 +1391,7 @@ param( oidx: None } ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); diff --git a/backend/parsers/windmill-parser-csharp/src/lib.rs b/backend/parsers/windmill-parser-csharp/src/lib.rs index 53f6eb89dd..26c2607563 100644 --- a/backend/parsers/windmill-parser-csharp/src/lib.rs +++ b/backend/parsers/windmill-parser-csharp/src/lib.rs @@ -37,7 +37,11 @@ pub fn parse_csharp_sig_meta(code: &str) -> anyhow::Result { // Traverse the AST to find the Main method signature let main_sig = find_main_signature(root_node, code); - let no_main_func = Some(main_sig.is_none()); + let auto_kind = if main_sig.is_none() { + Some("lib".to_string()) + } else { + None + }; let mut is_async = false; let mut is_public = false; let mut returns_void = false; @@ -84,7 +88,7 @@ pub fn parse_csharp_sig_meta(code: &str) -> anyhow::Result { star_kwargs: false, args, has_preprocessor: None, - no_main_func, + auto_kind, }; Ok(CsharpMainSigMeta { is_async, returns_void, class_name, main_sig, is_public }) diff --git a/backend/parsers/windmill-parser-go/src/lib.rs b/backend/parsers/windmill-parser-go/src/lib.rs index 5df0d4e58e..19f1f52b38 100644 --- a/backend/parsers/windmill-parser-go/src/lib.rs +++ b/backend/parsers/windmill-parser-go/src/lib.rs @@ -41,7 +41,7 @@ pub fn parse_go_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }) } else { @@ -49,7 +49,7 @@ pub fn parse_go_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args: vec![], - no_main_func: Some(true), + auto_kind: Some("lib".to_string()), has_preprocessor: None, }) } @@ -243,7 +243,7 @@ func main(x int, y string, z bool, l []string, o struct { Name string `json:"nam oidx: None }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None } ); diff --git a/backend/parsers/windmill-parser-graphql/src/lib.rs b/backend/parsers/windmill-parser-graphql/src/lib.rs index 02d3e9a891..255bb3df34 100644 --- a/backend/parsers/windmill-parser-graphql/src/lib.rs +++ b/backend/parsers/windmill-parser-graphql/src/lib.rs @@ -19,7 +19,7 @@ pub fn parse_graphql_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }) } else { @@ -125,7 +125,7 @@ query($i: Int, $arr: [String]!, $wahoo: String = "wahoo") { oidx: None } ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); diff --git a/backend/parsers/windmill-parser-java/src/lib.rs b/backend/parsers/windmill-parser-java/src/lib.rs index 559462c902..6d3eeb6f18 100644 --- a/backend/parsers/windmill-parser-java/src/lib.rs +++ b/backend/parsers/windmill-parser-java/src/lib.rs @@ -34,7 +34,11 @@ pub fn parse_java_sig_meta(code: &str) -> anyhow::Result { // Traverse the AST to find the Main method signature let main_sig = find_main_signature(root_node, code); - let no_main_func = Some(main_sig.is_none()); + let auto_kind = if main_sig.is_none() { + Some("lib".to_string()) + } else { + None + }; let mut is_public = false; let mut returns_void = false; let mut class_name = None; @@ -76,7 +80,7 @@ pub fn parse_java_sig_meta(code: &str) -> anyhow::Result { star_kwargs: false, args, has_preprocessor: None, - no_main_func, + auto_kind, }; Ok(JavaMainSigMeta { returns_void, class_name, main_sig, is_public }) diff --git a/backend/parsers/windmill-parser-nu/src/lib.rs b/backend/parsers/windmill-parser-nu/src/lib.rs index 76f124efad..8bf0615cc0 100644 --- a/backend/parsers/windmill-parser-nu/src/lib.rs +++ b/backend/parsers/windmill-parser-nu/src/lib.rs @@ -36,7 +36,7 @@ pub fn parse_nu_signature(code: &str) -> anyhow::Result { }; let mut sig = MainArgSignature::default(); - sig.no_main_func = Some(false); + sig.auto_kind = None; let batches = args .lines() diff --git a/backend/parsers/windmill-parser-nu/tests/tests.rs b/backend/parsers/windmill-parser-nu/tests/tests.rs index 315041cb55..6b16864c26 100644 --- a/backend/parsers/windmill-parser-nu/tests/tests.rs +++ b/backend/parsers/windmill-parser-nu/tests/tests.rs @@ -54,7 +54,7 @@ mod test { oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }, sig @@ -81,7 +81,7 @@ mod test { has_default: true, oidx: None },], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }, sig @@ -118,7 +118,7 @@ mod test { oidx: None }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }, sig @@ -230,7 +230,7 @@ mod test { oidx: None }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }, sig @@ -277,7 +277,7 @@ mod test { oidx: None }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }, sig @@ -341,7 +341,7 @@ mod test { // has_default: false, // oidx: None // },], - // no_main_func: Some(false), + // auto_kind: None, // has_preprocessor: None, // }, // sig @@ -371,7 +371,7 @@ mod test { has_default: false, oidx: None },], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }, sig @@ -418,7 +418,7 @@ mod test { oidx: None }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }, sig @@ -446,7 +446,7 @@ mod test { has_default: false, oidx: None },], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }, sig @@ -478,7 +478,7 @@ mod test { // has_default: false, // oidx: None // },], - // no_main_func: Some(false), + // auto_kind: None, // has_preprocessor: None, // }, // sig @@ -540,7 +540,7 @@ mod test { oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }, sig @@ -633,7 +633,7 @@ mod test { // oidx: None // }, // ], - // no_main_func: Some(false), + // auto_kind: None, // has_preprocessor: None, // }, // sig diff --git a/backend/parsers/windmill-parser-php/src/lib.rs b/backend/parsers/windmill-parser-php/src/lib.rs index 14f30f940c..a7cb928bc3 100644 --- a/backend/parsers/windmill-parser-php/src/lib.rs +++ b/backend/parsers/windmill-parser-php/src/lib.rs @@ -99,7 +99,7 @@ pub fn parse_php_signature( star_args: false, star_kwargs: false, args, - no_main_func: Some(false), + auto_kind: None, has_preprocessor, }) } else { @@ -107,7 +107,7 @@ pub fn parse_php_signature( star_args: false, star_kwargs: false, args: vec![], - no_main_func: Some(true), + auto_kind: Some("lib".to_string()), has_preprocessor, }) } @@ -179,7 +179,7 @@ function main(string $input1 = \"hey\", bool $input2 = false, int $input3 = 3, f oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None } ); diff --git a/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql b/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql index 4b3df78534..30629d969d 100644 --- a/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql +++ b/backend/parsers/windmill-parser-py-imports/tests/fixtures/base.sql @@ -803,7 +803,9 @@ ALTER TABLE public.script OWNER TO postgres; -- CREATE TABLE public.token ( - token character varying(50) NOT NULL, + token_hash character varying(64) NOT NULL, + token_prefix character varying(10) NOT NULL, + token character varying(50), label character varying(50), expiration timestamp with time zone, workspace_id character varying(50), @@ -1209,7 +1211,7 @@ ALTER TABLE ONLY public.script -- ALTER TABLE ONLY public.token - ADD CONSTRAINT token_pkey PRIMARY KEY (token); + ADD CONSTRAINT token_pkey PRIMARY KEY (token_hash); -- @@ -2534,7 +2536,7 @@ INSERT INTO public.usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO public.workspace_key(workspace_id, kind, key) VALUES ('test-workspace', 'cloud', 'test-key'); -insert INTO public.token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO public.token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES ( 'test-workspace', diff --git a/backend/parsers/windmill-parser-py/src/lib.rs b/backend/parsers/windmill-parser-py/src/lib.rs index c6852bd351..87848e9c0e 100644 --- a/backend/parsers/windmill-parser-py/src/lib.rs +++ b/backend/parsers/windmill-parser-py/src/lib.rs @@ -16,7 +16,7 @@ use windmill_parser::{json_to_typ, Arg, MainArgSignature, ObjectType, Typ}; use rustpython_parser::{ ast::{ Constant, Expr, ExprAttribute, ExprConstant, ExprDict, ExprList, ExprName, Stmt, - StmtAssign, StmtClassDef, StmtFunctionDef, Suite, + StmtAssign, StmtAsyncFunctionDef, StmtClassDef, StmtFunctionDef, Suite, }, Parse, }; @@ -25,6 +25,50 @@ pub mod pydantic_parser; const FUNCTION_CALL: &str = ""; +/// Get the simple type name from an expression (e.g. `str`, `int`). +fn simple_type_name(e: &Expr) -> Option<&str> { + match e { + Expr::Name(ExprName { id, .. }) => Some(id.as_ref()), + _ => None, + } +} + +/// If `e` is `list[T]` or `List[T]`, return the inner expression `T`. +fn list_elem_expr(e: &Expr) -> Option<&Expr> { + match e { + Expr::Subscript(x) => match x.value.as_ref() { + Expr::Name(ExprName { id, .. }) if id == "list" || id == "List" => { + Some(x.slice.as_ref()) + } + _ => None, + }, + _ => None, + } +} + +/// Detect `T | list[T]` or `list[T] | T` union patterns. +/// Returns the original type string (e.g. "str | list[str]") for use as `otyp`. +fn detect_py_union_array_otyp(e: &Expr) -> Option { + let Expr::BinOp(x) = e else { return None }; + // T | list[T] + if let (Some(scalar), Some(elem)) = (simple_type_name(&x.left), list_elem_expr(&x.right)) { + if let Some(elem_name) = simple_type_name(elem) { + if scalar == elem_name { + return Some(format!("{} | list[{}]", scalar, elem_name)); + } + } + } + // list[T] | T + if let (Some(elem), Some(scalar)) = (list_elem_expr(&x.left), simple_type_name(&x.right)) { + if let Some(elem_name) = simple_type_name(elem) { + if scalar == elem_name { + return Some(format!("list[{}] | {}", elem_name, scalar)); + } + } + } + None +} + /// Cheap string-based check to see if code might contain Pydantic models or dataclasses. /// Returns true if we should do full AST parsing for type detection, false otherwise. /// This avoids expensive parsing for the common case where scripts don't use these features. @@ -39,11 +83,17 @@ fn should_parse_for_models(code: &str) -> bool { fn filter_non_main(code: &str, main_name: &str) -> String { let def_main = format!("def {}(", main_name); + let async_def_main = format!("async def {}(", main_name); let mut filtered_code = String::new(); let mut code_iter = code.split("\n"); let mut remaining: String = String::new(); while let Some(line) = code_iter.next() { - if line.starts_with(&def_main) { + if line.starts_with(&async_def_main) { + filtered_code += &async_def_main; + remaining += line.strip_prefix(&async_def_main).unwrap(); + remaining += &code_iter.join("\n"); + break; + } else if line.starts_with(&def_main) { filtered_code += &def_main; remaining += line.strip_prefix(&def_main).unwrap(); remaining += &code_iter.join("\n"); @@ -266,6 +316,11 @@ pub fn parse_python_signature( Stmt::FunctionDef(StmtFunctionDef { name, args, .. }) if name == &main_name => { Some(args.as_ref().clone()) } + Stmt::AsyncFunctionDef(StmtAsyncFunctionDef { name, args, .. }) + if name == &main_name => + { + Some(args.as_ref().clone()) + } _ => None, }); @@ -284,6 +339,11 @@ pub fn parse_python_signature( Stmt::FunctionDef(StmtFunctionDef { name, args, .. }) if &name == &main_name => { Some(*args) } + Stmt::AsyncFunctionDef(StmtAsyncFunctionDef { name, args, .. }) + if &name == &main_name => + { + Some(*args) + } _ => None, }); @@ -300,7 +360,11 @@ pub fn parse_python_signature( star_args: false, star_kwargs: false, args: vec![], - no_main_func: Some(!is_wac_v2), + auto_kind: if is_wac_v2 { + Some("wac".to_string()) + } else { + Some("lib".to_string()) + }, has_preprocessor: Some(has_preprocessor), }); } @@ -390,8 +454,19 @@ pub fn parse_python_signature( _ => {} } + // Detect T | list[T] union types and set otyp for + // debounce accumulation support. Falls back to docstring + // description if no union array pattern is found. + let union_otyp = params.args[i] + .as_arg() + .annotation + .as_ref() + .and_then(|ann| detect_py_union_array_otyp(ann.as_ref())); + Arg { - otyp: metadata.descriptions.get(&arg_name).map(|d| d.to_string()), + otyp: union_otyp.or_else(|| { + metadata.descriptions.get(&arg_name).map(|d| d.to_string()) + }), name: arg_name, typ, has_default: has_default || default.is_some(), @@ -400,7 +475,7 @@ pub fn parse_python_signature( } }) .collect(), - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(has_preprocessor), }) } else { @@ -408,7 +483,11 @@ pub fn parse_python_signature( star_args: false, star_kwargs: false, args: vec![], - no_main_func: Some(params.is_none()), + auto_kind: if params.is_none() { + Some("lib".to_string()) + } else { + None + }, has_preprocessor: Some(has_preprocessor), }) } @@ -441,6 +520,9 @@ fn parse_expr( Expr::Constant(ExprConstant { value: Constant::None, .. }) ) { (parse_expr(&x.left, enums, module).0, true) + } else if detect_py_union_array_otyp(e.as_ref()).is_some() { + // T | list[T] — parsed type is Unknown; otyp is set separately + (Typ::Unknown, false) } else { (Typ::Unknown, false) } @@ -672,7 +754,7 @@ def main(test1: str, name: datetime.datetime = datetime.now(), byte: bytes = byt oidx: None }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -737,7 +819,7 @@ def main(test1: str, oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -797,7 +879,7 @@ def main(test1: str, oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -841,7 +923,7 @@ def main(test1: Literal["foo", "bar"], test2: List[Literal["foo", "bar"]]): retu oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -872,7 +954,7 @@ def main(test1: DynSelect_foo): return has_default: false, oidx: None }], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -896,7 +978,7 @@ def hello(): return star_args: false, star_kwargs: false, args: vec![], - no_main_func: Some(true), + auto_kind: Some("lib".to_string()), has_preprocessor: Some(false) } ); @@ -924,7 +1006,7 @@ def main(): return star_args: false, star_kwargs: false, args: vec![], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(true) } ); @@ -989,7 +1071,7 @@ def main(a: list, e: List[int], b: list = [1,2,3,4], c = [1,2,3,4], d = ["a", "b oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -1038,7 +1120,7 @@ def main(a: str, b: Optional[str], c: str | None): return oidx: None }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -1046,6 +1128,33 @@ def main(a: str, b: Optional[str], c: str | None): return Ok(()) } + #[test] + fn test_parse_python_union_array_type() -> anyhow::Result<()> { + let code = r#" +def main(items: str | list[str], numbers: list[int] | int, plain: str): + pass +"#; + let result = parse_python_signature(code, None, false)?; + assert_eq!(result.args.len(), 3); + + // str | list[str] → otyp set, typ Unknown + assert_eq!(result.args[0].name, "items"); + assert_eq!(result.args[0].otyp, Some("str | list[str]".to_string())); + assert_eq!(result.args[0].typ, Typ::Unknown); + + // list[int] | int → otyp set, typ Unknown + assert_eq!(result.args[1].name, "numbers"); + assert_eq!(result.args[1].otyp, Some("list[int] | int".to_string())); + assert_eq!(result.args[1].typ, Typ::Unknown); + + // plain str → no otyp + assert_eq!(result.args[2].name, "plain"); + assert_eq!(result.args[2].otyp, None); + assert_eq!(result.args[2].typ, Typ::Str(None)); + + Ok(()) + } + #[test] fn test_parse_python_sig_enum() -> anyhow::Result<()> { let code = r#" diff --git a/backend/parsers/windmill-parser-ruby/src/lib.rs b/backend/parsers/windmill-parser-ruby/src/lib.rs index c736cef86a..805d2f9933 100644 --- a/backend/parsers/windmill-parser-ruby/src/lib.rs +++ b/backend/parsers/windmill-parser-ruby/src/lib.rs @@ -29,14 +29,18 @@ pub fn parse_ruby_sig_meta(code: &str) -> anyhow::Result { root_node.clone().to_string(); // Traverse the AST to find the Main method signature let args = find_main_signature(root_node, code)?; - let no_main_func = Some(args.is_none()); + let auto_kind = if args.is_none() { + Some("lib".to_string()) + } else { + None + }; let main_sig = MainArgSignature { star_args: false, star_kwargs: false, args: args.unwrap_or_default(), has_preprocessor: None, - no_main_func, + auto_kind, }; Ok(main_sig) @@ -198,7 +202,7 @@ def private_fn end assert_eq!( sig, - MainArgSignature { no_main_func: Some(true), ..Default::default() } + MainArgSignature { auto_kind: Some("lib".to_string()), ..Default::default() } ); } #[test] @@ -211,7 +215,7 @@ end assert_eq!( sig, - MainArgSignature { no_main_func: Some(false), ..Default::default() } + MainArgSignature { auto_kind: None, ..Default::default() } ); } #[test] @@ -235,7 +239,7 @@ end Arg { name: "b".into(), ..Default::default() }, Arg { name: "c".into(), ..Default::default() } ], - no_main_func: Some(false), + auto_kind: None, ..Default::default() } ); @@ -266,7 +270,7 @@ end ..Default::default() }, ], - no_main_func: Some(false), + auto_kind: None, ..Default::default() } ); @@ -296,7 +300,7 @@ end default: Some(json!({"1": 4, "2": [ 1, 2, 3 ]})), ..Default::default() },], - no_main_func: Some(false), + auto_kind: None, ..Default::default() } ); @@ -355,7 +359,7 @@ end ..Default::default() }, ], - no_main_func: Some(false), + auto_kind: None, ..Default::default() } ); diff --git a/backend/parsers/windmill-parser-ruby/src/wasm_libc.rs b/backend/parsers/windmill-parser-ruby/src/wasm_libc.rs index ddb6705023..924748a073 100644 --- a/backend/parsers/windmill-parser-ruby/src/wasm_libc.rs +++ b/backend/parsers/windmill-parser-ruby/src/wasm_libc.rs @@ -121,11 +121,7 @@ pub unsafe extern "C" fn strncmp(ptr1: *const c_void, ptr2: *const c_void, n: us pub type size_t = usize; use std::slice; #[no_mangle] -pub unsafe extern "C" fn memchr( - haystack: *const c_void, - needle: c_int, - len: usize, -) -> *mut c_void { +pub unsafe extern "C" fn memchr(haystack: *const c_void, needle: c_int, len: usize) -> *mut c_void { if haystack.is_null() || len == 0 { return ptr::null_mut(); // Return null if the input pointer is null or length is zero } @@ -165,7 +161,7 @@ pub unsafe extern "C" fn strchr(mut s: *const c_char, c: c_int) -> *mut c_char { std::ptr::null_mut() // Return null if the character was not found } -// End of AI implemetation +// End of AI implemetation /* -------------------------------- wctype.h -------------------------------- */ #[no_mangle] @@ -202,7 +198,8 @@ pub extern "C" fn iswupper(wc: wint_t) -> c_int { #[no_mangle] pub extern "C" fn iswalpha(wc: wint_t) -> c_int { // Check if the character is an alphabetic character ('A' to 'Z' or 'a' to 'z') - if (wc >= 'A' as wint_t && wc <= 'Z' as wint_t) || (wc >= 'a' as wint_t && wc <= 'z' as wint_t) { + if (wc >= 'A' as wint_t && wc <= 'Z' as wint_t) || (wc >= 'a' as wint_t && wc <= 'z' as wint_t) + { return 1; // Return true (1) } 0 // Return false (0) @@ -216,8 +213,7 @@ pub extern "C" fn iswlower(wc: wint_t) -> c_int { } 0 // Return false (0) } -// End of AI implemetation - +// End of AI implemetation /* --------------------------------- time.h --------------------------------- */ diff --git a/backend/parsers/windmill-parser-rust/src/lib.rs b/backend/parsers/windmill-parser-rust/src/lib.rs index 3999fb044f..0d72ad4893 100644 --- a/backend/parsers/windmill-parser-rust/src/lib.rs +++ b/backend/parsers/windmill-parser-rust/src/lib.rs @@ -28,7 +28,7 @@ pub fn parse_rust_signature(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: Some(false), + auto_kind: None, has_preprocessor: None, }) } else { @@ -36,7 +36,7 @@ pub fn parse_rust_signature(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args: vec![], - no_main_func: Some(true), + auto_kind: Some("lib".to_string()), has_preprocessor: None, }) } diff --git a/backend/parsers/windmill-parser-sql/src/lib.rs b/backend/parsers/windmill-parser-sql/src/lib.rs index 13fd1a3199..4938a8e194 100644 --- a/backend/parsers/windmill-parser-sql/src/lib.rs +++ b/backend/parsers/windmill-parser-sql/src/lib.rs @@ -28,7 +28,7 @@ pub fn parse_mysql_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }) } else { @@ -44,7 +44,7 @@ pub fn parse_oracledb_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }) } else { @@ -65,7 +65,7 @@ pub fn parse_pgsql_sig_with_typed_schema(code: &str) -> anyhow::Result<(MainArgS star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }, typed_schema, @@ -83,7 +83,7 @@ pub fn parse_bigquery_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }) } else { @@ -98,7 +98,7 @@ pub fn parse_duckdb_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }) } else { @@ -114,7 +114,7 @@ pub fn parse_snowflake_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }) } else { @@ -130,7 +130,7 @@ pub fn parse_mssql_sig(code: &str) -> anyhow::Result { star_args: false, star_kwargs: false, args, - no_main_func: None, + auto_kind: None, has_preprocessor: None, }) } else { @@ -944,7 +944,7 @@ SELECT * FROM table WHERE token=$1::TEXT AND image=$2::BIGINT oidx: Some(2), }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -993,7 +993,7 @@ SELECT $2::TEXT; oidx: Some(3), }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1120,7 +1120,7 @@ SELECT ?, ?; oidx: None, }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1168,7 +1168,7 @@ SELECT :param2; oidx: None, }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1208,7 +1208,7 @@ SELECT @token; oidx: None, }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1256,7 +1256,7 @@ SELECT ?; oidx: None, } ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1304,7 +1304,7 @@ SELECT @P2; oidx: None, }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1353,7 +1353,7 @@ SELECT * FROM table_name WHERE thing = :name4; oidx: None, }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1391,7 +1391,7 @@ SELECT * FROM users WHERE id = $1 AND email = $2::text; oidx: Some(2), }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1429,7 +1429,7 @@ SELECT * FROM users LIMIT $1 OFFSET $2; oidx: Some(2), }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1479,7 +1479,7 @@ WHERE id = $1 oidx: Some(3), }, ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1506,7 +1506,7 @@ SELECT * FROM users WHERE id = ANY($1); has_default: false, oidx: Some(1), },], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1535,7 +1535,7 @@ SELECT $1::integer; has_default: false, oidx: Some(1), },], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); @@ -1567,7 +1567,7 @@ SELECT x has_default: false, oidx: None, },], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index 54e9d465fb..53ecaf277c 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -240,6 +240,7 @@ pub fn parse_deno_signature( let mut has_preprocessor = false; let mut entrypoint_params = None; + let mut is_wac = false; let ast = parser .parse_module() @@ -277,6 +278,16 @@ pub fn parse_deno_signature( } } + // export default workflow(async (...) => { ... }) + if entrypoint_params.is_none() { + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDefaultExpr(export_default)) = &item { + if let Some(params) = extract_workflow_params(&export_default.expr) { + entrypoint_params = Some(params); + is_wac = true; + } + } + } + if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(ExportDecl { decl, .. })) | ModuleItem::Stmt(Stmt::Decl(decl)) = item { @@ -308,6 +319,20 @@ pub fn parse_deno_signature( entrypoint_params = Some(fn_decl.function.params.clone()); } } + Decl::Var(var_decl) if entrypoint_params.is_none() => { + for decl in &var_decl.decls { + if let Some(name) = &decl.name.as_ident() { + if name.sym.as_ref() == entrypoint_function { + if let Some(init) = &decl.init { + if let Some(params) = extract_workflow_params(init) { + entrypoint_params = Some(params); + is_wac = true; + } + } + } + } + } + } _ => {} } } @@ -315,11 +340,18 @@ pub fn parse_deno_signature( let mut c: u16 = 0; - 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 auto_kind = if is_wac { + Some("wac".to_string()) + } else if entrypoint_params.is_none() { + if code.contains("workflow(") && code.contains("task(") && code.contains("windmill-client") + { + Some("wac".to_string()) + } else { + Some("lib".to_string()) + } + } else { + None + }; let mut type_resolver = HashMap::new(); let r = MainArgSignature { star_args: false, @@ -346,12 +378,65 @@ pub fn parse_deno_signature( .transpose()? .unwrap_or_else(|| vec![]) }, - no_main_func: Some(no_main_func), + auto_kind, has_preprocessor: Some(has_preprocessor), }; Ok(r) } +/// Extract params from `workflow(async (...) => { ... })` or `workflow(async function(...) { ... })` +fn extract_workflow_params(expr: &Expr) -> Option> { + if let Expr::Call(call) = expr { + if let swc_ecma_ast::Callee::Expr(callee) = &call.callee { + if let Expr::Ident(ident) = callee.as_ref() { + if ident.sym.as_ref() == "workflow" { + if let Some(first_arg) = call.args.first() { + match first_arg.expr.as_ref() { + Expr::Arrow(arrow) if arrow.is_async => { + return Some( + arrow + .params + .iter() + .map(|pat| Param { + span: pat.span(), + decorators: vec![], + pat: pat.clone(), + }) + .collect(), + ); + } + Expr::Fn(fn_expr) if fn_expr.function.is_async => { + return Some(fn_expr.function.params.clone()); + } + Expr::Paren(p) => { + return extract_workflow_params_from_inner(&p.expr); + } + _ => {} + } + } + } + } + } + } + None +} + +/// Helper for parenthesized expressions inside workflow() +fn extract_workflow_params_from_inner(expr: &Expr) -> Option> { + match expr { + Expr::Arrow(arrow) if arrow.is_async => Some( + arrow + .params + .iter() + .map(|pat| Param { span: pat.span(), decorators: vec![], pat: pat.clone() }) + .collect(), + ), + Expr::Fn(fn_expr) if fn_expr.function.is_async => Some(fn_expr.function.params.clone()), + Expr::Paren(p) => extract_workflow_params_from_inner(&p.expr), + _ => None, + } +} + fn parse_param( symbol_table: &HashMap, type_resolver: &mut HashMap, @@ -363,8 +448,12 @@ fn parse_param( let r = match param.pat { Pat::Ident(ident) => { let (name, typ, nullable) = binding_ident_to_arg(symbol_table, type_resolver, &ident); + let otyp = ident + .type_ann + .as_ref() + .and_then(|ta| detect_union_array_otyp(&ta.type_ann)); Ok(Arg { - otyp: None, + otyp, name, typ, default: None, @@ -374,13 +463,21 @@ fn parse_param( } // Pat::Object(ObjectPat { ... }) = todo!() Pat::Assign(AssignPat { left, right, .. }) => { - let (name, mut typ, _nullable) = match *left { - Pat::Ident(ident) => binding_ident_to_arg(symbol_table, type_resolver, &ident), + let (name, mut typ, _nullable, otyp) = match *left { + Pat::Ident(ident) => { + let otyp = ident + .type_ann + .as_ref() + .and_then(|ta| detect_union_array_otyp(&ta.type_ann)); + let (name, typ, nullable) = + binding_ident_to_arg(symbol_table, type_resolver, &ident); + (name, typ, nullable, otyp) + } Pat::Object(ObjectPat { type_ann, .. }) => { let (typ, nullable) = eval_type_ann(symbol_table, type_resolver, &type_ann); *counter += 1; let name = format!("anon{}", counter); - (name, typ, nullable) + (name, typ, nullable, None) } _ => { return Err(anyhow::anyhow!( @@ -416,7 +513,7 @@ fn parse_param( if typ == Typ::Unknown && dflt.is_some() { typ = json_to_typ(dflt.as_ref().unwrap(), false); } - Ok(Arg { otyp: None, name, typ, default: dflt, has_default: true, oidx: None }) + Ok(Arg { otyp, name, typ, default: dflt, has_default: true, oidx: None }) } Pat::Object(ObjectPat { type_ann, .. }) => { let (typ, nullable) = eval_type_ann(symbol_table, type_resolver, &type_ann); @@ -961,6 +1058,75 @@ fn one_of_properties( .collect() } +fn ts_type_to_string(ts_type: &TsType) -> Option { + match ts_type { + TsType::TsKeywordType(t) => Some( + match t.kind { + TsKeywordTypeKind::TsStringKeyword => "string", + TsKeywordTypeKind::TsNumberKeyword => "number", + TsKeywordTypeKind::TsBooleanKeyword => "boolean", + TsKeywordTypeKind::TsObjectKeyword => "object", + TsKeywordTypeKind::TsBigIntKeyword => "bigint", + TsKeywordTypeKind::TsAnyKeyword => "any", + _ => return None, + } + .to_string(), + ), + TsType::TsTypeRef(TsTypeRef { type_name, .. }) => match type_name { + TsEntityName::Ident(Ident { sym, .. }) => Some(sym.to_string()), + _ => None, + }, + _ => None, + } +} + +fn get_array_elem_type(ts_type: &TsType) -> Option<&TsType> { + match ts_type { + TsType::TsArrayType(TsArrayType { elem_type, .. }) => Some(elem_type), + _ => None, + } +} + +/// Detects union types of the form `T | T[]` or `T[] | T` and returns +/// the original type string (e.g. "string | string[]"). +fn detect_union_array_otyp(ts_type: &TsType) -> Option { + let TsType::TsUnionOrIntersectionType(TsUnionOrIntersectionType::TsUnionType(TsUnionType { + types, + .. + })) = ts_type + else { + return None; + }; + + if types.len() != 2 { + return None; + } + + // Check pattern: T | T[] + if let (Some(scalar_name), Some(array_elem)) = + (ts_type_to_string(&types[0]), get_array_elem_type(&types[1])) + { + if let Some(elem_name) = ts_type_to_string(array_elem) { + if scalar_name == elem_name { + return Some(format!("{} | {}[]", scalar_name, elem_name)); + } + } + } + + // Check pattern: T[] | T + if let (Some(array_elem), Some(scalar_name)) = + (get_array_elem_type(&types[0]), ts_type_to_string(&types[1])) + { + if let Some(elem_name) = ts_type_to_string(array_elem) { + if scalar_name == elem_name { + return Some(format!("{}[] | {}", elem_name, scalar_name)); + } + } + } + + None +} + fn find_undefined(types: &Vec>) -> Option { types.into_iter().position(|x| match **x { TsType::TsKeywordType(TsKeywordType { kind, .. }) => { diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index 1d9fbda6ef..7a685fbb77 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -45,7 +45,7 @@ mod tests { star_args: false, star_kwargs: false, args: vec![], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -103,7 +103,7 @@ mod tests { oidx: None, }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -152,7 +152,7 @@ mod tests { oidx: None, }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -201,7 +201,7 @@ mod tests { oidx: None, }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -232,7 +232,7 @@ mod tests { has_default: false, oidx: None, },], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -263,7 +263,7 @@ mod tests { has_default: false, oidx: None, },], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -303,7 +303,7 @@ mod tests { has_default: false, oidx: None, }], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -341,7 +341,7 @@ mod tests { has_default: false, oidx: None, }], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -399,7 +399,7 @@ mod tests { has_default: false, oidx: None, }], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -457,7 +457,7 @@ mod tests { oidx: None, }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -506,7 +506,7 @@ mod tests { oidx: None, }, ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -555,7 +555,7 @@ mod tests { star_args: false, star_kwargs: false, args: vec![], - no_main_func: Some(true), + auto_kind: Some("lib".to_string()), has_preprocessor: Some(false), } ); @@ -582,7 +582,7 @@ mod tests { has_default: false, oidx: None, }], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -611,7 +611,7 @@ mod tests { has_default: false, oidx: None, }], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false), } ); @@ -640,7 +640,56 @@ mod tests { has_default: false, oidx: None, }], - no_main_func: Some(false), + auto_kind: None, + has_preprocessor: Some(false), + } + ); + } + + #[test] + fn test_parse_union_array_type() { + let code = r#" + export async function main( + items: string | string[], + numbers: number[] | number, + plain: string + ) { + return { items, numbers, plain }; + } + "#; + let sig = parse_deno_signature(code, false, false, None).unwrap(); + assert_eq!( + sig, + MainArgSignature { + star_args: false, + star_kwargs: false, + args: vec![ + Arg { + name: "items".to_string(), + otyp: Some("string | string[]".to_string()), + typ: Typ::Unknown, + default: None, + has_default: false, + oidx: None, + }, + Arg { + name: "numbers".to_string(), + otyp: Some("number[] | number".to_string()), + typ: Typ::Unknown, + default: None, + has_default: false, + oidx: None, + }, + Arg { + name: "plain".to_string(), + otyp: None, + typ: Typ::Str(None), + default: None, + has_default: false, + oidx: None, + }, + ], + auto_kind: None, has_preprocessor: Some(false), } ); @@ -680,7 +729,7 @@ mod tests { has_default: false, oidx: None, }], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(true), } ); @@ -710,7 +759,7 @@ mod tests { has_default: false, oidx: None, }], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(true), } ); diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock new file mode 100644 index 0000000000..5b0eb580f3 --- /dev/null +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -0,0 +1,4868 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object", +] + +[[package]] +name = "ariadne" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1cb2a2046bea8ce5e875551f5772024882de0b540c7f93dfc5d6cf1ca8b030c" +dependencies = [ + "yansi", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ast_node" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9184f2b369b3e8625712493c89b785881f27eedc6cde480a81883cef78868b2" +dependencies = [ + "proc-macro2", + "quote", + "swc_macros_common", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "better_scoped_tls" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297b153aa5e573b5863108a6ddc9d5c968bd0b20e75cc614ee9821d2f45679c7" +dependencies = [ + "scoped-tls", +] + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn 2.0.117", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +dependencies = [ + "serde", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "byte-unit" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6d47a4e2961fb8721bcfc54feae6455f2f64e7054f9bc67e875f0e77f4c58d" +dependencies = [ + "rust_decimal", + "schemars 1.2.1", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bytesize" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e93abca9e28e0a1b9877922aacb20576e05d4679ffa78c3d6dc22a26a216659" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "pure-rust-locales", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-humanize" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799627e6b4d27827a814e837b9d8a504832086081806d45b1afa34dc982b023b" +dependencies = [ + "chrono", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7" +dependencies = [ + "serde", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "from_variant" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32016f1242eb82af5474752d00fd8ebcd9004bd69b462b1c91de833972d08ed4" +dependencies = [ + "proc-macro2", + "swc_macros_common", + "syn 2.0.117", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gosyn" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb37859fda6792e95231aef1c5838f4043ec0ee352d8313421e311c606df612" +dependencies = [ + "anyhow", + "strum 0.25.0", + "thiserror 1.0.69", + "unic-ucd-category", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a26def229ea95a8709dad32868d975d0dd40235bd2ce82920e4a8fe692b5e0" +dependencies = [ + "hashbrown 0.14.5", + "new_debug_unreachable", + "once_cell", + "phf", + "rustc-hash 1.1.0", + "triomphe", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "inventory" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009ae045c87e7082cb72dab0ccd01ae075dd00141ddc108f43a0ea150a9e7227" +dependencies = [ + "rustversion", +] + +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libproc" +version = "0.14.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a54ad7278b8bc5301d5ffd2a94251c004feb971feba96c971ea4063645990757" +dependencies = [ + "bindgen", + "errno", + "libc", +] + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "lscolors" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53304fff6ab1e597661eee37e42ea8c47a146fca280af902bb76bff8a896e523" +dependencies = [ + "nu-ansi-term", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "malachite" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbdf9cb251732db30a7200ebb6ae5d22fe8e11397364416617d2c2cf0c51cb5" +dependencies = [ + "malachite-base", + "malachite-nz", + "malachite-q", +] + +[[package]] +name = "malachite-base" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ea0ed76adf7defc1a92240b5c36d5368cfe9251640dcce5bd2d0b7c1fd87aeb" +dependencies = [ + "hashbrown 0.14.5", + "itertools 0.11.0", + "libm", + "ryu", +] + +[[package]] +name = "malachite-bigint" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d149aaa2965d70381709d9df4c7ee1fc0de1c614a4efc2ee356f5e43d68749f8" +dependencies = [ + "derive_more", + "malachite", + "num-integer", + "num-traits", + "paste", +] + +[[package]] +name = "malachite-nz" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34a79feebb2bc9aa7762047c8e5495269a367da6b5a90a99882a0aeeac1841f7" +dependencies = [ + "itertools 0.11.0", + "libm", + "malachite-base", +] + +[[package]] +name = "malachite-q" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f235d5747b1256b47620f5640c2a17a88c7569eebdf27cd9cb130e1a619191" +dependencies = [ + "itertools 0.11.0", + "malachite-base", + "malachite-nz", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-derive-value" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71f7c8ed6ba88a567ec6f7c4cad4a7a8465ab93b8cdaf89d3dc72347a83c2d1f" +dependencies = [ + "heck 0.5.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "nu-engine" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6619583ed281060a9ea0a3f4532eea918370c94e703b903065f35e5aa49b14" +dependencies = [ + "log", + "nu-glob", + "nu-path", + "nu-protocol", + "nu-utils", + "terminal_size", +] + +[[package]] +name = "nu-glob" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acd0a9fe69412acdc8501f5ef19031f9cac119d93823cb957b14ddfe1cb97660" + +[[package]] +name = "nu-parser" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2adc2876bd7bc83be15786cedf2cb08a81a9d70fa4b8df569b3f1cbec1e0b58d" +dependencies = [ + "bytesize", + "chrono", + "itertools 0.13.0", + "log", + "nu-engine", + "nu-path", + "nu-protocol", + "nu-utils", + "serde_json", +] + +[[package]] +name = "nu-path" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ccd1bbaf370d79118bd1a807abb07d8d1386751d0ae9266baafa91bd0b5523f" +dependencies = [ + "dirs", + "omnipath", + "pwd", +] + +[[package]] +name = "nu-protocol" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f49a395b632530d7f46fd24183c7f42423677f70afb3cb4726e3abfe92273b" +dependencies = [ + "byte-unit", + "bytes", + "chrono", + "chrono-humanize", + "dirs", + "dirs-sys", + "fancy-regex", + "heck 0.5.0", + "indexmap", + "log", + "lru", + "miette", + "nix", + "nu-derive-value", + "nu-path", + "nu-system", + "nu-utils", + "num-format", + "serde", + "serde_json", + "thiserror 2.0.18", + "typetag", + "windows-sys 0.48.0", +] + +[[package]] +name = "nu-system" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81182f7e64bd5dd16ab844d8e40f78e389d06d95f5a0c419f4701fb8fc163077" +dependencies = [ + "chrono", + "itertools 0.13.0", + "libc", + "libproc", + "log", + "mach2", + "nix", + "ntapi", + "procfs", + "sysinfo", + "windows 0.56.0", +] + +[[package]] +name = "nu-utils" +version = "0.101.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d1468fa8e6e12d9d53c90b44f3d11a37d87502d7a30d145f122341c5b33745" +dependencies = [ + "crossterm_winapi", + "fancy-regex", + "log", + "lscolors", + "nix", + "num-format", + "serde", + "serde_json", + "strip-ansi-escapes", + "sys-locale", + "unicase", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "omnipath" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80adb31078122c880307e9cdfd4e3361e6545c319f9b9dcafcb03acd3b51a575" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "php-parser-rs" +version = "0.1.3" +source = "git+https://github.com/php-rust-tools/parser?rev=ec4cb411dec09450946ef57920b7ffced7f6495d#ec4cb411dec09450946ef57920b7ffced7f6495d" +dependencies = [ + "ariadne", + "clap", + "schemars 0.8.22", + "serde", + "serde_json", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.4", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "procfs" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" +dependencies = [ + "bitflags", + "chrono", + "flate2", + "hex", + "procfs-core", + "rustix 0.38.44", +] + +[[package]] +name = "procfs-core" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" +dependencies = [ + "bitflags", + "chrono", + "hex", +] + +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pulldown-cmark" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "unicase", +] + +[[package]] +name = "pure-rust-locales" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d" + +[[package]] +name = "pwd" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c71c0c79b9701efe4e1e4b563b2016dd4ee789eb99badcb09d61ac4b92e4a2" +dependencies = [ + "libc", + "thiserror 1.0.69", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", + "zerocopy", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "recursive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +dependencies = [ + "recursive-proc-macro-impl", + "stacker", +] + +[[package]] +name = "recursive-proc-macro-impl" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_decimal" +version = "1.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustpython-ast" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cdaf8ee5c1473b993b398c174641d3aa9da847af36e8d5eb8291930b72f31a5" +dependencies = [ + "is-macro", + "malachite-bigint", + "rustpython-parser-core", + "static_assertions", +] + +[[package]] +name = "rustpython-parser" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "868f724daac0caf9bd36d38caf45819905193a901e8f1c983345a68e18fb2abb" +dependencies = [ + "anyhow", + "is-macro", + "itertools 0.11.0", + "lalrpop-util", + "log", + "malachite-bigint", + "num-traits", + "phf", + "phf_codegen", + "rustc-hash 1.1.0", + "rustpython-ast", + "rustpython-parser-core", + "tiny-keccak", + "unic-emoji-char", + "unic-ucd-ident", + "unicode_names2", +] + +[[package]] +name = "rustpython-parser-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b6c12fa273825edc7bccd9a734f0ad5ba4b8a2f4da5ff7efe946f066d0f4ad" +dependencies = [ + "is-macro", + "memchr", + "rustpython-parser-vendored", +] + +[[package]] +name = "rustpython-parser-vendored" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04fcea49a4630a3a5d940f4d514dc4f575ed63c14c3e3ed07146634aed7f67a6" +dependencies = [ + "memchr", + "once_cell", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlparser" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4591acadbcf52f0af60eafbb2c003232b2b4cd8de5f0e9437cb8b1b59046cc0f" +dependencies = [ + "log", + "recursive", + "sqlparser_derive", +] + +[[package]] +name = "sqlparser_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bigdecimal", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bigdecimal", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bigdecimal", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "num-bigint", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_enum" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e383308aebc257e7d7920224fa055c632478d92744eca77f99be8fa1545b90" +dependencies = [ + "proc-macro2", + "quote", + "swc_macros_common", + "syn 2.0.117", +] + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros 0.25.3", +] + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "swc_allocator" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76aa0eb65c0f39f9b6d82a7e5192c30f7ac9a78f084a21f270de1d8c600ca388" +dependencies = [ + "bumpalo", + "hashbrown 0.14.5", + "ptr_meta", + "rustc-hash 1.1.0", + "triomphe", +] + +[[package]] +name = "swc_atoms" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb6567e4e67485b3e7662b486f1565bdae54bd5b9d6b16b2ba1a9babb1e42125" +dependencies = [ + "hstr", + "once_cell", + "rustc-hash 1.1.0", + "serde", +] + +[[package]] +name = "swc_common" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12d0a8eaaf1606c9207077d75828008cb2dfb51b095a766bd2b72ef893576e31" +dependencies = [ + "ast_node", + "better_scoped_tls", + "cfg-if", + "either", + "from_variant", + "new_debug_unreachable", + "num-bigint", + "once_cell", + "rustc-hash 1.1.0", + "serde", + "siphasher 0.3.11", + "swc_allocator", + "swc_atoms", + "swc_eq_ignore_macros", + "swc_visit", + "tracing", + "unicode-width 0.1.14", + "url", +] + +[[package]] +name = "swc_ecma_ast" +version = "0.118.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6f866d12e4d519052b92a0a86d1ac7ff17570da1272ca0c89b3d6f802cd79df" +dependencies = [ + "bitflags", + "is-macro", + "num-bigint", + "phf", + "scoped-tls", + "string_enum", + "swc_atoms", + "swc_common", + "unicode-id-start", +] + +[[package]] +name = "swc_ecma_parser" +version = "0.149.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683dada14722714588b56481399c699378b35b2ba4deb5c4db2fb627a97fb54b" +dependencies = [ + "either", + "new_debug_unreachable", + "num-bigint", + "num-traits", + "phf", + "serde", + "smallvec", + "smartstring", + "stacker", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "tracing", + "typed-arena", +] + +[[package]] +name = "swc_ecma_visit" +version = "0.104.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1c6802e68e51f336e8bc9644e9ff9da75d7da9c1a6247d532f2e908aa33e81" +dependencies = [ + "new_debug_unreachable", + "num-bigint", + "swc_atoms", + "swc_common", + "swc_ecma_ast", + "swc_visit", + "tracing", +] + +[[package]] +name = "swc_eq_ignore_macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63db0adcff29d220c3d151c5b25c0eabe7e32dd936212b84cdaa1392e3130497" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "swc_macros_common" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27e18fbfe83811ffae2bb23727e45829a0d19c6870bced7c0f545cc99ad248dd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "swc_visit" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ceb044142ba2719ef9eb3b6b454fce61ab849eb696c34d190f04651955c613d" +dependencies = [ + "either", + "new_debug_unreachable", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.60.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.19.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" +dependencies = [ + "indexmap", + "toml_datetime 0.7.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.0.9+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +dependencies = [ + "winnow 0.7.15", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tree-sitter" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0203df02a3b6dd63575cc1d6e609edc2181c9a11867a271b25cfd2abff3ec5ca" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-c-sharp" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67f06accca7b45351758663b8215089e643d53bd9a660ce0349314263737fcb0" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-java" +version = "0.23.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa6cbcdc8c679b214e616fd3300da67da0e492e066df01bcf5a5921a71e90d6" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-ruby" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be0484ea4ef6bb9c575b4fdabde7e31340a8d2dbc7d52b321ac83da703249f95" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" +dependencies = [ + "serde", + "stable_deref_trait", +] + +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "typetag" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +dependencies = [ + "erased-serde", + "inventory", + "once_cell", + "serde", + "typetag-impl", +] + +[[package]] +name = "typetag-impl" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-emoji-char" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b07221e68897210270a38bde4babb655869637af0f69407f96053a34f76494d" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-category" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8d4591f5fcfe1bd4453baaf803c40e1b1e69ff8455c47620440b46efef91c0" +dependencies = [ + "matches", + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-id-start" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand 0.8.5", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-bindgen-test" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee0a0f5343de9221a0d233b04520ed8dc2e6728dce180b1dcd9288ec9d9fa3c" +dependencies = [ + "js-sys", + "minicov", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a369369e4360c2884c3168d22bded735c43cccae97bbc147586d4b480edd138d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "web-sys" +version = "0.3.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windmill-parser" +version = "1.654.0" +dependencies = [ + "convert_case", + "serde", + "serde_json", +] + +[[package]] +name = "windmill-parser-bash" +version = "1.654.0" +dependencies = [ + "anyhow", + "lazy_static", + "regex", + "regex-lite", + "serde_json", + "windmill-parser", +] + +[[package]] +name = "windmill-parser-csharp" +version = "1.654.0" +dependencies = [ + "anyhow", + "serde_json", + "tree-sitter", + "tree-sitter-c-sharp", + "wasm-bindgen", + "windmill-parser", +] + +[[package]] +name = "windmill-parser-go" +version = "1.654.0" +dependencies = [ + "anyhow", + "gosyn", + "itertools 0.14.0", + "lazy_static", + "regex", + "windmill-parser", +] + +[[package]] +name = "windmill-parser-graphql" +version = "1.654.0" +dependencies = [ + "anyhow", + "lazy_static", + "regex", + "regex-lite", + "serde_json", + "windmill-parser", +] + +[[package]] +name = "windmill-parser-java" +version = "1.654.0" +dependencies = [ + "anyhow", + "serde_json", + "tree-sitter", + "tree-sitter-java", + "wasm-bindgen", + "windmill-parser", +] + +[[package]] +name = "windmill-parser-nu" +version = "1.654.0" +dependencies = [ + "anyhow", + "nu-parser", + "serde_json", + "wasm-bindgen", + "windmill-parser", +] + +[[package]] +name = "windmill-parser-php" +version = "1.654.0" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "php-parser-rs", + "serde_json", + "windmill-parser", +] + +[[package]] +name = "windmill-parser-py" +version = "1.654.0" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "rustpython-ast", + "rustpython-parser", + "serde_json", + "windmill-parser", + "windmill-parser-sql", +] + +[[package]] +name = "windmill-parser-ruby" +version = "1.654.0" +dependencies = [ + "anyhow", + "lazy_static", + "regex", + "serde_json", + "tree-sitter", + "tree-sitter-ruby", + "wasm-bindgen", + "windmill-parser", +] + +[[package]] +name = "windmill-parser-rust" +version = "1.654.0" +dependencies = [ + "anyhow", + "convert_case", + "itertools 0.14.0", + "lazy_static", + "pulldown-cmark", + "quote", + "regex", + "serde_json", + "syn 2.0.117", + "toml", + "windmill-parser", +] + +[[package]] +name = "windmill-parser-sql" +version = "1.654.0" +dependencies = [ + "anyhow", + "lazy_static", + "regex", + "regex-lite", + "serde", + "serde_json", + "sqlparser", + "windmill-parser", + "windmill-types", +] + +[[package]] +name = "windmill-parser-ts" +version = "1.654.0" +dependencies = [ + "anyhow", + "lazy_static", + "regex", + "serde-wasm-bindgen", + "serde_json", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", + "swc_ecma_visit", + "triomphe", + "wasm-bindgen", + "windmill-parser", + "windmill-parser-sql", +] + +[[package]] +name = "windmill-parser-wac" +version = "1.654.0" +dependencies = [ + "anyhow", + "rustpython-ast", + "rustpython-parser", + "serde", + "serde_json", + "sha2", + "swc_common", + "swc_ecma_ast", + "swc_ecma_parser", + "swc_ecma_visit", +] + +[[package]] +name = "windmill-parser-wasm" +version = "1.654.0" +dependencies = [ + "anyhow", + "getrandom 0.2.17", + "getrandom 0.3.4", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-test", + "windmill-parser", + "windmill-parser-bash", + "windmill-parser-csharp", + "windmill-parser-go", + "windmill-parser-graphql", + "windmill-parser-java", + "windmill-parser-nu", + "windmill-parser-php", + "windmill-parser-py", + "windmill-parser-ruby", + "windmill-parser-rust", + "windmill-parser-sql", + "windmill-parser-ts", + "windmill-parser-wac", + "windmill-parser-yaml", +] + +[[package]] +name = "windmill-parser-yaml" +version = "1.654.0" +dependencies = [ + "anyhow", + "serde", + "serde_json", + "windmill-parser", + "yaml-rust", +] + +[[package]] +name = "windmill-types" +version = "1.654.0" +dependencies = [ + "anyhow", + "bitflags", + "chrono", + "hex", + "itertools 0.14.0", + "rand 0.9.0", + "serde", + "serde_json", + "sqlx", + "strum 0.27.2", + "tracing", + "uuid", +] + +[[package]] +name = "windows" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +dependencies = [ + "windows-core 0.56.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" +dependencies = [ + "windows-implement 0.56.0", + "windows-interface 0.56.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yaml-rust" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/backend/parsers/windmill-parser-wasm/tests/wasm.rs b/backend/parsers/windmill-parser-wasm/tests/wasm.rs index 204db994d7..14a72b7e2a 100644 --- a/backend/parsers/windmill-parser-wasm/tests/wasm.rs +++ b/backend/parsers/windmill-parser-wasm/tests/wasm.rs @@ -140,7 +140,7 @@ export function main(test1?: string, test2: string = \"burkina\", oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -219,7 +219,7 @@ export function main(test2 = \"burkina\", oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -270,7 +270,7 @@ export function main(foo: FooBar, {a, b}: FooBar, {c, d}: FooBar = {a: \"foo\", oidx: None } ], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -302,7 +302,7 @@ export function main(foo: (\"foo\" | \"bar\")[]) { has_default: false, oidx: None }], - no_main_func: Some(false), + auto_kind: None, has_preprocessor: Some(false) } ); @@ -446,7 +446,7 @@ Write-Output 'Testing...' oidx: None } ], - no_main_func: None, + auto_kind: None, has_preprocessor: None } ); diff --git a/backend/parsers/windmill-parser-yaml/src/lib.rs b/backend/parsers/windmill-parser-yaml/src/lib.rs index 7c5a6ec437..f010d21ab4 100644 --- a/backend/parsers/windmill-parser-yaml/src/lib.rs +++ b/backend/parsers/windmill-parser-yaml/src/lib.rs @@ -25,7 +25,7 @@ pub fn parse_ansible_sig(inner_content: &str) -> anyhow::Result anyhow::Result, - pub no_main_func: Option, + pub auto_kind: Option, pub has_preprocessor: Option, } diff --git a/backend/src/cgroups.rs b/backend/src/cgroups.rs index c6120c3524..285b28dfe7 100644 --- a/backend/src/cgroups.rs +++ b/backend/src/cgroups.rs @@ -42,24 +42,73 @@ pub fn disable_oom_group() -> Result<(), CgroupError> { let oom_group_file = cgroup_path.join("memory.oom.group"); if !oom_group_file.exists() { + tracing::warn!( + "memory.oom.group not found at {:?} — cgroups v2 memory controller may not be enabled. \ + OOM killer may kill the entire pod instead of individual jobs", + oom_group_file + ); return Err(CgroupError::NotSupported); } let current = fs::read_to_string(&oom_group_file)?; if current.trim() == "0" { - tracing::info!("memory.oom.group already disabled"); + tracing::info!("memory.oom.group already disabled at {:?}", cgroup_path); return Ok(()); } + tracing::info!( + "memory.oom.group is currently '{}' at {:?}, attempting to disable", + current.trim(), + cgroup_path + ); + match fs::write(&oom_group_file, "0") { Ok(_) => { - tracing::info!("Disabled memory.oom.group at {:?}", cgroup_path); + // Verify the write took effect + match fs::read_to_string(&oom_group_file) { + Ok(val) if val.trim() == "0" => { + tracing::info!("Disabled memory.oom.group at {:?}", cgroup_path); + } + Ok(val) => { + tracing::error!( + "Wrote 0 to memory.oom.group but read back '{}' at {:?}. \ + OOM killer may kill the entire pod instead of individual jobs", + val.trim(), + cgroup_path + ); + return Err(CgroupError::Io(std::io::Error::new( + std::io::ErrorKind::Other, + format!( + "memory.oom.group write did not take effect, read back '{}'", + val.trim() + ), + ))); + } + Err(e) => { + tracing::warn!( + "Wrote 0 to memory.oom.group but could not verify at {:?}: {e}", + cgroup_path + ); + } + } Ok(()) } Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { - tracing::error!("Failed to disable memory.oom.group (need privileged mode)"); + tracing::error!( + "Failed to disable memory.oom.group at {:?} (permission denied). \ + The container needs SYS_RESOURCE capability or privileged mode. \ + OOM killer WILL kill the entire pod instead of individual jobs", + oom_group_file + ); Err(CgroupError::PermissionDenied) } - Err(e) => Err(CgroupError::Io(e)), + Err(e) => { + tracing::error!( + "Failed to disable memory.oom.group at {:?}: {e}. \ + OOM killer may kill the entire pod instead of individual jobs", + oom_group_file + ); + Err(CgroupError::Io(e)) + } } } diff --git a/backend/src/db_connect.rs b/backend/src/db_connect.rs index 05bf7b6522..abb3378efc 100644 --- a/backend/src/db_connect.rs +++ b/backend/src/db_connect.rs @@ -20,6 +20,7 @@ pub async fn connect_db( server_mode: bool, indexer_mode: bool, worker_mode: bool, + num_workers: i32, #[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>, ) -> anyhow::Result> { use anyhow::Context; @@ -34,13 +35,7 @@ pub async fn connect_db( } else if indexer_mode { DEFAULT_MAX_CONNECTIONS_INDEXER } else { - DEFAULT_MAX_CONNECTIONS_WORKER - + std::env::var("NUM_WORKERS") - .ok() - .map(|x| x.parse().ok()) - .flatten() - .unwrap_or(1) - - 1 + DEFAULT_MAX_CONNECTIONS_WORKER + (num_workers.max(1) as u32) - 1 } } }; @@ -103,7 +98,7 @@ pub async fn connect( use sqlx::Executor; use std::time::Duration; let mut pool_options = sqlx::postgres::PgPoolOptions::new() - .min_connections((max_connections / 5).clamp(1, max_connections)) + .min_connections(0) .max_connections(max_connections) .max_lifetime(Duration::from_secs(30 * 60)); // 30 mins if worker_mode { diff --git a/backend/src/main.rs b/backend/src/main.rs index 45849c0bd3..9a3e2027af 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -243,7 +243,14 @@ async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { create_dir_all(&*HUB_CACHE_DIR)?; create_dir_all(&*BUN_BUNDLE_CACHE_DIR)?; - for path in paths.values() { + // Ensure the latest git sync script is always cached, regardless of hubPaths.json contents + let mut all_paths: Vec = paths.into_values().collect(); + let latest_git_sync = windmill_common::workspaces::LATEST_GIT_SYNC_SCRIPT_PATH.to_string(); + if !all_paths.contains(&latest_git_sync) { + all_paths.push(latest_git_sync); + } + + for path in &all_paths { tracing::info!("Caching hub script at {path}"); let res = get_hub_script_content_and_requirements(Some(path), None).await?; if res @@ -285,6 +292,7 @@ async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { envs.clone(), false, &mut None, + false, ) .await?; @@ -694,6 +702,7 @@ async fn windmill_main() -> anyhow::Result<()> { let mut num_workers = if mode == Mode::Server || mode == Mode::Indexer || mode == Mode::MCP { 0 } else if is_native_mode_from_env() { + NATIVE_MODE_RESOLVED.store(true, std::sync::atomic::Ordering::Relaxed); println!("Native mode enabled: forcing NUM_WORKERS=8"); 8 } else { @@ -862,7 +871,79 @@ async fn windmill_main() -> anyhow::Result<()> { if worker_mode { #[cfg(any(target_os = "linux"))] if let Err(e) = disable_oom_group() { - tracing::warn!("failed to disable oom group: {:?}", e); + tracing::warn!( + "Failed to disable cgroup OOM group kill: {e:?}. \ + When a job exceeds memory, the OOM killer will kill the entire pod \ + instead of just the offending job process" + ); + } + + // Lower the worker's oom_score_adj so the OOM killer strongly prefers killing + // job subprocesses (oom_score_adj=1000) over the worker itself. + // Kubernetes sets it high for burstable QoS (e.g. 937), leaving a tiny gap vs jobs. + // Requires CAP_SYS_RESOURCE to lower it; if missing, we just warn. + #[cfg(any(target_os = "linux"))] + match std::fs::read_to_string("/proc/self/oom_score_adj") { + Ok(current) => { + let current = current.trim().to_string(); + let current_val = match current.parse::() { + Ok(v) => v, + Err(e) => { + tracing::warn!("Could not parse oom_score_adj '{current}': {e}"); + 0 + } + }; + if current_val > 0 { + match std::fs::write("/proc/self/oom_score_adj", "0") { + Ok(_) => { + tracing::info!( + "Lowered worker oom_score_adj from {current} to 0 \ + (jobs get 1000, gap=1000)" + ); + } + Err(e) => { + tracing::warn!( + "Could not lower worker oom_score_adj from {current} to 0: {e}. \ + Gap to jobs is only {} — OOM killer may target the worker instead. \ + Add CAP_SYS_RESOURCE to the container to fix this", + 1000 - current_val + ); + } + } + } else { + tracing::info!( + "Worker oom_score_adj={current} (jobs get 1000, gap={})", + 1000 - current_val + ); + } + } + Err(e) => { + tracing::warn!("Could not read worker oom_score_adj: {e}"); + } + } + } + + // Resolve native mode early (before connect_db) so connection pool size accounts for it. + // native_mode can come from env OR from the DB worker group config. + if worker_mode && !is_native_mode_from_env() { + if let Some(db) = conn.as_sql() { + let native_from_db: bool = sqlx::query_scalar!( + "SELECT (config->>'native_mode')::boolean FROM config WHERE name = $1", + format!("worker__{}", *windmill_common::worker::WORKER_GROUP) + ) + .fetch_optional(db) + .await + .ok() + .flatten() + .flatten() + .unwrap_or(false); + if native_from_db { + NATIVE_MODE_RESOLVED.store(true, std::sync::atomic::Ordering::Relaxed); + num_workers = 8; + tracing::info!( + "Native mode detected from worker config (early): forcing NUM_WORKERS=8" + ); + } } } @@ -878,6 +959,7 @@ async fn windmill_main() -> anyhow::Result<()> { server_mode, indexer_mode, worker_mode, + num_workers, #[cfg(feature = "private")] killpill_rx.resubscribe(), ) @@ -982,16 +1064,6 @@ Windmill Community Edition {GIT_VERSION} ) .await; - // native_mode may also be set via DB worker group config (not just env). - // NATIVE_MODE_RESOLVED is updated by load_worker_config during initial_load. - if worker_mode - && !is_native_mode_from_env() - && NATIVE_MODE_RESOLVED.load(std::sync::atomic::Ordering::Relaxed) - { - num_workers = 8; - tracing::info!("Native mode detected from worker config: forcing NUM_WORKERS=8"); - } - monitor_db( &conn, &base_internal_url, @@ -1615,7 +1687,7 @@ async fn process_notify_event( } "notify_token_invalidation" => { tracing::info!( - "Token invalidation detected for token: {}...", + "Token invalidation detected for prefix: {}...", payload.get(..8).unwrap_or(payload) ); windmill_api::auth::invalidate_token_from_cache(payload); @@ -1884,7 +1956,7 @@ pub async fn run_workers( tracing::info!( "Starting {num_workers} workers and SLEEP_QUEUE={}ms", - *windmill_worker::SLEEP_QUEUE + windmill_worker::sleep_queue() ); for i in 1..(num_workers + 1) { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index d0b26a6b50..4cc9dc0aeb 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -251,9 +251,8 @@ pub async fn initial_load( .map(|x| x.tags.clone()) .unwrap_or_default(); // we only check from env as native_mode is not stored in the token + // NATIVE_MODE_RESOLVED is already set in main.rs during startup let native_mode = windmill_common::worker::is_native_mode_from_env(); - windmill_common::worker::NATIVE_MODE_RESOLVED - .store(native_mode, std::sync::atomic::Ordering::Relaxed); *config = WorkerConfig { worker_tags, env_vars: load_env_vars( @@ -946,7 +945,7 @@ pub async fn delete_expired_items(db: &DB) -> () { let expired_tokens_r = sqlx::query_as!( TokenRow, "DELETE FROM token WHERE expiration <= now() - RETURNING substring(token for 10) as token_prefix, label, email, workspace_id", + RETURNING token_prefix, label, email, workspace_id", ) .fetch_all(db) .await; @@ -1165,15 +1164,17 @@ 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 + // Find tokens expiring within 7 days that still have a pending notification row. + // The notification table stores token_hash (not plaintext) so the join works + // even after the hash migration makes token.token nullable. let expiring_tokens_r = sqlx::query_as!( TokenRow, "DELETE FROM token_expiry_notification n USING token t - WHERE n.token = t.token + WHERE n.token_hash = t.token_hash 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", + RETURNING t.token_prefix, t.label, t.email, t.workspace_id", ) .fetch_all(db) .await; @@ -2596,6 +2597,7 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n AND running = true AND kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') AND same_worker = false + AND q.suspend_until IS NULL AND (zjc.counter IS NULL OR zjc.counter <= $2) FOR UPDATE of q SKIP LOCKED ), @@ -2708,7 +2710,7 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n let same_worker_timeout_jobs = { let long_same_worker_jobs = sqlx::query!( "SELECT worker, array_agg(v2_job_queue.id) as ids FROM v2_job_queue LEFT JOIN v2_job ON v2_job_queue.id = v2_job.id LEFT JOIN v2_job_runtime ON v2_job_queue.id = v2_job_runtime.id WHERE v2_job_queue.created_at < now() - ('60 seconds')::interval - AND running = true AND (ping IS NULL OR ping < now() - ('60 seconds')::interval) AND same_worker = true AND worker IS NOT NULL GROUP BY worker", + AND running = true AND (ping IS NULL OR ping < now() - ('60 seconds')::interval) AND same_worker = true AND worker IS NOT NULL AND v2_job_queue.suspend_until IS NULL GROUP BY worker", ) .fetch_all(db) .await @@ -2763,7 +2765,7 @@ async fn handle_zombie_jobs(db: &Pool, base_internal_url: &str, node_n sqlx::query_scalar!("SELECT j.id FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id) WHERE r.ping < now() - ($1 || ' seconds')::interval - AND q.running = true AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') AND j.same_worker = false", + AND q.running = true AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') AND j.same_worker = false AND q.suspend_until IS NULL", ZOMBIE_JOB_TIMEOUT.as_str()) .fetch_all(db) .await diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 3ece1ce7ef..ce0905ac08 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -109,18 +109,20 @@ job_result_stream_v2: job_id(uuid), workspace_id(text), stream(text), idx(int) job_settings: job_id(uuid), runnable_settings(bigint) job_stats: workspace_id(char), job_id(uuid), metric_id(char), metric_name(char), metric_kind(metric_kind), scalar_int(int), scalar_float(float), timestamps(ts), timeseries_int(int[]), timeseries_float(float[]) FK: (workspace_id) -> workspace(id) -kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), filters(jsonb[]) +kafka_pending_commits: id(bigint), workspace_id(char), kafka_trigger_path(char), topic(char), partition(int), offset(bigint), created_at(ts) + FK: (workspace_id, kafka_trigger_path) -> kafka_trigger(workspace_id, path) +kafka_trigger: path(char), kafka_resource_path(char), topics(char), group_id(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode), filters(jsonb[]), auto_commit(bool) log_file: hostname(char), log_ts(ts), ok_lines(bigint), err_lines(bigint), mode(log_mode), worker_group(char), file_path(char), json_fmt(bool) magic_link: email(char), token(char), expiration(ts) mcp_oauth_client: mcp_server_url(text), client_id(text), client_secret(text), client_secret_expires_at(ts), token_endpoint(text), created_at(ts) -mcp_oauth_refresh_token: id(bigint), refresh_token(char), access_token(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), token_family(uuid), created_at(ts), expires_at(ts), used_at(ts), revoked(bool) +mcp_oauth_refresh_token: id(bigint), refresh_token(char), access_token_hash(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), token_family(uuid), created_at(ts), expires_at(ts), used_at(ts), revoked(bool) FK: (client_id) -> mcp_oauth_server_client(client_id) mcp_oauth_server_client: client_id(char), client_name(char), redirect_uris(text[]), created_at(ts) mcp_oauth_server_code: code(char), client_id(char), user_email(char), workspace_id(char), scopes(text[]), redirect_uri(text), code_challenge(char), code_challenge_method(char), created_at(ts), expires_at(ts) FK: (client_id) -> mcp_oauth_server_client(client_id) metrics: id(char), value(jsonb), created_at(ts) mqtt_trigger: mqtt_resource_path(char), subscribe_topics(jsonb[]), client_version(mqtt_client_version), v5_config(jsonb), v3_config(jsonb), client_id(char), path(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) -native_trigger: external_id(char), workspace_id(char), service_name(native_trigger_service), script_path(char), is_flow(bool), webhook_token_prefix(char), service_config(jsonb), error(text), created_at(ts), updated_at(ts) +native_trigger: external_id(char), workspace_id(char), service_name(native_trigger_service), script_path(char), is_flow(bool), webhook_token_hash(char), service_config(jsonb), error(text), created_at(ts), updated_at(ts) FK: (workspace_id) -> workspace(id) nats_trigger: path(char), nats_resource_path(char), subjects(char), stream_name(char), consumer_name(char), use_jetstream(bool), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) FK: (workspace_id) -> workspace(id) @@ -144,14 +146,14 @@ resume_job: id(uuid), job(uuid), flow(uuid), created_at(ts), value(jsonb), appro runnable_settings: hash(bigint), debouncing_settings(bigint), concurrency_settings(bigint) schedule: workspace_id(char), path(char), edited_by(char), edited_at(ts), schedule(char), enabled(bool), script_path(char), args(jsonb), extra_perms(jsonb), is_flow(bool), email(char), error(text), timezone(char), on_failure(char), on_recovery(char), on_failure_times(int), on_failure_exact(bool), on_failure_extra_args(jsonb), on_recovery_times(int), on_recovery_extra_args(jsonb), ws_error_handler_muted(bool), retry(jsonb), summary(char), no_flow_overlap(bool), tag(char), paused_until(ts), on_success(char), on_success_extra_args(jsonb), cron_version(text), description(text), dynamic_skip(char) FK: (workspace_id) -> workspace(id) -script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), no_main_func(bool), codebase(char), has_preprocessor(bool), on_behalf_of_email(text), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint) +script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), summary(text), description(text), content(text), created_by(char), created_at(ts), archived(bool), schema(json), deleted(bool), is_template(bool), extra_perms(jsonb), lock(text), lock_error_logs(text), language(script_lang), kind(script_kind), tag(char), draft_only(bool), envs(char), concurrent_limit(int), concurrency_time_window_s(int), cache_ttl(int), dedicated_worker(bool), ws_error_handler_muted(bool), priority(smallint), timeout(int), delete_after_use(bool), restart_unless_cancelled(bool), concurrency_key(char), visible_to_runner_only(bool), auto_kind(varchar), codebase(char), has_preprocessor(bool), on_behalf_of_email(text), schema_validation(bool), assets(jsonb), debounce_key(char), debounce_delay_s(int), cache_ignore_s3_path(bool), runnable_settings_handle(bigint) FK: (workspace_id) -> workspace(id) skip_workspace_diff_tally: workspace_id(char), added_at(ts) sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode) 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) +token: token_hash(char), token_prefix(char), 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) +token_expiry_notification: token_hash(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) diff --git a/backend/tests/agent_workers.rs b/backend/tests/agent_workers.rs index 4f12d5f42c..f422ed3a12 100644 --- a/backend/tests/agent_workers.rs +++ b/backend/tests/agent_workers.rs @@ -21,6 +21,7 @@ fn bun_code(code: &str) -> RawCode { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, } } diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index c69300028d..b2eb89112c 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -34,6 +34,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -70,6 +71,7 @@ export function main(name: string, count: number) { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = RunJob::from(job) @@ -112,6 +114,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -144,6 +147,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -177,6 +181,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -217,6 +222,7 @@ export async function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -256,6 +262,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -288,6 +295,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -330,6 +338,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let completed = run_job_in_new_worker_until_complete(&db, false, job, port).await; @@ -370,6 +379,7 @@ export function notMain() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let completed = run_job_in_new_worker_until_complete(&db, false, job, port).await; @@ -410,6 +420,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let completed = run_job_in_new_worker_until_complete(&db, false, job, port).await; @@ -449,6 +460,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -486,6 +498,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -528,6 +541,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -626,6 +640,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -661,6 +676,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -707,6 +723,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -755,6 +772,7 @@ export function main(x: number) { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); // x=5, main adds 10 = 15 @@ -805,6 +823,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -850,6 +869,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -1175,6 +1195,7 @@ export function main(name: string) { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = RunJob::from(job) @@ -1238,6 +1259,7 @@ export function main(name: string) { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = RunJob::from(job) @@ -1513,6 +1535,7 @@ module.exports.main = function() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = RunJob::from(job) @@ -1554,6 +1577,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = RunJob::from(job) @@ -1604,6 +1628,7 @@ module.exports.main = function() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); use std::sync::atomic::Ordering; @@ -1664,6 +1689,7 @@ export function main() { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); use std::sync::atomic::Ordering; diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index f3c03aa932..ba7b9bd546 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -41,11 +41,12 @@ mod dependency_map { deployment_message: None, concurrency_key: None, visible_to_runner_only: None, - no_main_func: None, + auto_kind: None, codebase: None, has_preprocessor: None, on_behalf_of_email: None, assets: vec![], + modules: None, } } async fn init(db: Pool) -> (windmill_api_client::Client, u16, ApiServer) { diff --git a/backend/tests/fixtures/base.sql b/backend/tests/fixtures/base.sql index 7db9918fba..ce5df2640e 100644 --- a/backend/tests/fixtures/base.sql +++ b/backend/tests/fixtures/base.sql @@ -33,9 +33,11 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/tests/fixtures/end_user_email.sql b/backend/tests/fixtures/end_user_email.sql index 654ad93680..17d459f7de 100644 --- a/backend/tests/fixtures/end_user_email.sql +++ b/backend/tests/fixtures/end_user_email.sql @@ -24,15 +24,17 @@ VALUES ('other-ws@windmill.dev', 'hash', 'password', false, true, 'Other WS User INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('other-workspace', 'other-ws@windmill.dev', 'other-ws-user', true, 'Admin'); -INSERT INTO token(token, email, label, super_admin) -VALUES ('OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false); +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('OTHER_WS_TOKEN'::bytea), 'hex'), 'OTHER_WS_T', 'OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false); -- User not in any workspace INSERT INTO password(email, password_hash, login_type, super_admin, verified, name) VALUES ('no-ws@windmill.dev', 'hash', 'password', false, true, 'No WS User'); -INSERT INTO token(token, email, label, super_admin) -VALUES ('NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false); +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('NO_WS_TOKEN'::bytea), 'hex'), 'NO_WS_TOKE', 'NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false); -- Script that returns WM_END_USER_EMAIL (public via extra_perms) INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, extra_perms) diff --git a/backend/tests/fixtures/permissions_test.sql b/backend/tests/fixtures/permissions_test.sql index a9006f5233..66f406c08c 100644 --- a/backend/tests/fixtures/permissions_test.sql +++ b/backend/tests/fixtures/permissions_test.sql @@ -39,13 +39,13 @@ ON CONFLICT (email) DO NOTHING; -- Tokens associated with emails (workspace-scoped) -- The auth system will look up the user by email in the usr table -- Note: tokens must be at least 10 characters (TOKEN_PREFIX_LEN) -INSERT INTO token (token, email, label, super_admin, owner, workspace_id) +INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, owner, workspace_id) VALUES - ('ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), - ('ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), - ('BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), - ('CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), - ('OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); + (encode(sha256('ADMIN_TOKEN_TEST'::bytea), 'hex'), 'ADMIN_TOKE', 'ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), + (encode(sha256('ALICE_TOKEN_TEST'::bytea), 'hex'), 'ALICE_TOKE', 'ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), + (encode(sha256('BOB_TOKEN_TEST12'::bytea), 'hex'), 'BOB_TOKEN_', 'BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), + (encode(sha256('CHARLIE_TOKEN_01'::bytea), 'hex'), 'CHARLIE_TO', 'CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), + (encode(sha256('OPERATOR_TOKEN_1'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); -- ============================================ -- GROUPS diff --git a/backend/tests/fixtures/preserve_on_behalf_of.sql b/backend/tests/fixtures/preserve_on_behalf_of.sql index 514c554960..7467843e80 100644 --- a/backend/tests/fixtures/preserve_on_behalf_of.sql +++ b/backend/tests/fixtures/preserve_on_behalf_of.sql @@ -65,14 +65,20 @@ INSERT INTO usr_to_group(workspace_id, group_, usr) VALUES ('test-workspace', 'wm_deployers', 'deployer-user') ON CONFLICT DO NOTHING; --- Tokens for all users -INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true) +-- Tokens for all users (token_hash = sha256 hex, token_prefix = first 10 chars) +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true) ON CONFLICT DO NOTHING; -INSERT INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false) +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false) ON CONFLICT DO NOTHING; -INSERT INTO token(token, email, label, super_admin) VALUES ('DEPLOYER_TOKEN', 'deployer@windmill.dev', 'deployer token', false) +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('DEPLOYER_TOKEN'::bytea), 'hex'), 'DEPLOYER_T', 'DEPLOYER_TOKEN', 'deployer@windmill.dev', 'deployer token', false) ON CONFLICT DO NOTHING; -INSERT INTO token(token, email, label, super_admin) VALUES ('ORIGINAL_TOKEN', 'original@windmill.dev', 'original token', false) +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin) +VALUES (encode(sha256('ORIGINAL_TOKEN'::bytea), 'hex'), 'ORIGINAL_T', 'ORIGINAL_TOKEN', 'original@windmill.dev', 'original token', false) ON CONFLICT DO NOTHING; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; diff --git a/backend/tests/list_jobs.rs b/backend/tests/list_jobs.rs index 65451bcbb7..5db9e3cb14 100644 --- a/backend/tests/list_jobs.rs +++ b/backend/tests/list_jobs.rs @@ -67,6 +67,7 @@ async fn test_list_jobs_without_include_args(db: Pool) -> anyhow::Resu concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("x", json!(42)) .push(&db) @@ -123,6 +124,7 @@ async fn test_list_jobs_with_include_args(db: Pool) -> anyhow::Result< concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("x", json!(42)) .push(&db) @@ -193,6 +195,7 @@ async fn test_list_jobs_completed_with_include_args(db: Pool) -> anyho concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("x", json!(42)) .run_until_complete(&db, false, port) @@ -265,6 +268,7 @@ async fn test_list_jobs_mixed_queue_and_completed(db: Pool) -> anyhow: concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("completed_arg", json!("completed_value")) .run_until_complete(&db, false, port) @@ -285,6 +289,7 @@ async fn test_list_jobs_mixed_queue_and_completed(db: Pool) -> anyhow: concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("queued_arg", json!("queued_value")) .push(&db) @@ -367,6 +372,7 @@ async fn test_list_jobs_multiple_queued_with_include_args(db: Pool) -> concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("x", json!(1)) .push(&db) @@ -384,6 +390,7 @@ async fn test_list_jobs_multiple_queued_with_include_args(db: Pool) -> concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("y", json!(2)) .push(&db) @@ -457,6 +464,7 @@ async fn test_queue_list_without_include_args(db: Pool) -> anyhow::Res concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("x", json!(42)) .push(&db) @@ -519,6 +527,7 @@ async fn test_queue_list_with_include_args(db: Pool) -> anyhow::Result concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("x", json!(42)) .push(&db) @@ -588,6 +597,7 @@ async fn test_queue_list_multiple_jobs_with_include_args(db: Pool) -> concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("a", json!("value_a")) .push(&db) @@ -605,6 +615,7 @@ async fn test_queue_list_multiple_jobs_with_include_args(db: Pool) -> concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("b", json!("value_b")) .push(&db) @@ -686,6 +697,7 @@ async fn test_completed_list_without_include_args(db: Pool) -> anyhow: concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("x", json!(42)) .run_until_complete(&db, false, port) @@ -751,6 +763,7 @@ async fn test_completed_list_with_include_args(db: Pool) -> anyhow::Re concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("x", json!(42)) .run_until_complete(&db, false, port) @@ -825,6 +838,7 @@ async fn test_completed_list_multiple_jobs_with_include_args( concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("a", json!("completed_a")) .run_until_complete(&db, false, port) @@ -845,6 +859,7 @@ async fn test_completed_list_multiple_jobs_with_include_args( concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("b", json!("completed_b")) .run_until_complete(&db, false, port) diff --git a/backend/tests/nativets_jobs.rs b/backend/tests/nativets_jobs.rs index eaf3a96652..5c57e5c0c8 100644 --- a/backend/tests/nativets_jobs.rs +++ b/backend/tests/nativets_jobs.rs @@ -39,6 +39,7 @@ fn nativets_code(content: &str) -> JobPayload { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }) } diff --git a/backend/tests/nativets_stress.rs b/backend/tests/nativets_stress.rs index 082a43be2c..a175d97af4 100644 --- a/backend/tests/nativets_stress.rs +++ b/backend/tests/nativets_stress.rs @@ -156,6 +156,7 @@ async fn push_job(db: &Pool, content: &str, args: &serde_json::Value) cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let tx = PushIsolationLevel::IsolatedRoot(db.clone()); diff --git a/backend/tests/python_jobs.rs b/backend/tests/python_jobs.rs index 22c2313b91..2ffb85a418 100644 --- a/backend/tests/python_jobs.rs +++ b/backend/tests/python_jobs.rs @@ -1,3 +1,4 @@ +use serde_json::json; use sqlx::postgres::Postgres; use sqlx::Pool; use windmill_common::scripts::ScriptLang; @@ -194,6 +195,7 @@ def main(): cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -245,6 +247,7 @@ def main(): cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -281,6 +284,7 @@ def main(): cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -321,6 +325,7 @@ def main(): cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -359,6 +364,7 @@ def main(): cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port) @@ -405,3 +411,54 @@ def main(): run_preview_relative_imports(&db, content, ScriptLang::Python3).await?; Ok(()) } + +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base"))] +async fn test_python_wac_v2_with_args(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +from wmill import task, workflow + +@task() +def greet(label: str, count: int) -> str: + return f"hello {label} x{count}" + +@workflow +async def main(item: str, qty: int, email: str): + greeting = await greet(item, qty) + return {"item": item, "qty": qty, "email": email, "greeting": greeting} +"# + .to_string(); + + // WAC requires at least 2 workers (parent + task sub-jobs) + let db = &db; + in_test_worker( + db, + async move { + let job = Box::pin( + RunJob::from(JobPayload::Code(RawCode { + language: ScriptLang::Python3, + content, + ..RawCode::default() + })) + .arg("item", json!("widget")) + .arg("qty", json!(5)) + .arg("email", json!("test@example.com")) + .run_until_complete(db, false, port), + ) + .await; + + let result = job.json_result().unwrap(); + assert_eq!(result["item"], json!("widget")); + assert_eq!(result["qty"], json!(5)); + assert_eq!(result["email"], json!("test@example.com")); + assert_eq!(result["greeting"], json!("hello widget x5")); + }, + port, + ) + .await; + Ok(()) +} diff --git a/backend/tests/relock_skip.rs b/backend/tests/relock_skip.rs index ce274796bc..9aae97adf3 100644 --- a/backend/tests/relock_skip.rs +++ b/backend/tests/relock_skip.rs @@ -39,11 +39,12 @@ mod relock_skip { deployment_message: None, concurrency_key: None, visible_to_runner_only: None, - no_main_func: None, + auto_kind: None, codebase: None, has_preprocessor: None, on_behalf_of_email: None, assets: vec![], + modules: None, } } @@ -57,13 +58,10 @@ mod relock_skip { pattern: &str, after: chrono::DateTime, ) -> i64 { - let logs = sqlx::query_scalar!( - "SELECT logs FROM job_logs WHERE created_at > $1", - after - ) - .fetch_all(db) - .await - .unwrap(); + let logs = sqlx::query_scalar!("SELECT logs FROM job_logs WHERE created_at > $1", after) + .fetch_all(db) + .await + .unwrap(); logs.iter() .filter_map(|l| l.as_ref()) @@ -270,8 +268,14 @@ def main(): // We allow up to 3 skips from cascade re-triggers. let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await; let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await; - assert!(skipping_count <= 3, "First deployment should have at most 3 skips from cascade"); - assert!(relocking_count >= 3, "First deployment should have at least 3 relocking jobs"); + assert!( + skipping_count <= 3, + "First deployment should have at most 3 skips from cascade" + ); + assert!( + relocking_count >= 3, + "First deployment should have at least 3 relocking jobs" + ); // Step 2: Redeploy default workspace deps again - should SKIP let before = chrono::Utc::now(); @@ -289,7 +293,10 @@ def main(): in_test_worker(&db, wait_for_jobs_ge(&mut completed, 10), port).await; let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await; - assert!(skipping_count >= 3, "Second deployment of same content should skip at least 3 times"); + assert!( + skipping_count >= 3, + "Second deployment of same content should skip at least 3 times" + ); // Step 3: Redeploy default workspace deps with different content - should NOT skip let before = chrono::Utc::now(); @@ -308,8 +315,14 @@ def main(): let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await; let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await; - assert!(skipping_count <= 4, "Changed content should have at most 3 skips from cascade"); - assert!(relocking_count >= 3, "Changed content should trigger at least 3 relocking jobs"); + assert!( + skipping_count <= 4, + "Changed content should have at most 3 skips from cascade" + ); + assert!( + relocking_count >= 3, + "Changed content should trigger at least 3 relocking jobs" + ); // Step 4: Deploy named workspace deps first time - should relock (no hash exists yet) // Named deps trigger exactly 3 independent objects with no cascade @@ -329,8 +342,14 @@ def main(): let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await; let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await; - assert_eq!(skipping_count, 0, "Named workspace deps first deployment should not skip"); - assert!(relocking_count > 0, "Named workspace deps first deployment should relock"); + assert_eq!( + skipping_count, 0, + "Named workspace deps first deployment should not skip" + ); + assert!( + relocking_count > 0, + "Named workspace deps first deployment should relock" + ); // Step 5: Deploy named workspace deps again with no change - should SKIP let before = chrono::Utc::now(); @@ -350,8 +369,14 @@ def main(): let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await; let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await; - assert!(skipping_count > 0, "Named workspace deps second deployment should skip"); - assert_eq!(relocking_count, 0, "Named workspace deps second deployment should not relock"); + assert!( + skipping_count > 0, + "Named workspace deps second deployment should skip" + ); + assert_eq!( + relocking_count, 0, + "Named workspace deps second deployment should not relock" + ); // Step 6: Deploy named workspace deps with small change - should NOT skip let before = chrono::Utc::now(); @@ -370,8 +395,14 @@ def main(): let skipping_count = count_pattern_in_job_logs(&db, "Skipping relock", before).await; let relocking_count = count_pattern_in_job_logs(&db, "Relocking", before).await; - assert_eq!(skipping_count, 0, "Named workspace deps with change should not skip"); - assert!(relocking_count > 0, "Named workspace deps with change should relock"); + assert_eq!( + skipping_count, 0, + "Named workspace deps with change should not skip" + ); + assert!( + relocking_count > 0, + "Named workspace deps with change should relock" + ); Ok(()) } diff --git a/backend/tests/script_modules.rs b/backend/tests/script_modules.rs new file mode 100644 index 0000000000..068555c7df --- /dev/null +++ b/backend/tests/script_modules.rs @@ -0,0 +1,158 @@ +use serde_json::json; +use sqlx::postgres::Postgres; +use sqlx::Pool; +use std::collections::HashMap; +use windmill_common::jobs::{JobPayload, RawCode}; +use windmill_common::scripts::{ScriptLang, ScriptModule}; +use windmill_test_utils::*; + +// ============================================================================ +// Python: script with inline module via relative import +// ============================================================================ + +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base"))] +async fn test_python_script_with_module(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let main_content = r#" +from .helper import greet + +def main(name: str): + return greet(name) +"# + .to_owned(); + + let mut modules = HashMap::new(); + modules.insert( + "helper.py".to_string(), + ScriptModule { + content: "def greet(name):\n return f\"hello {name}\"\n".to_string(), + language: ScriptLang::Python3, + lock: None, + }, + ); + + let job = JobPayload::Code(RawCode { + content: main_content, + path: Some("f/test/my_script".to_string()), + language: ScriptLang::Python3, + modules: Some(modules), + ..RawCode::default() + }); + + let result = RunJob::from(job) + .arg("name", json!("world")) + .run_until_complete(&db, false, port) + .await + .json_result() + .unwrap(); + + assert_eq!(result, json!("hello world")); + Ok(()) +} + +// ============================================================================ +// Python: nested module path (subdirectory) +// ============================================================================ + +#[cfg(feature = "python")] +#[sqlx::test(fixtures("base"))] +async fn test_python_script_with_nested_module(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let main_content = r#" +from .utils.math import add + +def main(a: int, b: int): + return add(a, b) +"# + .to_owned(); + + let mut modules = HashMap::new(); + modules.insert( + "utils/__init__.py".to_string(), + ScriptModule { content: "".to_string(), language: ScriptLang::Python3, lock: None }, + ); + modules.insert( + "utils/math.py".to_string(), + ScriptModule { + content: "def add(a, b):\n return a + b\n".to_string(), + language: ScriptLang::Python3, + lock: None, + }, + ); + + let job = JobPayload::Code(RawCode { + content: main_content, + path: Some("f/test/my_script".to_string()), + language: ScriptLang::Python3, + modules: Some(modules), + ..RawCode::default() + }); + + let result = RunJob::from(job) + .arg("a", json!(3)) + .arg("b", json!(4)) + .run_until_complete(&db, false, port) + .await + .json_result() + .unwrap(); + + assert_eq!(result, json!(7)); + Ok(()) +} + +// ============================================================================ +// Bun: script with inline module via relative import +// ============================================================================ + +#[sqlx::test(fixtures("base"))] +async fn test_bun_script_with_module(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let main_content = r#" +import { greet } from "./helper.ts"; + +export function main(name: string) { + return greet(name); +} +"# + .to_owned(); + + let mut modules = HashMap::new(); + modules.insert( + "helper.ts".to_string(), + ScriptModule { + content: + "export function greet(name: string): string {\n return `hello ${name}`;\n}\n" + .to_string(), + language: ScriptLang::Bun, + lock: None, + }, + ); + + let job = JobPayload::Code(RawCode { + content: main_content, + path: Some("f/test/my_script".to_string()), + language: ScriptLang::Bun, + modules: Some(modules), + ..RawCode::default() + }); + + let result = RunJob::from(job) + .arg("name", json!("world")) + .run_until_complete(&db, false, port) + .await + .json_result() + .unwrap(); + + assert_eq!(result, json!("hello world")); + Ok(()) +} diff --git a/backend/tests/volume_tests.rs b/backend/tests/volume_tests.rs index 9df30c5615..27f82dfb38 100644 --- a/backend/tests/volume_tests.rs +++ b/backend/tests/volume_tests.rs @@ -596,6 +596,7 @@ export function main() { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, }); let result = run_job_in_new_worker_until_complete(&db, false, job, port).await; diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index 32fada32fb..621ec9ceb5 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -867,6 +867,7 @@ func main(derp string) (string, error) { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("derp", json!("world")) .run_until_complete(&db, false, port) @@ -905,6 +906,7 @@ fn main(world: String) -> Result { debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), cache_ttl: None, dedicated_worker: None, + modules: None, })) .arg("world", json!("Hyrule")) .run_until_complete(&db, false, port) @@ -949,6 +951,7 @@ class Script concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("world", json!("Arakis")) .arg("b", json!(3)) @@ -985,6 +988,7 @@ echo "hello $msg" concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("msg", json!("world")) .run_until_complete(&db, false, port) @@ -1022,6 +1026,7 @@ echo "$result" concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .run_until_complete(&db, false, port) .await; @@ -1056,6 +1061,7 @@ echo "$result" concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .run_until_complete(&db, false, port) .await; @@ -1093,6 +1099,7 @@ def main [ msg: string ] { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("msg", json!("world")) .run_until_complete(&db, false, port) @@ -1147,6 +1154,7 @@ def main [ concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("a", json!("3")) .arg("b", json!("null")) @@ -1210,6 +1218,7 @@ public class Main { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("a", json!(3)) .arg("b", json!(3.0)) @@ -1247,6 +1256,7 @@ export async function main(name: string): Promise { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("name", json!("world")) .run_until_complete(&db, false, port) @@ -1284,6 +1294,7 @@ export async function main(a: number, b: number): Promise { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("a", json!(3)) .arg("b", json!(7)) @@ -1322,6 +1333,7 @@ export async function main(items: string[]): Promise<{ count: number; items: str concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("items", json!(["a", "b", "c"])) .run_until_complete(&db, false, port) @@ -1360,6 +1372,7 @@ export async function main(a: Date) { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("a", json!("2024-09-24T10:00:00.000Z")) .run_until_complete(&db, false, port) @@ -1395,6 +1408,7 @@ SELECT 'hello ' || $1::text AS result; concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("name", json!("world")) .arg( @@ -1435,6 +1449,7 @@ SELECT ? AS result; concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("name", json!("world")) .arg( @@ -1475,6 +1490,7 @@ export async function main(name: string): Promise { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("name", json!("world")) .run_until_complete(&db, false, port) @@ -1510,6 +1526,7 @@ Write-Output "hello $msg" concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("msg", json!("world")) .run_until_complete(&db, false, port) @@ -1518,6 +1535,94 @@ Write-Output "hello $msg" Ok(()) } +#[sqlx::test(fixtures("base"))] +async fn test_powershell_param_block_with_attributes(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +param( + [Parameter(Mandatory=$true)] + [string]$Name, + [int]$Count = 3 +) +Write-Output "$Name-$Count" +"# + .to_owned(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content, + path: None, + lock: None, + language: ScriptLang::Powershell, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + })) + .arg("Name", json!("test")) + .arg("Count", json!(7)) + .run_until_complete(&db, false, port) + .await; + assert_eq!(job.json_result(), Some(json!("test-7"))); + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn test_powershell_error_caught(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Script with param block that throws an error — verifies the catch block works + let content = r#" +param($x) +throw "intentional error" +"# + .to_owned(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content, + path: None, + lock: None, + language: ScriptLang::Powershell, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + })) + .arg("x", json!(1)) + .run_until_complete(&db, false, port) + .await; + assert!(!job.success, "job should fail on thrown error"); + let result_str = serde_json::to_string(&job.result).unwrap_or_default(); + assert!( + result_str.contains("An error occurred:"), + "catch block should output 'An error occurred:', got: {result_str}" + ); + assert!( + result_str.contains("intentional error"), + "catch block should output the error message, got: {result_str}" + ); + // Verify the catch block doesn't leak "Write-Output" as literal text + // (regression from the old broken line continuation in strict_termination_end) + let after_marker = result_str.split("An error occurred:").nth(1).unwrap_or(""); + assert!( + !after_marker.starts_with("\\nWrite-Output"), + "catch block should not output literal 'Write-Output' text, got: {result_str}" + ); + Ok(()) +} + #[cfg(feature = "php")] #[sqlx::test(fixtures("base"))] async fn test_php_job(db: Pool) -> anyhow::Result<()> { @@ -1546,6 +1651,7 @@ function main(string $name): string { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("name", json!("world")) .run_until_complete(&db, false, port) @@ -1583,6 +1689,7 @@ end concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("name", json!("world")) .run_until_complete(&db, false, port) @@ -1619,6 +1726,7 @@ export async function main(a: Date) { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("a", json!("2024-09-24T10:00:00.000Z")) .run_until_complete(&db, false, port) @@ -1655,6 +1763,7 @@ export async function main(a: Date) { concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("a", json!("2024-09-24T10:00:00.000Z")) .run_until_complete(&db, false, port) @@ -1707,6 +1816,7 @@ export function main(name: string) { cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: None, + modules: None, })) .arg("name", json!("World")) .run_until_complete(&db, false, port) @@ -1752,6 +1862,7 @@ def main(a: datetime, b: bytes): concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() .into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .arg("a", json!("2024-09-24T10:00:00.000Z")) .arg("b", json!("dGVzdA==")) diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index af212dc315..88c3b0cf0b 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -26,7 +26,9 @@ use tokio::sync::RwLock; use windmill_common::DB; use windmill_common::{ - auth::{get_folders_for_user, get_groups_for_user, JWTAuthClaims, TOKEN_PREFIX_LEN}, + auth::{ + get_folders_for_user, get_groups_for_user, hash_token, safe_token_prefix, JWTAuthClaims, + }, error::{Error, JsonResult}, jwt, users::{COOKIE_NAME, SUPERADMIN_SECRET_EMAIL}, @@ -42,13 +44,14 @@ lazy_static::lazy_static! { /// Get email from a valid token, with caching. /// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member. async fn get_email_from_token(db: &DB, token: &str) -> Option { - if let Some(cached) = TOKEN_EMAIL_CACHE.get(token) { + let t_hash = hash_token(token); + if let Some(cached) = TOKEN_EMAIL_CACHE.get(&t_hash) { return cached; } let email = sqlx::query_scalar!( - "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)", - token + "SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + t_hash ) .fetch_optional(db) .await @@ -56,7 +59,7 @@ async fn get_email_from_token(db: &DB, token: &str) -> Option { .flatten() .flatten(); // email column is nullable, so we get Option> - TOKEN_EMAIL_CACHE.insert(token.to_string(), email.clone()); + TOKEN_EMAIL_CACHE.insert(t_hash, email.clone()); email } @@ -75,13 +78,15 @@ pub async fn get_end_user_email( } None } -// Global function to invalidate a specific token from cache -pub fn invalidate_token_from_cache(token: &str) { - // Remove all cache entries for this token (across all workspaces) - AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| cached_token != token); +// Global function to invalidate tokens from cache by prefix +pub fn invalidate_token_from_cache(token_prefix: &str) { + // Remove all cache entries whose raw token starts with this prefix (across all workspaces) + AUTH_CACHE.retain(|(_workspace_id, cached_token), _cached_value| { + !cached_token.starts_with(token_prefix) + }); tracing::info!( - "Invalidated token from auth cache: {}...", - &token[..token.len().min(8)] + "Invalidated token(s) from auth cache with prefix: {}...", + &token_prefix[..token_prefix.len().min(8)] ); } @@ -211,13 +216,14 @@ impl AuthCache { } } _ => { + let t_hash = hash_token(token); let user_o = sqlx::query!( "UPDATE token SET last_used_at = now() WHERE - token = $1 + token_hash = $1 AND (expiration > NOW() OR expiration IS NULL) AND (workspace_id IS NULL OR workspace_id = $2) RETURNING owner, email, super_admin, scopes, label", - token, + t_hash, w_id.as_ref(), ) .map(|x| (x.owner, x.email, x.super_admin, x.scopes, x.label)) @@ -275,7 +281,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }) } else { @@ -299,7 +305,7 @@ impl AuthCache { scopes: None, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }) } @@ -315,7 +321,7 @@ impl AuthCache { folders, scopes: None, username_override, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(token)), }) } } @@ -364,7 +370,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }) } @@ -378,7 +384,7 @@ impl AuthCache { scopes, username_override, token_prefix: Some( - token[0..TOKEN_PREFIX_LEN].to_string(), + safe_token_prefix(token), ), }), None => None, @@ -393,7 +399,7 @@ impl AuthCache { folders: Vec::new(), scopes, username_override, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(token)), }) } } @@ -427,7 +433,7 @@ impl AuthCache { folders: Vec::new(), scopes: None, username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(token)), }; Some(OptJobAuthed { authed, job_id: None }) } else { @@ -717,7 +723,7 @@ fn username_override_from_label(label: Option) -> Option { #[derive(FromRow, Serialize)] pub struct TruncatedTokenWithEmail { pub label: Option, - pub token_prefix: Option, + pub token_prefix: String, pub expiration: Option>, pub created_at: chrono::DateTime, pub last_used_at: chrono::DateTime, @@ -736,7 +742,7 @@ pub async fn list_tokens_internal( TruncatedTokenWithEmail, r#" SELECT label, - concat(substring(token for 10)) AS token_prefix, + token_prefix, expiration, created_at, last_used_at, @@ -759,7 +765,7 @@ pub async fn list_tokens_internal( TruncatedTokenWithEmail, r#" SELECT label, - concat(substring(token for 10)) AS token_prefix, + token_prefix, expiration, created_at, last_used_at, diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 557ce7f706..aceef77e01 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -18,7 +18,10 @@ use http::request::Parts; use windmill_audit::audit_oss::AuditAuthorable; use windmill_common::{ - auth::{fetch_authed_from_permissioned_as, is_devops_email, is_super_admin_email}, + auth::{ + fetch_authed_from_permissioned_as, hash_token, is_devops_email, is_super_admin_email, + TOKEN_PREFIX_LEN, + }, db::{Authable, Authed, AuthedRef}, error::{self, Error, Result}, users::username_to_permissioned_as, @@ -511,9 +514,20 @@ pub async fn create_token_internal( ) -> Result { use tracing::Instrument; use windmill_audit::{audit_oss::audit_log, ActionKind}; - use windmill_common::{utils::rd_string, worker::CLOUD_HOSTED}; + use windmill_common::{ + min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH, utils::rd_string, worker::CLOUD_HOSTED, + }; let token = rd_string(32); + let t_hash = hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); + + // Write plaintext token column until all workers support hash-based lookup + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&token) + }; let is_super_admin = sqlx::query_scalar!( "SELECT super_admin FROM password WHERE email = $1", @@ -536,12 +550,14 @@ pub async fn create_token_internal( } let rows = sqlx::query!( "INSERT INTO token - (token, email, label, expiration, super_admin, scopes, workspace_id) - SELECT $1, $2, $3, $4, $5, $6, $7 - WHERE $7::varchar IS NULL OR NOT EXISTS( - SELECT 1 FROM workspace WHERE id = $7 AND deleted = true + (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9 + WHERE $9::varchar IS NULL OR NOT EXISTS( + SELECT 1 FROM workspace WHERE id = $9 AND deleted = true )", - token, + t_hash, + t_prefix, + plaintext as Option<&str>, authed.email, token_config.label, token_config.expiration, @@ -559,7 +575,7 @@ pub async fn create_token_internal( register_token_expiry_notification( &mut *tx, - &token, + &t_hash, token_config.label.as_deref(), token_config.expiration, ) @@ -571,7 +587,7 @@ pub async fn create_token_internal( "users.token.create", ActionKind::Create, &"global", - Some(&token[0..10]), + Some(t_prefix), None, ) .instrument(tracing::info_span!("token", email = &authed.email)) @@ -581,12 +597,14 @@ pub async fn create_token_internal( } /// Insert a pending expiry notification row for user tokens that have an expiration. +/// Stores the token_hash so the join in check_expiring_tokens works even when +/// the plaintext token column is NULL (after hash migration). /// When updating this filter, also update: /// - `is_user_token` in src/monitor.rs /// - `isUserToken` in frontend/src/lib/components/settings/TokensTable.svelte pub async fn register_token_expiry_notification( tx: &mut sqlx::PgConnection, - token: &str, + token_hash: &str, label: Option<&str>, expiration: Option>, ) { @@ -602,8 +620,8 @@ pub async fn register_token_expiry_notification( return; } if let Err(e) = sqlx::query!( - "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", - token, + "INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", + token_hash, expiration, ) .execute(&mut *tx) diff --git a/backend/windmill-api-client/src/lib.rs b/backend/windmill-api-client/src/lib.rs index 4e37958b7e..f2170b3c2f 100644 --- a/backend/windmill-api-client/src/lib.rs +++ b/backend/windmill-api-client/src/lib.rs @@ -17,10 +17,7 @@ pub struct Client { impl Client { /// Create a new client with an existing reqwest::Client pub fn new_with_client(baseurl: &str, client: reqwest::Client) -> Self { - Self { - baseurl: baseurl.to_string(), - client, - } + Self { baseurl: baseurl.to_string(), client } } /// Get the base URL @@ -49,7 +46,10 @@ impl Client { if response.status().is_success() { Ok(response.text().await?) } else { - Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default())) + Err(Error::UnexpectedResponse( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )) } } @@ -69,7 +69,10 @@ impl Client { if response.status().is_success() { Ok(response.text().await?) } else { - Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default())) + Err(Error::UnexpectedResponse( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )) } } @@ -97,7 +100,10 @@ impl Client { if response.status().is_success() { Ok(response.json().await?) } else { - Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default())) + Err(Error::UnexpectedResponse( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )) } } @@ -117,7 +123,10 @@ impl Client { if response.status().is_success() { Ok(response.text().await?) } else { - Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default())) + Err(Error::UnexpectedResponse( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )) } } @@ -139,7 +148,10 @@ impl Client { if response.status().is_success() { Ok(response.text().await?) } else { - Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default())) + Err(Error::UnexpectedResponse( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )) } } @@ -151,7 +163,10 @@ impl Client { if response.status().is_success() { Ok(response.json().await?) } else { - Err(Error::UnexpectedResponse(response.status().as_u16(), response.text().await.unwrap_or_default())) + Err(Error::UnexpectedResponse( + response.status().as_u16(), + response.text().await.unwrap_or_default(), + )) } } } @@ -367,7 +382,7 @@ pub mod types { #[serde(default, skip_serializing_if = "Option::is_none")] pub lock: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub no_main_func: Option, + pub auto_kind: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub on_behalf_of_email: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -388,6 +403,8 @@ pub mod types { pub visible_to_runner_only: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub ws_error_handler_muted: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modules: Option>, } /// Script arguments (used in schedules) @@ -555,12 +572,12 @@ pub mod types { Static { #[serde(rename = "type")] type_: String, - value: serde_json::Value + value: serde_json::Value, }, Javascript { #[serde(rename = "type")] type_: String, - expr: String + expr: String, }, } diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs index f4e91e554f..a05f2dbaa8 100644 --- a/backend/windmill-api-configs/src/lib.rs +++ b/backend/windmill-api-configs/src/lib.rs @@ -129,11 +129,12 @@ async fn update_config( #[cfg(not(feature = "enterprise"))] let config = if name.starts_with("worker__") { - // In CE, only allow setting worker_tags, cache_clear, and init_bash + // In CE, only allow setting worker_tags, cache_clear, init_bash, and native_mode serde_json::json!({ "worker_tags": config.get("worker_tags"), "cache_clear": config.get("cache_clear"), - "init_bash": config.get("init_bash") + "init_bash": config.get("init_bash"), + "native_mode": config.get("native_mode") }) } else { config diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index a68b72072d..4b07ae7a4d 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -328,9 +328,133 @@ async fn create_igroup( Ok(format!("Created group {}", normalized_name)) } +fn validate_instance_role(role: &Option) -> Result> { + match role.as_deref() { + None => Ok(None), + Some("") | Some("user") => Ok(None), + Some("devops") => Ok(Some("devops".to_string())), + Some("superadmin") => Ok(Some("superadmin".to_string())), + Some(other) => Err(Error::BadRequest(format!( + "Invalid instance_role '{}'. Must be 'devops', 'superadmin', 'user', or empty to clear", + other + ))), + } +} + +/// Compute the highest-precedence instance role from all groups a user belongs to. +/// superadmin > devops > none +pub async fn compute_effective_instance_role( + email: &str, + tx: &mut Transaction<'_, Postgres>, +) -> Result> { + let roles = sqlx::query_scalar!( + "SELECT ig.instance_role FROM email_to_igroup eig + JOIN instance_group ig ON ig.name = eig.igroup + WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + email + ) + .fetch_all(&mut **tx) + .await?; + + let mut highest: Option = None; + for role in roles.into_iter().flatten() { + match role.as_str() { + "superadmin" => return Ok(Some("superadmin".to_string())), + "devops" => highest = Some("devops".to_string()), + _ => {} + } + } + Ok(highest) +} + +/// Apply computed instance role to password table and invalidate session tokens. +/// Only applies if role_source = 'instance_group' or user has no elevated role. +pub async fn apply_instance_role( + email: &str, + role: Option<&str>, + tx: &mut Transaction<'_, Postgres>, +) -> Result<()> { + let current = sqlx::query!( + "SELECT super_admin, devops, role_source FROM password WHERE email = $1", + email + ) + .fetch_optional(&mut **tx) + .await?; + + let current = match current { + Some(c) => c, + None => return Ok(()), // user doesn't exist in password table + }; + + // Don't touch manually-set elevated roles — manual always wins + if current.role_source == "manual" && (current.super_admin || current.devops) { + return Ok(()); + } + + let (new_super_admin, new_devops) = match role { + Some("superadmin") => (true, false), + Some("devops") => (false, true), + _ => (false, false), + }; + + // Only update if something actually changed + if current.super_admin == new_super_admin && current.devops == new_devops { + return Ok(()); + } + + sqlx::query!( + "UPDATE password SET super_admin = $1, devops = $2, role_source = 'instance_group' WHERE email = $3", + new_super_admin, + new_devops, + email + ) + .execute(&mut **tx) + .await?; + + // Invalidate session tokens to force re-login with new privileges + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + email + ) + .execute(&mut **tx) + .await?; + + // Update super_admin flag on non-session tokens + sqlx::query!( + "UPDATE token SET super_admin = $1 WHERE email = $2 AND label != 'session'", + new_super_admin, + email + ) + .execute(&mut **tx) + .await?; + + Ok(()) +} + +/// Recompute and apply roles for all members of a given instance group. +pub async fn propagate_instance_group_roles( + group_name: &str, + tx: &mut Transaction<'_, Postgres>, +) -> Result<()> { + let members = sqlx::query_scalar!( + "SELECT email FROM email_to_igroup WHERE igroup = $1", + group_name + ) + .fetch_all(&mut **tx) + .await?; + + for email in members { + let effective_role = compute_effective_instance_role(&email, tx).await?; + apply_instance_role(&email, effective_role.as_deref(), tx).await?; + } + + Ok(()) +} + #[derive(Deserialize)] struct IGroupUpdate { new_summary: String, + instance_role: Option, } async fn update_igroup( @@ -348,17 +472,37 @@ async fn update_igroup( .await?; not_found_if_none(exists_opt, "instance_group", name.clone())?; - sqlx::query("UPDATE instance_group SET summary = $1 WHERE name = $2") + let validated_role = validate_instance_role(&igroup_update.instance_role)?; + + // Fetch old role before updating so we can detect changes + let old_role = if igroup_update.instance_role.is_some() { + sqlx::query_scalar!( + "SELECT instance_role FROM instance_group WHERE name = $1", + &name + ) + .fetch_one(&mut *tx) + .await? + } else { + None + }; + + sqlx::query("UPDATE instance_group SET summary = $1, instance_role = $2 WHERE name = $3") .bind(igroup_update.new_summary) + .bind(&validated_role) .bind(&name) .execute(&mut *tx) .await?; + // If instance_role actually changed, propagate to all group members + if igroup_update.instance_role.is_some() && old_role != validated_role { + propagate_instance_group_roles(&name, &mut tx).await?; + } + audit_log( &mut *tx, &authed, "igroup.updated", - ActionKind::Delete, + ActionKind::Update, "global", Some(&name.to_string()), None, @@ -366,7 +510,7 @@ async fn update_igroup( .await?; tx.commit().await?; - Ok(format!("Deleted group {}", name)) + Ok(format!("Updated group {}", name)) } async fn delete_igroup( @@ -376,14 +520,38 @@ async fn delete_igroup( ) -> Result { require_super_admin(&db, &authed.email).await?; let mut tx: Transaction<'_, Postgres> = db.begin().await?; - sqlx::query!("DELETE FROM instance_group WHERE name = $1", name) - .execute(&mut *tx) - .await?; + + // Fetch group's instance_role and members before deletion + let group_role = sqlx::query_scalar!( + "SELECT instance_role FROM instance_group WHERE name = $1", + &name + ) + .fetch_optional(&mut *tx) + .await? + .flatten(); + + let affected_members: Vec = if group_role.is_some() { + sqlx::query_scalar!("SELECT email FROM email_to_igroup WHERE igroup = $1", &name) + .fetch_all(&mut *tx) + .await? + } else { + vec![] + }; sqlx::query!("DELETE FROM email_to_igroup WHERE igroup = $1", name) .execute(&mut *tx) .await?; + sqlx::query!("DELETE FROM instance_group WHERE name = $1", name) + .execute(&mut *tx) + .await?; + + // Recompute roles for affected members after deletion + for email in &affected_members { + let effective_role = compute_effective_instance_role(email, &mut tx).await?; + apply_instance_role(email, effective_role.as_deref(), &mut tx).await?; + } + audit_log( &mut *tx, &authed, @@ -723,6 +891,10 @@ async fn add_user_igroup( } } + // Apply instance-level role from group membership + let effective_role = compute_effective_instance_role(&email, &mut tx).await?; + apply_instance_role(&email, effective_role.as_deref(), &mut tx).await?; + tx.commit().await?; Ok(format!("Added {} to igroup {}", email, name)) } @@ -732,6 +904,7 @@ struct IGroup { name: String, summary: Option, emails: Option>, + instance_role: Option, } #[derive(Serialize)] @@ -739,6 +912,7 @@ struct IGroupWithWorkspaces { name: String, summary: Option, emails: Option>, + instance_role: Option, workspaces: Vec, } @@ -753,7 +927,7 @@ async fn list_igroups(Extension(db): Extension) -> JsonResult> { let groups = sqlx::query_as!( IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name" + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, instance_role" ) .fetch_all(&mut *tx) .await?; @@ -770,7 +944,7 @@ async fn list_igroups_with_workspaces( // Get all instance groups with their emails first let groups = sqlx::query_as!( IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary" + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name, summary, instance_role" ) .fetch_all(&mut *tx) .await?; @@ -825,6 +999,7 @@ async fn list_igroups_with_workspaces( name: group.name, summary: group.summary, emails: group.emails, + instance_role: group.instance_role, workspaces, }); } @@ -833,16 +1008,54 @@ async fn list_igroups_with_workspaces( return Ok(Json(result)); } -async fn get_igroup(Path(name): Path, Extension(db): Extension) -> JsonResult { +async fn get_igroup( + Path(name): Path, + Extension(db): Extension, +) -> JsonResult { let group = sqlx::query_as!( IGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name", + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup WHERE name = $1 GROUP BY name, instance_role", name ) .fetch_optional(&db) .await?; let group = not_found_if_none(group, "IGroup", &name)?; - return Ok(Json(group)); + + let workspace_mappings = sqlx::query!( + r#" + SELECT + ws.workspace_id, + w.name as workspace_name, + ws.auto_invite->'instance_groups_roles'->$1 as role + FROM workspace_settings ws + INNER JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false + WHERE ws.auto_invite->'instance_groups' ? $1 + ORDER BY ws.workspace_id + "#, + &name + ) + .fetch_all(&db) + .await?; + + let workspaces: Vec = workspace_mappings + .into_iter() + .map(|m| WorkspaceInfo { + workspace_id: m.workspace_id, + workspace_name: m.workspace_name, + role: m + .role + .and_then(|r| r.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| "developer".to_string()), + }) + .collect(); + + return Ok(Json(IGroupWithWorkspaces { + name: group.name, + summary: group.summary, + emails: group.emails, + instance_role: group.instance_role, + workspaces, + })); } async fn remove_user_igroup( @@ -886,6 +1099,10 @@ async fn remove_user_igroup( remove_users_from_instance_group_workspaces(&email, &name, &mut tx).await?; } + // Recompute instance-level role after group removal + let effective_role = compute_effective_instance_role(&email, &mut tx).await?; + apply_instance_role(&email, effective_role.as_deref(), &mut tx).await?; + tx.commit().await?; Ok(format!("Removed {} from igroup {}", email, name)) } @@ -967,6 +1184,8 @@ struct ExportedIGroup { external_id: Option, #[serde(skip_serializing_if = "Option::is_none")] emails: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + instance_role: Option, } #[cfg(feature = "enterprise")] @@ -978,7 +1197,7 @@ async fn export_igroups( let mut tx = db.begin().await?; let igroups = sqlx::query_as!( ExportedIGroup, - "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", + "SELECT name, summary, array_remove(array_agg(email_to_igroup.email), null) as emails, id, scim_display_name, external_id, instance_role FROM email_to_igroup RIGHT JOIN instance_group ON instance_group.name = email_to_igroup.igroup GROUP BY name", ).fetch_all(&mut *tx).await?; audit_log( @@ -1022,13 +1241,15 @@ async fn overwrite_igroups( .await?; for igroup in igroups.iter() { + let validated_role = validate_instance_role(&igroup.instance_role)?; sqlx::query!( - "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id) VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO instance_group (name, summary, id, scim_display_name, external_id, instance_role) VALUES ($1, $2, $3, $4, $5, $6)", igroup.name, igroup.summary, igroup.id, igroup.scim_display_name, igroup.external_id, + validated_role, ) .execute(&mut *tx) .await?; @@ -1046,6 +1267,31 @@ async fn overwrite_igroups( } } + // Propagate instance roles for all groups that have one + for igroup in igroups.iter() { + if igroup.instance_role.is_some() { + propagate_instance_group_roles(&igroup.name, &mut tx).await?; + } + } + + // Demote orphaned users: those whose role was set by a group that no longer + // grants them any instance_role after the import + let orphaned_users = sqlx::query_scalar!( + "SELECT email FROM password + WHERE role_source = 'instance_group' AND (super_admin = true OR devops = true) + AND email NOT IN ( + SELECT eig.email FROM email_to_igroup eig + JOIN instance_group ig ON ig.name = eig.igroup + WHERE ig.instance_role IS NOT NULL + )" + ) + .fetch_all(&mut *tx) + .await?; + + for email in &orphaned_users { + apply_instance_role(email, None, &mut tx).await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api-integration-tests/Cargo.toml b/backend/windmill-api-integration-tests/Cargo.toml index c7d608ed28..35dd64f995 100644 --- a/backend/windmill-api-integration-tests/Cargo.toml +++ b/backend/windmill-api-integration-tests/Cargo.toml @@ -10,8 +10,8 @@ path = "src/lib.rs" [features] default = [] -private = ["windmill-test-utils/private", "dep:aws-config", "dep:aws-credential-types", "dep:aws-sdk-sqs"] -enterprise = ["windmill-test-utils/enterprise", "dep:base64"] +private = ["windmill-test-utils/private", "dep:aws-config", "dep:aws-credential-types", "dep:aws-sdk-sqs", "windmill-git-sync/private"] +enterprise = ["windmill-test-utils/enterprise", "dep:base64", "windmill-git-sync/enterprise"] deno_core = ["windmill-test-utils/deno_core"] mcp = [] run_inline = ["dep:windmill-worker", "windmill-test-utils/run_inline", "windmill-test-utils/duckdb"] @@ -22,6 +22,7 @@ windmill-api-client.workspace = true windmill-common = { workspace = true, default-features = false } windmill-native-triggers = { workspace = true, features = ["native_trigger"] } windmill-api-auth.workspace = true +windmill-git-sync.workspace = true windmill-worker = { workspace = true, optional = true } sqlx.workspace = true serde_json.workspace = true diff --git a/backend/windmill-api-integration-tests/tests/fixtures/base.sql b/backend/windmill-api-integration-tests/tests/fixtures/base.sql index 7db9918fba..ce5df2640e 100644 --- a/backend/windmill-api-integration-tests/tests/fixtures/base.sql +++ b/backend/windmill-api-integration-tests/tests/fixtures/base.sql @@ -33,9 +33,11 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +-- NOTE: plaintext `token` column is included for backward compat during transition. +-- Remove it once the `token` column is dropped from the schema. +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql b/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql index a9006f5233..66f406c08c 100644 --- a/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql +++ b/backend/windmill-api-integration-tests/tests/fixtures/permissions_test.sql @@ -39,13 +39,13 @@ ON CONFLICT (email) DO NOTHING; -- Tokens associated with emails (workspace-scoped) -- The auth system will look up the user by email in the usr table -- Note: tokens must be at least 10 characters (TOKEN_PREFIX_LEN) -INSERT INTO token (token, email, label, super_admin, owner, workspace_id) +INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, owner, workspace_id) VALUES - ('ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), - ('ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), - ('BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), - ('CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), - ('OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); + (encode(sha256('ADMIN_TOKEN_TEST'::bytea), 'hex'), 'ADMIN_TOKE', 'ADMIN_TOKEN_TEST', 'admin@windmill.dev', 'Admin token', false, 'u/admin', 'test-workspace'), + (encode(sha256('ALICE_TOKEN_TEST'::bytea), 'hex'), 'ALICE_TOKE', 'ALICE_TOKEN_TEST', 'alice@windmill.dev', 'Alice token', false, 'u/alice', 'test-workspace'), + (encode(sha256('BOB_TOKEN_TEST12'::bytea), 'hex'), 'BOB_TOKEN_', 'BOB_TOKEN_TEST12', 'bob@windmill.dev', 'Bob token', false, 'u/bob', 'test-workspace'), + (encode(sha256('CHARLIE_TOKEN_01'::bytea), 'hex'), 'CHARLIE_TO', 'CHARLIE_TOKEN_01', 'charlie@windmill.dev', 'Charlie token', false, 'u/charlie', 'test-workspace'), + (encode(sha256('OPERATOR_TOKEN_1'::bytea), 'hex'), 'OPERATOR_T', 'OPERATOR_TOKEN_1', 'operator@windmill.dev', 'Operator token', false, 'u/operator', 'test-workspace'); -- ============================================ -- GROUPS diff --git a/backend/windmill-api-integration-tests/tests/permissions.rs b/backend/windmill-api-integration-tests/tests/permissions.rs index 4342c61235..c333cf924b 100644 --- a/backend/windmill-api-integration-tests/tests/permissions.rs +++ b/backend/windmill-api-integration-tests/tests/permissions.rs @@ -517,9 +517,14 @@ async fn test_group_permission_inheritance(db: Pool) -> anyhow::Result // for (workspace_id, token) tuples. Since we can't easily clear it from tests, // we use a different token or wait for cache expiry. For this test, we create // a new token for Charlie. + let charlie_token = "CHARLIE_TOKEN_NEW"; + let charlie_token_hash = windmill_common::utils::calculate_hash(charlie_token); + let charlie_token_prefix = &charlie_token[..10.min(charlie_token.len())]; sqlx::query!( - "INSERT INTO token (token, email, label, super_admin, owner, workspace_id) - VALUES ('CHARLIE_TOKEN_NEW', 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')" + "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id) + VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')", + charlie_token_hash, + charlie_token_prefix, ) .execute(&db) .await?; @@ -563,28 +568,100 @@ async fn test_all_item_types_permissions(db: Pool) -> anyhow::Result<( let bob_client = create_client_for_user(port, "BOB_TOKEN_TEST12").await; // Test Scripts - uses /scripts/get/p/{path} - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/scripts/get/p/u/alice/my_script") + ) + .await + ); // Test Flows - uses /flows/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/flows/get/u/alice/my_flow") + ) + .await + ); // Test Resources - uses /resources/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/resources/get/u/alice/my_resource") + ) + .await + ); // Test Variables - uses /variables/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/variables/get/u/alice/my_variable") + ) + .await + ); // Test Schedules - uses /schedules/get/{path} (no /p/) - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/schedules/get/u/alice/my_schedule") + ) + .await + ); // Test Apps - uses /apps/get/p/{path} - assert!(can_read(&alice_client, &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app")).await); - assert!(!can_read(&bob_client, &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app")).await); + assert!( + can_read( + &alice_client, + &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app") + ) + .await + ); + assert!( + !can_read( + &bob_client, + &format!("{base_url}/w/test-workspace/apps/get/p/u/alice/my_app") + ) + .await + ); Ok(()) } @@ -747,11 +824,9 @@ async fn test_operator_cannot_create_update(db: Pool) -> anyhow::Resul .await?; // Update app versions - sqlx::query!( - "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001" - ) - .execute(&db) - .await?; + sqlx::query!("UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001") + .execute(&db) + .await?; let update_app = json!({ "path": "u/operator/existing_app", diff --git a/backend/windmill-api-integration-tests/tests/token_hash.rs b/backend/windmill-api-integration-tests/tests/token_hash.rs new file mode 100644 index 0000000000..aa7dd22286 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/token_hash.rs @@ -0,0 +1,399 @@ +//! Tests for the token hash migration. +//! +//! Verifies that: +//! - Rust hash_token() matches PostgreSQL's encode(sha256(...),'hex') +//! - Newly created tokens can authenticate immediately +//! - Token list/delete-by-prefix works with the new token_prefix column +//! - Logout invalidates tokens via hash-based deletion +//! - Backward compat: plaintext column is populated when old workers exist +//! - rotate_webhook_token produces valid tokens and defers old token deletion + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +fn authed_with(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {}", token)) +} + +/// Test 1: Verify that Rust's hash_token() produces the same hash as PostgreSQL's +/// encode(sha256(token::bytea), 'hex'). This is the foundational invariant. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_hash_consistency(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + // Compute hash in Rust + let rust_hash = hash_token("SECRET_TOKEN"); + + // Compute hash in PostgreSQL + let pg_hash: String = + sqlx::query_scalar!("SELECT encode(sha256('SECRET_TOKEN'::bytea), 'hex') AS hash") + .fetch_one(&db) + .await? + .unwrap(); + + assert_eq!( + rust_hash, pg_hash, + "Rust hash_token() must match PostgreSQL sha256()" + ); + + // Also verify it matches what's stored in the fixture + let stored_hash: String = sqlx::query_scalar!( + "SELECT token_hash FROM token WHERE email = 'test@windmill.dev' AND label = 'test token'" + ) + .fetch_one(&db) + .await?; + + assert_eq!( + rust_hash, stored_hash, + "hash_token() must match the fixture's pre-computed hash" + ); + + Ok(()) +} + +/// Test 2: Create a token via API, then immediately use it to authenticate. +/// Verifies create_token_internal stores the hash correctly and auth lookups work. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_create_token_and_auth(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + + // Create a new token + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "test-hash-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let new_token = resp.text().await?; + assert!(!new_token.is_empty()); + + // Use the new token to call whoami + let resp = authed_with(client().get(format!("{base}/whoami")), &new_token) + .send() + .await?; + assert_eq!(resp.status(), 200, "newly created token must authenticate"); + let body = resp.json::().await?; + assert_eq!(body["email"], "test@windmill.dev"); + + // Verify the hash is stored correctly in DB + let expected_hash = hash_token(&new_token); + let db_hash: Option = sqlx::query_scalar!( + "SELECT token_hash FROM token WHERE token_hash = $1", + expected_hash + ) + .fetch_optional(&db) + .await?; + assert!(db_hash.is_some(), "token_hash must be stored in DB"); + + Ok(()) +} + +/// Test 3: Create a token, list tokens (verify prefix), delete by prefix, confirm invalid. +/// Covers the change from WHERE token LIKE to WHERE token_prefix = $1. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_token_list_and_delete_by_prefix(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + + // Create a token + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "prefix-test-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let new_token = resp.text().await?; + let prefix = &new_token[..TOKEN_PREFIX_LEN]; + + // List tokens and find our token by prefix + let resp = authed(client().get(format!("{base}/tokens/list"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let tokens = resp.json::>().await?; + let found = tokens + .iter() + .any(|t| t["token_prefix"].as_str() == Some(prefix)); + assert!(found, "token with prefix {prefix} must appear in list"); + + // Verify the new token works + let resp = authed_with(client().get(format!("{base}/whoami")), &new_token) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // Delete by prefix + let resp = authed(client().delete(format!("{base}/tokens/delete/{prefix}"))) + .send() + .await?; + assert_eq!(resp.status(), 200, "delete token: {}", resp.text().await?); + + // Confirm the token is gone from the DB (auth cache may still serve 200 briefly) + let token_hash = hash_token(&new_token); + let deleted: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + token_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!( + !deleted, + "token must be deleted from DB after delete-by-prefix" + ); + + Ok(()) +} + +/// Test 4: Logout invalidates a token via hash-based deletion. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_logout_invalidates_token(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + let auth_base = format!("http://localhost:{port}/api/auth"); + + // Create a fresh token (don't burn the fixture token) + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "logout-test-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let token = resp.text().await?; + + // Verify it works + let resp = authed_with(client().get(format!("{base}/whoami")), &token) + .send() + .await?; + assert_eq!(resp.status(), 200); + + // Logout with the token + let resp = authed_with(client().post(format!("{auth_base}/logout")), &token) + .send() + .await?; + assert!( + resp.status() == 200 || resp.status() == 303, + "logout: unexpected status {}", + resp.status() + ); + + // Confirm the token is gone from the DB (auth cache may still serve 200 briefly) + let token_hash = hash_token(&token); + let exists: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + token_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!exists, "token must be deleted from DB after logout"); + + Ok(()) +} + +/// Test 5: Backward compatibility — plaintext column behavior based on MIN_VERSION. +/// When old workers exist (MIN_VERSION < 1.650.0), plaintext must be written so +/// old workers running WHERE token = $1 can still authenticate. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_plaintext_backward_compat(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + use windmill_common::min_version::{MIN_VERSION, MIN_VERSION_SUPPORTS_TOKEN_HASH}; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/users"); + + // --- Phase 1: Simulate old workers present (version < 1.650.0) --- + // Set MIN_VERSION to one minor below the token hash feature version + let mut old_version = MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone(); + old_version.minor -= 1; + *MIN_VERSION.write().await = old_version; + + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "old-worker-compat-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let old_compat_token = resp.text().await?; + let old_compat_hash = hash_token(&old_compat_token); + + // Plaintext should be stored (for old workers) + let plaintext: Option = sqlx::query_scalar!( + "SELECT token FROM token WHERE token_hash = $1", + old_compat_hash + ) + .fetch_one(&db) + .await?; + assert!( + plaintext.is_some(), + "plaintext must be stored when old workers exist" + ); + assert_eq!(plaintext.unwrap(), old_compat_token); + + // Old-style query (what old workers run) must find the token + let old_style_email: Option = sqlx::query_scalar!( + "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)", + &old_compat_token + ) + .fetch_optional(&db) + .await? + .flatten(); + assert_eq!( + old_style_email.as_deref(), + Some("test@windmill.dev"), + "old-style WHERE token = $1 must find the token" + ); + + // New-style query must also work + let new_style_email: Option = sqlx::query_scalar!( + "SELECT email FROM token WHERE token_hash = $1 AND (expiration > NOW() OR expiration IS NULL)", + old_compat_hash + ) + .fetch_optional(&db) + .await? + .flatten(); + assert_eq!( + new_style_email.as_deref(), + Some("test@windmill.dev"), + "new-style WHERE token_hash = $1 must also work" + ); + + // --- Phase 2: All workers upgraded (version >= 1.650.0) --- + *MIN_VERSION.write().await = MIN_VERSION_SUPPORTS_TOKEN_HASH.version().clone(); + + let resp = authed(client().post(format!("{base}/tokens/create"))) + .json(&json!({"label": "new-worker-token"})) + .send() + .await?; + assert_eq!(resp.status(), 201); + let new_token = resp.text().await?; + let new_hash = hash_token(&new_token); + + // Plaintext should NOT be stored + let plaintext: Option = + sqlx::query_scalar!("SELECT token FROM token WHERE token_hash = $1", new_hash) + .fetch_one(&db) + .await?; + assert!( + plaintext.is_none(), + "plaintext must be NULL when all workers support hash" + ); + + // Old-style query should NOT find this token + let old_style_result: Option = + sqlx::query_scalar!("SELECT email FROM token WHERE token = $1", &new_token) + .fetch_optional(&db) + .await? + .flatten(); + assert!( + old_style_result.is_none(), + "old-style query must not find token when plaintext is NULL" + ); + + // New-style query must still work + let resp = authed_with(client().get(format!("{base}/whoami")), &new_token) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "new token must authenticate via hash lookup" + ); + + Ok(()) +} + +/// Test 6: rotate_webhook_token creates a new token and keeps the old one alive. +/// Callers delete the old token after successfully updating the trigger. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_rotate_webhook_token(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + use windmill_native_triggers::{delete_token_by_hash, rotate_webhook_token}; + + // Insert a token directly with known values + let original_token = "test-webhook-token-original-1234"; + let original_hash = hash_token(original_token); + let original_prefix = &original_token[..TOKEN_PREFIX_LEN]; + + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin) + VALUES ($1, $2, $3, 'test@windmill.dev', 'webhook-test', false)", + original_hash, + original_prefix, + original_token, + ) + .execute(&db) + .await?; + + // Rotate the token + let rotated = rotate_webhook_token(&db, &original_hash) + .await? + .expect("rotate must return Some for existing token"); + + // New token should be different + assert_ne!(rotated.new_token, original_token); + assert_eq!(rotated.old_token_hash, original_hash); + + // New token's hash should exist in DB + let new_hash = hash_token(&rotated.new_token); + let exists: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + new_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + assert!(exists, "new token hash must exist in DB after rotation"); + + // Old token should still exist (deletion deferred to caller) + let old_exists: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + original_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + assert!( + old_exists, + "old token must still exist until caller deletes it" + ); + + // Caller deletes old token after successful trigger update + let deleted = delete_token_by_hash(&db, &rotated.old_token_hash).await?; + assert!(deleted, "old token must be deletable"); + + // Old token should now be gone + let old_gone: bool = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM token WHERE token_hash = $1) AS exists", + original_hash + ) + .fetch_one(&db) + .await? + .unwrap_or(true); + assert!(!old_gone, "old token must be gone after explicit deletion"); + + // Rotating a non-existent hash should return None + let result = rotate_webhook_token(&db, "nonexistent_hash").await?; + assert!( + result.is_none(), + "rotating a non-existent token must return None" + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/trigger_e2e.rs b/backend/windmill-api-integration-tests/tests/trigger_e2e.rs index 3b0e32749a..1d4f122bcc 100644 --- a/backend/windmill-api-integration-tests/tests/trigger_e2e.rs +++ b/backend/windmill-api-integration-tests/tests/trigger_e2e.rs @@ -251,7 +251,8 @@ async fn test_websocket_e2e(db: Pool) -> anyhow::Result<()> { "test-workspace", "test-user", "test@windmill.dev", - &[json!({"type": "RawMessage", "content": "hello from e2e test"})] as &[serde_json::Value], + &[json!({"type": "RawMessage", "content": "hello from e2e test"})] + as &[serde_json::Value], ) .execute(&db) .await?; @@ -302,9 +303,11 @@ async fn test_postgres_e2e(db: Pool) -> anyhow::Result<()> { sqlx::query("CREATE TABLE test_trigger_table (id serial PRIMARY KEY, data text)") .execute(&db) .await?; - sqlx::query(&format!("CREATE PUBLICATION {pub_name} FOR TABLE test_trigger_table")) - .execute(&db) - .await?; + sqlx::query(&format!( + "CREATE PUBLICATION {pub_name} FOR TABLE test_trigger_table" + )) + .execute(&db) + .await?; sqlx::query(&format!( "SELECT pg_create_logical_replication_slot('{slot_name}', 'pgoutput')" )) @@ -313,10 +316,9 @@ async fn test_postgres_e2e(db: Pool) -> anyhow::Result<()> { // Extract the test DB name from the pool so the resource points here, // not at the main windmill database. - let test_db_name: String = - sqlx::query_scalar("SELECT current_database()") - .fetch_one(&db) - .await?; + let test_db_name: String = sqlx::query_scalar("SELECT current_database()") + .fetch_one(&db) + .await?; insert_resource( &db, diff --git a/backend/windmill-api-integration-tests/tests/triggers.rs b/backend/windmill-api-integration-tests/tests/triggers.rs index 46854d566e..b55f24397c 100644 --- a/backend/windmill-api-integration-tests/tests/triggers.rs +++ b/backend/windmill-api-integration-tests/tests/triggers.rs @@ -214,12 +214,9 @@ async fn test_capture_delete(db: Pool) -> anyhow::Result<()> { .execute(&db) .await?; - let count = sqlx::query_scalar!( - "SELECT COUNT(*) FROM capture WHERE id = $1", - id, - ) - .fetch_one(&db) - .await?; + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM capture WHERE id = $1", id,) + .fetch_one(&db) + .await?; assert_eq!(count, Some(0)); @@ -385,7 +382,10 @@ async fn test_capture_api_list_captures(db: Pool) -> anyhow::Result<() .send() .await?; - assert!(response.status().is_success(), "list captures should succeed"); + assert!( + response.status().is_success(), + "list captures should succeed" + ); let captures: Vec = response.json().await?; assert_eq!(captures.len(), 3); @@ -480,12 +480,9 @@ async fn test_capture_api_delete(db: Pool) -> anyhow::Result<()> { assert!(response.status().is_success(), "delete should succeed"); - let count = sqlx::query_scalar!( - "SELECT COUNT(*) FROM capture WHERE id = $1", - id, - ) - .fetch_one(&db) - .await?; + let count = sqlx::query_scalar!("SELECT COUNT(*) FROM capture WHERE id = $1", id,) + .fetch_one(&db) + .await?; assert_eq!(count, Some(0)); @@ -933,7 +930,8 @@ async fn test_kafka_trigger_insert(db: Pool) -> anyhow::Result<()> { let trigger = sqlx::query!( r#" - SELECT kafka_resource_path, topics, group_id, mode AS "mode: String" + SELECT kafka_resource_path, topics, group_id, mode AS "mode: String", + auto_offset_reset, auto_commit, reset_offset FROM kafka_trigger WHERE workspace_id = $1 AND path = $2 "#, @@ -947,6 +945,50 @@ async fn test_kafka_trigger_insert(db: Pool) -> anyhow::Result<()> { assert_eq!(trigger.topics, vec!["topic-a", "topic-b"]); assert_eq!(trigger.group_id, "my-consumer-group"); assert_eq!(trigger.mode, "enabled"); + assert_eq!(trigger.auto_offset_reset, "latest"); + assert_eq!(trigger.auto_commit, true); + assert_eq!(trigger.reset_offset, false); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_kafka_trigger_insert_auto_commit_disabled(db: Pool) -> anyhow::Result<()> { + sqlx::query!( + r#" + INSERT INTO kafka_trigger ( + path, kafka_resource_path, topics, group_id, script_path, + is_flow, workspace_id, edited_by, email, auto_commit + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + "#, + "f/test/kafka_trigger_no_commit", + "u/admin/kafka_resource", + &["topic-c"] as &[&str], + "my-consumer-group-2", + "f/test/kafka_handler", + false, + "test-workspace", + "test-user", + "test@windmill.dev", + false, + ) + .execute(&db) + .await?; + + let trigger = sqlx::query!( + r#" + SELECT auto_commit + FROM kafka_trigger + WHERE workspace_id = $1 AND path = $2 + "#, + "test-workspace", + "f/test/kafka_trigger_no_commit", + ) + .fetch_one(&db) + .await?; + + assert_eq!(trigger.auto_commit, false); Ok(()) } diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index d93b33670d..e68d352a02 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -65,7 +65,8 @@ use windmill_common::{ schema::should_validate_schema, scripts::{ to_i64, HubScript, ListScriptQuery, ListableScript, NewScript, Schema, Script, ScriptHash, - ScriptHistory, ScriptHistoryUpdate, ScriptKind, ScriptLang, ScriptWithStarred, + ScriptHistory, ScriptHistoryUpdate, ScriptKind, ScriptLang, ScriptModule, + ScriptWithStarred, }, users::username_to_permissioned_as, utils::{not_found_if_none, query_elems_from_hub, require_admin, Pagination, StripPath}, @@ -116,7 +117,7 @@ pub struct ScriptWDraft { #[serde(skip_serializing_if = "Option::is_none")] pub visible_to_runner_only: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub no_main_func: Option, + pub auto_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub has_preprocessor: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -124,6 +125,9 @@ pub struct ScriptWDraft { #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(json(nullable))] pub assets: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(json(nullable))] + pub modules: Option>, #[serde(flatten)] #[sqlx(flatten)] pub runnable_settings: SR, @@ -174,10 +178,11 @@ impl ScriptWDraft { delete_after_use: self.delete_after_use, timeout: self.timeout, visible_to_runner_only: self.visible_to_runner_only, - no_main_func: self.no_main_func, + auto_kind: self.auto_kind, has_preprocessor: self.has_preprocessor, on_behalf_of_email: self.on_behalf_of_email, assets: self.assets, + modules: self.modules, }) } } @@ -295,7 +300,7 @@ async fn list_scripts( "draft.path IS NOT NULL as has_draft", "draft_only", "ws_error_handler_muted", - "no_main_func", + "auto_kind", "codebase IS NOT NULL as use_codebase", "kind" ]) @@ -330,7 +335,7 @@ async fn list_scripts( { // only include scripts that have a main function // do not hide scripts without main if preprocessor is in the kinds - sqlb.and_where("o.no_main_func IS NOT TRUE"); + sqlb.and_where("o.auto_kind IS NULL"); } if !lq.include_draft_only.unwrap_or(false) || authed.is_operator { @@ -825,21 +830,24 @@ async fn create_script_internal<'c>( let validate_schema = should_validate_schema(&ns.content, &ns.language); - let (no_main_func, has_preprocessor) = if matches!(ns.kind, Some(ScriptKind::Preprocessor)) { - (ns.no_main_func, ns.has_preprocessor) + let (auto_kind, has_preprocessor) = if matches!(ns.kind, Some(ScriptKind::Preprocessor)) { + (ns.auto_kind.clone(), ns.has_preprocessor) } else { match lang { ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { let args = windmill_parser_ts::parse_deno_signature(&ns.content, true, true, None); match args { - Ok(args) => (args.no_main_func, args.has_preprocessor), + Ok(args) => ( + ns.auto_kind.clone().or(args.auto_kind), + args.has_preprocessor, + ), Err(e) => { tracing::warn!( "Error parsing deno signature when deploying script {}: {:?}", ns.path, e ); - (None, None) + (ns.auto_kind.clone(), None) } } } @@ -847,18 +855,21 @@ async fn create_script_internal<'c>( ScriptLang::Python3 => { let args = windmill_parser_py::parse_python_signature(&ns.content, None, true); match args { - Ok(args) => (args.no_main_func, args.has_preprocessor), + Ok(args) => ( + ns.auto_kind.clone().or(args.auto_kind), + args.has_preprocessor, + ), Err(e) => { tracing::warn!( "Error parsing python signature when deploying script {}: {:?}", ns.path, e ); - (None, None) + (ns.auto_kind.clone(), None) } } } - _ => (ns.no_main_func, ns.has_preprocessor), + _ => (ns.auto_kind.clone(), ns.has_preprocessor), } }; @@ -894,8 +905,8 @@ async fn create_script_internal<'c>( content, created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ - delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38)", + delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, cache_ignore_s3_path, runnable_settings_handle, modules) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::json, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39)", &w_id, &hash.0, ns.path, @@ -924,7 +935,7 @@ async fn create_script_internal<'c>( ns.timeout, guarded_concurrency_key, ns.visible_to_runner_only, - no_main_func.filter(|x: &bool| *x), // should be Some(true) or None + auto_kind.as_deref(), codebase, has_preprocessor.filter(|x: &bool| *x), // should be Some(true) or None windmill_common::resolve_on_behalf_of_email( @@ -937,7 +948,8 @@ async fn create_script_internal<'c>( guarded_debounce_key, guarded_debounce_delay_s, ns.cache_ignore_s3_path, - runnable_settings_handle + runnable_settings_handle, + ns.modules.as_ref().and_then(|m| serde_json::to_value(m).ok()) ) .execute(&mut *tx) .await?; @@ -1387,7 +1399,7 @@ async fn get_script_by_path_w_draft( let mut tx = user_db.begin(&authed).await?; let script_o = sqlx::query_as::<_, ScriptWDraft>( - "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, has_preprocessor, on_behalf_of_email, assets, debounce_key, debounce_delay_s FROM script LEFT JOIN draft ON + "SELECT hash, script.path, summary, description, content, language, kind, tag, schema, draft_only, envs, runnable_settings_handle, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, ws_error_handler_muted, draft.value as draft, dedicated_worker, priority, restart_unless_cancelled, delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, has_preprocessor, on_behalf_of_email, assets, modules, debounce_key, debounce_delay_s FROM script LEFT JOIN draft ON script.path = draft.path AND script.workspace_id = draft.workspace_id AND draft.typ = 'script' WHERE script.path = $1 AND script.workspace_id = $2 ORDER BY script.created_at DESC LIMIT 1", diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index f7409169a9..8f7bec1398 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -42,7 +42,7 @@ use tracing::Instrument; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::audit::AuditAuthor; -use windmill_common::auth::TOKEN_PREFIX_LEN; +use windmill_common::auth::{safe_token_prefix, TOKEN_PREFIX_LEN}; use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING; use windmill_common::oauth2::InstanceEvent; use windmill_common::users::truncate_token; @@ -156,6 +156,7 @@ pub struct GlobalUserInfo { #[serde(skip_serializing_if = "Option::is_none")] operator_only: Option, first_time_user: bool, + role_source: String, } #[derive(Serialize, Debug)] @@ -235,7 +236,7 @@ pub struct EditLoginType { #[derive(FromRow, Serialize)] pub struct TruncatedToken { pub label: Option, - pub token_prefix: Option, + pub token_prefix: String, pub expiration: Option>, pub created_at: chrono::DateTime, pub last_used_at: chrono::DateTime, @@ -395,7 +396,7 @@ async fn list_users_as_super_admin( GlobalUserInfo, "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), authors as (SELECT distinct email FROM usr WHERE usr.operator IS false) - SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user + SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source FROM password WHERE email IN (SELECT email FROM active_users) ORDER BY super_admin DESC, devops DESC @@ -408,7 +409,7 @@ async fn list_users_as_super_admin( } else { sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ + "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \ $1 OFFSET $2", per_page as i32, offset as i32 @@ -527,23 +528,28 @@ async fn logout( } cookies.remove(cookie); let mut tx = db.begin().await?; + let t_hash = windmill_common::auth::hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); let email = if *INVALIDATE_ALL_SESSIONS_ON_LOGOUT { sqlx::query_scalar!( "WITH email_lookup AS ( - SELECT email FROM token WHERE token = $1 + SELECT email FROM token WHERE token_hash = $1 ) DELETE FROM token WHERE email = (SELECT email FROM email_lookup) AND label = 'session' RETURNING email", - token + t_hash ) .fetch_optional(&mut *tx) .await? } else { - sqlx::query_scalar!("DELETE FROM token WHERE token = $1 RETURNING email", token) - .fetch_optional(&mut *tx) - .await? + sqlx::query_scalar!( + "DELETE FROM token WHERE token_hash = $1 RETURNING email", + t_hash + ) + .fetch_optional(&mut *tx) + .await? }; if let Some(email) = email { @@ -559,7 +565,7 @@ async fn logout( email: email.clone(), username: email, username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(t_prefix.to_string()), }, audit_message, ActionKind::Delete, @@ -619,7 +625,7 @@ async fn global_whoami( ) -> JsonResult { let user = sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user FROM password WHERE \ + "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE \ email = $1", email ) @@ -641,6 +647,7 @@ async fn global_whoami( username: None, operator_only: None, first_time_user: false, + role_source: "manual".to_string(), })) } else { Err(user.unwrap_err()) @@ -1276,7 +1283,7 @@ async fn update_user( let mut new_super_admin: Option = None; if let Some(sa) = eu.is_super_admin { sqlx::query_scalar!( - "UPDATE password SET super_admin = $1 WHERE email = $2", + "UPDATE password SET super_admin = $1, role_source = 'manual' WHERE email = $2", sa, &email_to_update ) @@ -1287,7 +1294,7 @@ async fn update_user( if let Some(dv) = eu.is_devops { sqlx::query_scalar!( - "UPDATE password SET devops = $1 WHERE email = $2", + "UPDATE password SET devops = $1, role_source = 'manual' WHERE email = $2", dv, &email_to_update ) @@ -1322,6 +1329,74 @@ async fn update_user( .await?; } + // If the result is "user" (no elevation), recompute from instance groups. + // Setting to "user" means "clear manual override, fall back to group role". + // Manual elevated roles (devops/superadmin) are never overridden by groups. + if eu.is_super_admin.is_some() || eu.is_devops.is_some() { + let current = sqlx::query!( + "SELECT super_admin, devops FROM password WHERE email = $1", + &email_to_update + ) + .fetch_optional(&mut *tx) + .await?; + + if let Some(c) = current { + if !c.super_admin && !c.devops { + // Compute effective role from all instance groups + let roles = sqlx::query_scalar!( + "SELECT ig.instance_role FROM email_to_igroup eig + JOIN instance_group ig ON ig.name = eig.igroup + WHERE eig.email = $1 AND ig.instance_role IS NOT NULL", + &email_to_update + ) + .fetch_all(&mut *tx) + .await?; + + let mut effective: Option<&str> = None; + for role in roles.iter().flatten() { + match role.as_str() { + "superadmin" => { + effective = Some("superadmin"); + break; + } + "devops" if effective.is_none() => { + effective = Some("devops"); + } + _ => {} + } + } + + if let Some(role) = effective { + let (sa, dv) = match role { + "superadmin" => (true, false), + _ => (false, true), + }; + sqlx::query!( + "UPDATE password SET super_admin = $1, devops = $2, role_source = 'instance_group' WHERE email = $3", + sa, dv, &email_to_update + ) + .execute(&mut *tx) + .await?; + + // Re-invalidate tokens with the group role + sqlx::query!( + "DELETE FROM token WHERE email = $1 AND label = 'session'", + &email_to_update + ) + .execute(&mut *tx) + .await?; + sqlx::query!( + "UPDATE token SET super_admin = $1 WHERE email = $2 AND label != 'session'", + sa, + &email_to_update + ) + .execute(&mut *tx) + .await?; + } + } + } + } + if let Some(n) = eu.name { sqlx::query_scalar!( "UPDATE password SET name = $1 WHERE email = $2", @@ -1643,7 +1718,7 @@ async fn login( email: email.clone(), username: email.clone(), username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(safe_token_prefix(&token)), }; audit_log( @@ -1689,7 +1764,8 @@ async fn refresh_token( let mut tx = db.begin().await?; if let Some(thresh_s) = query.if_expiring_in_less_than_s { - let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &token, thresh_s) + let t_hash = windmill_common::auth::hash_token(&token); + let not_expired = sqlx::query_scalar!("SELECT true FROM token WHERE token_hash = $1 and expiration IS NOT NULL and expiration > now() + $2::int * '1 sec'::interval", &t_hash, thresh_s) .fetch_optional(&db) .await? .flatten() @@ -1740,7 +1816,16 @@ pub async fn create_session_token<'c>( tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, cookies: Cookies, ) -> Result { + use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; + let token = rd_string(32); + let t_hash = windmill_common::auth::hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&token) + }; if *INVALIDATE_OLD_SESSIONS { sqlx::query!( @@ -1756,7 +1841,7 @@ pub async fn create_session_token<'c>( email: email.to_string(), username: email.to_string(), username_override: None, - token_prefix: Some(token[0..TOKEN_PREFIX_LEN].to_string()), + token_prefix: Some(t_prefix.to_string()), }, "users.token.invalidate_old_sessions", ActionKind::Delete, @@ -1770,9 +1855,11 @@ pub async fn create_session_token<'c>( sqlx::query!( "INSERT INTO token - (token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5)", - token, + (token_hash, token_prefix, token, email, label, expiration, super_admin) + VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", + t_hash, + t_prefix, + plaintext as Option<&str>, email, "session", &MAX_SESSION_VALIDITY_SECONDS.to_string(), @@ -1817,7 +1904,16 @@ async fn impersonate( authed: ApiAuthed, Json(new_token): Json, ) -> Result<(StatusCode, String)> { + use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; + let token = rd_string(32); + let t_hash = windmill_common::auth::hash_token(&token); + let t_prefix = token.get(..TOKEN_PREFIX_LEN).unwrap_or(&token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&token) + }; require_super_admin(&db, &authed.email).await?; if new_token.impersonate_email.is_none() { @@ -1839,9 +1935,11 @@ async fn impersonate( sqlx::query!( "INSERT INTO token - (token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, $4, $5)", - token, + (token_hash, token_prefix, token, email, label, expiration, super_admin) + VALUES ($1, $2, $3, $4, $5, $6, $7)", + t_hash, + t_prefix, + plaintext as Option<&str>, impersonated, new_token.label, new_token.expiration, @@ -1852,7 +1950,7 @@ async fn impersonate( windmill_api_auth::register_token_expiry_notification( &mut *tx, - &token, + &t_hash, new_token.label.as_deref(), new_token.expiration, ) @@ -1864,7 +1962,7 @@ async fn impersonate( "users.impersonate", ActionKind::Delete, &"global", - Some(&token[0..10]), + Some(t_prefix), Some([("impersonated", &format!("{impersonated}")[..])].into()), ) .instrument(tracing::info_span!("token", email = &impersonated)) @@ -1888,7 +1986,7 @@ async fn list_tokens( let rows = if query.exclude_ephemeral.unwrap_or(false) { sqlx::query_as!( TruncatedToken, - "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ + "SELECT label, token_prefix, expiration, created_at, \ last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL) ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, @@ -1900,7 +1998,7 @@ async fn list_tokens( } else { sqlx::query_as!( TruncatedToken, - "SELECT label, concat(substring(token for 10)) as token_prefix, expiration, created_at, \ + "SELECT label, token_prefix, expiration, created_at, \ last_used_at, scopes FROM token WHERE email = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", email, @@ -1923,8 +2021,8 @@ async fn delete_token( let tokens_deleted: Vec = sqlx::query_scalar( "DELETE FROM token WHERE email = $1 - AND token LIKE concat($2::text, '%') - RETURNING concat(substring(token for 10), '*****')", + AND token_prefix = $2 + RETURNING concat(token_prefix, '*****')", ) .bind(&authed.email) .bind(&token_prefix) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index a85383e496..ea6923c6d8 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -3099,8 +3099,8 @@ async fn clone_scripts( envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, restart_unless_cancelled, concurrency_key, - visible_to_runner_only, no_main_func, codebase, has_preprocessor, - on_behalf_of_email, assets + visible_to_runner_only, auto_kind, codebase, has_preprocessor, + on_behalf_of_email, assets, modules ) SELECT $1, hash, path, parent_hashes, summary, description, content, @@ -3109,8 +3109,8 @@ async fn clone_scripts( envs, concurrent_limit, concurrency_time_window_s, cache_ttl, dedicated_worker, ws_error_handler_muted, priority, timeout, delete_after_use, restart_unless_cancelled, concurrency_key, - visible_to_runner_only, no_main_func, codebase, has_preprocessor, - on_behalf_of_email, assets + visible_to_runner_only, auto_kind, codebase, has_preprocessor, + on_behalf_of_email, assets, modules FROM script WHERE workspace_id = $2"#, target_workspace_id, @@ -3594,7 +3594,7 @@ pub(crate) async fn archive_workspace_impl( // Delete non-session tokens scoped to this workspace let deleted_tokens = sqlx::query_scalar!( - "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token", + "DELETE FROM token WHERE workspace_id = $1 AND label IS DISTINCT FROM 'session' RETURNING token_prefix", w_id ) .fetch_all(&mut *tx) diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index e6760ea711..1b09f37861 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] private = ["windmill-audit/private", "windmill-common/private", "windmill-api-auth/private", "windmill-store/private", "windmill-api-users/private", "windmill-api-workspaces/private", "windmill-api-groups/private", "windmill-api-configs/private", "windmill-api-settings/private", "windmill-api-agent-workers?/private", "windmill-trigger-kafka?/private", "windmill-trigger-postgres?/private", "windmill-trigger-mqtt?/private", "windmill-trigger-websocket?/private", "windmill-trigger-nats?/private", "windmill-trigger-sqs?/private", "windmill-trigger-gcp?/private", "windmill-trigger-email?/private", "windmill-git-sync/private", "windmill-autoscaling?/private"] -enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise"] +enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker?/enterprise", "windmill-api-auth/enterprise", "windmill-store/enterprise", "windmill-api-jobs/enterprise", "windmill-api-scripts/enterprise", "windmill-api-flows/enterprise", "windmill-api-users/enterprise", "windmill-api-workspaces/enterprise", "windmill-api-groups/enterprise", "windmill-api-configs/enterprise", "windmill-api-settings/enterprise", "windmill-api-schedule/enterprise", "windmill-api-agent-workers?/enterprise", "windmill-trigger/enterprise", "windmill-trigger-kafka?/enterprise", "windmill-trigger-postgres?/enterprise", "windmill-trigger-mqtt?/enterprise", "windmill-trigger-websocket?/enterprise", "windmill-trigger-email?/enterprise", "windmill-trigger-nats?/enterprise", "windmill-trigger-sqs?/enterprise", "windmill-trigger-gcp?/enterprise", "windmill-trigger-http?/enterprise", "windmill-native-triggers?/enterprise", "dep:windmill-autoscaling", "windmill-autoscaling/enterprise"] stripe = [] run_inline = ["dep:windmill-worker", "windmill-api-configs/run_inline"] agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"] diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 7f04a7287e..ef3bdbf983 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -9683,8 +9683,8 @@ paths: type: boolean visible_to_runner_only: type: boolean - no_main_func: - type: boolean + auto_kind: + type: string codebase: type: string has_preprocessor: @@ -9706,7 +9706,6 @@ paths: - language - kind - starred - - no_main_func - has_preprocessor /w/{workspace}/scripts/list_paths: get: @@ -9904,8 +9903,8 @@ paths: type: integer visible_to_runner_only: type: boolean - no_main_func: - type: boolean + auto_kind: + type: string codebase: type: string has_preprocessor: @@ -29774,8 +29773,8 @@ components: required: - name - typ - no_main_func: - type: boolean + auto_kind: + type: string nullable: true has_preprocessor: type: boolean @@ -29786,7 +29785,7 @@ components: - args - type - error - - no_main_func + - auto_kind - has_preprocessor ScriptLang: type: string diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 5fbeacfddf..5b88630d71 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.654.0 + version: 1.658.0 title: Windmill API contact: @@ -1939,6 +1939,58 @@ paths: "200": description: Successfully imported the installation + /w/{workspace}/github_app/ghes_installation_callback: + post: + summary: GHES installation callback + description: Register a self-managed GitHub App installation from GitHub Enterprise Server + operationId: ghesInstallationCallback + tags: + - Git Sync + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - installation_id + properties: + installation_id: + type: integer + format: int64 + description: The GitHub App installation ID from GHES + responses: + "200": + description: GHES installation registered successfully + + /github_app/ghes_config: + get: + summary: Get GHES app config + description: Returns the GitHub Enterprise Server app configuration (without private key) for constructing the installation URL + operationId: getGhesConfig + tags: + - Git Sync + responses: + "200": + description: GHES app configuration + content: + application/json: + schema: + type: object + properties: + base_url: + type: string + app_slug: + type: string + client_id: + type: string + required: + - base_url + - app_slug + - client_id + /users/accept_invite: post: summary: accept invite to workspace @@ -12017,6 +12069,39 @@ paths: "200": description: kafka trigger offsets reset successfully + /w/{workspace}/kafka_triggers/commit_offsets/{path}: + post: + summary: commit kafka offsets for a specific trigger + operationId: commitKafkaOffsets + tags: + - kafka_trigger + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/Path" + requestBody: + description: offsets to commit + required: true + content: + application/json: + schema: + type: object + properties: + topic: + type: string + partition: + type: integer + format: int32 + offset: + type: integer + format: int64 + required: + - topic + - partition + - offset + responses: + "200": + description: kafka offsets committed successfully + /w/{workspace}/nats_triggers/create: post: summary: create nats trigger @@ -14079,7 +14164,7 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/InstanceGroup" + $ref: "#/components/schemas/InstanceGroupWithWorkspaces" /groups/create: post: @@ -14127,6 +14212,10 @@ paths: properties: new_summary: type: string + instance_role: + type: string + nullable: true + description: "Instance-level role for group members. 'superadmin', 'devops', 'user' or empty to clear." required: - new_summary responses: @@ -18690,14 +18779,20 @@ components: type: boolean visible_to_runner_only: type: boolean - no_main_func: - type: boolean + auto_kind: + type: string codebase: type: string has_preprocessor: type: boolean on_behalf_of_email: type: string + modules: + type: object + nullable: true + description: "Additional script modules keyed by relative file path" + additionalProperties: + $ref: "#/components/schemas/ScriptModule" required: - hash @@ -18714,7 +18809,6 @@ components: - language - kind - starred - - no_main_func - has_preprocessor NewScript: @@ -18787,8 +18881,8 @@ components: type: integer visible_to_runner_only: type: boolean - no_main_func: - type: boolean + auto_kind: + type: string codebase: type: string has_preprocessor: @@ -18816,6 +18910,12 @@ components: alt_access_type: type: string enum: [r, w, rw] + modules: + type: object + nullable: true + description: "Additional script modules keyed by relative file path" + additionalProperties: + $ref: "#/components/schemas/ScriptModule" required: - path @@ -19965,8 +20065,8 @@ components: required: - name - typ - no_main_func: - type: boolean + auto_kind: + type: string nullable: true has_preprocessor: type: boolean @@ -19977,7 +20077,7 @@ components: - args - type - error - - no_main_func + - auto_kind - has_preprocessor ScriptLang: @@ -20009,6 +20109,23 @@ components: # for related places search: ADD_NEW_LANG ] + ScriptModule: + type: object + description: "An additional module file associated with a script" + properties: + content: + type: string + description: "The source code content of this module" + language: + $ref: "#/components/schemas/ScriptLang" + lock: + type: string + nullable: true + description: "Lock file content for this module's dependencies" + required: + - content + - language + Preview: type: object properties: @@ -20036,6 +20153,12 @@ components: type: string flow_path: type: string + modules: + type: object + nullable: true + description: "Additional script modules keyed by relative file path" + additionalProperties: + $ref: "#/components/schemas/ScriptModule" required: - args @@ -22098,6 +22221,10 @@ components: - earliest default: latest description: "Initial offset behavior when consumer group has no committed offset. 'latest' starts from new messages only, 'earliest' starts from the beginning." + auto_commit: + type: boolean + default: true + description: "When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint." server_id: type: string description: ID of the server currently handling this trigger (internal) @@ -22165,6 +22292,10 @@ components: - earliest default: latest description: "Initial offset behavior when consumer group has no committed offset." + auto_commit: + type: boolean + default: true + description: "When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint." mode: $ref: "#/components/schemas/TriggerMode" error_handler_path: @@ -22224,6 +22355,10 @@ components: - earliest default: latest description: "Initial offset behavior when consumer group has no committed offset." + auto_commit: + type: boolean + default: true + description: "When true (default), offsets are committed automatically after receiving each message. When false, you must manually commit offsets using the commit_offsets endpoint." path: type: string description: The unique path identifier for this trigger @@ -22532,6 +22667,12 @@ components: type: array items: type: string + instance_role: + type: string + nullable: true + enum: + - superadmin + - devops InstanceGroupWithWorkspaces: type: object @@ -22546,6 +22687,12 @@ components: type: array items: type: string + instance_role: + type: string + nullable: true + enum: + - superadmin + - devops workspaces: type: array items: @@ -22829,6 +22976,9 @@ components: type: boolean first_time_user: type: boolean + role_source: + type: string + enum: ["manual", "instance_group"] required: - email @@ -22836,6 +22986,7 @@ components: - super_admin - verified - first_time_user + - role_source Flow: allOf: @@ -23532,7 +23683,6 @@ components: items: $ref: "#/components/schemas/GitSyncObjectType" required: - - script_path - git_repo_resource_path MetricMetadata: @@ -23706,6 +23856,12 @@ components: type: string external_id: type: string + instance_role: + type: string + nullable: true + enum: + - superadmin + - devops required: - name diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 1dca68f219..ed9cdb366c 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -169,6 +169,9 @@ struct AIStandardResource { /// Enable 1M context window for Anthropic #[serde(alias = "enable_1M_context", default)] enable_1m_context: bool, + /// Custom HTTP headers to include in AI requests + #[serde(default)] + headers: HashMap, } #[derive(Deserialize, Debug)] @@ -200,6 +203,7 @@ struct AIRequestConfig { pub aws_session_token: Option, pub platform: AIPlatform, pub enable_1m_context: bool, + pub custom_headers: HashMap, } impl AIRequestConfig { @@ -221,11 +225,13 @@ impl AIRequestConfig { aws_session_token, platform, enable_1m_context, + custom_headers, ) = match resource { AIResource::Standard(resource) => { let region = resource.region.clone(); let platform = resource.platform.clone(); let enable_1m_context = resource.enable_1m_context; + let custom_headers = resource.headers.clone(); // Skip get_base_url for Bedrock - it uses SDK directly, not HTTP let base_url = if matches!(provider, AIProvider::AWSBedrock) { String::new() @@ -271,6 +277,7 @@ impl AIRequestConfig { aws_session_token, platform, enable_1m_context, + custom_headers, ) } AIResource::OAuth(resource) => { @@ -294,6 +301,7 @@ impl AIRequestConfig { None, AIPlatform::Standard, false, + HashMap::new(), ) } }; @@ -310,6 +318,7 @@ impl AIRequestConfig { aws_session_token, platform, enable_1m_context, + custom_headers, }) } @@ -443,6 +452,11 @@ impl AIRequestConfig { request = request.header(header_name.as_str(), header_value.as_str()); } + // Apply custom headers from the resource + for (header_name, header_value) in &self.custom_headers { + request = request.header(header_name.as_str(), header_value.as_str()); + } + Ok(request) } diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index bcc287ec04..277504b481 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -1012,6 +1012,7 @@ async fn http_payload( .to_v2_preprocessor_args( &http_trigger_config.route_path, &route_path, + "", ¶ms, headers, query, diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index a9cb930b8f..29b9765480 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -44,7 +44,7 @@ use windmill_common::runnable_settings::{ }; #[cfg(feature = "run_inline")] use windmill_common::runtime_assets::{register_runtime_asset, InsertRuntimeAssetParams}; -use windmill_common::scripts::ScriptRunnableSettingsInline; +use windmill_common::scripts::{ScriptModule, ScriptRunnableSettingsInline}; use windmill_common::triggers::TriggerMetadata; use windmill_common::utils::{RunnableKind, WarnAfterExt}; use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR}; @@ -2948,6 +2948,7 @@ struct Preview { lock: Option, format: Option, flow_path: Option, + modules: Option>, } #[cfg(feature = "run_inline")] @@ -3608,6 +3609,7 @@ pub async fn run_workflow_as_code( dedicated_worker: None, // TODO(debouncing): enable for this mode debouncing_settings: DebouncingSettings::default(), + modules: None, }), Some(job.tag.clone()), None, @@ -4615,12 +4617,15 @@ async fn run_preview_script( let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); let preview_args = preview.args.unwrap_or_default(); - let flow_path_extra = preview.flow_path.map(|fp| { - let mut extra = HashMap::new(); - extra.insert("_FLOW_PATH".to_string(), to_raw_value(&fp)); - extra - }); - let push_args = PushArgs { extra: flow_path_extra, args: &preview_args }; + let mut extra = HashMap::new(); + if let Some(fp) = &preview.flow_path { + extra.insert("_FLOW_PATH".to_string(), to_raw_value(fp)); + } + if let Some(ref modules) = preview.modules { + extra.insert("_MODULES".to_string(), to_raw_value(modules)); + } + let extra = if extra.is_empty() { None } else { Some(extra) }; + let push_args = PushArgs { extra, args: &preview_args }; let (uuid, tx) = push( &db, @@ -4643,6 +4648,7 @@ async fn run_preview_script( cache_ttl: None, cache_ignore_s3_path: None, dedicated_worker: preview.dedicated_worker, + modules: preview.modules, }), }, push_args, @@ -4984,6 +4990,7 @@ async fn run_bundle_preview_script( dedicated_worker: preview.dedicated_worker, concurrency_settings: ConcurrencySettingsWithCustom::default(), debouncing_settings: DebouncingSettings::default(), + modules: None, }), PushArgs::from(&args), authed.display_username(), @@ -5777,6 +5784,7 @@ async fn run_dynamic_select( dedicated_worker: None, concurrency_settings: ConcurrencySettings::default().into(), debouncing_settings: DebouncingSettings::default(), + modules: None, }), PushArgs::from(&request.args.unwrap_or_default()), authed.display_username(), diff --git a/backend/windmill-api/src/mcp/oauth_server.rs b/backend/windmill-api/src/mcp/oauth_server.rs index 9a81e2efd8..ecfada2fcb 100644 --- a/backend/windmill-api/src/mcp/oauth_server.rs +++ b/backend/windmill-api/src/mcp/oauth_server.rs @@ -10,7 +10,9 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use sqlx::FromRow; use windmill_common::{ + auth::{hash_token, TOKEN_PREFIX_LEN}, error::{Error, Result}, + min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH, utils::rd_string, BASE_URL, DB, }; @@ -209,7 +211,7 @@ struct AuthorizationCode { struct RefreshTokenRow { id: i64, refresh_token: String, - access_token: String, + access_token_hash: String, client_id: String, user_email: String, workspace_id: String, @@ -383,16 +385,25 @@ async fn handle_authorization_code_grant( } let access_token = rd_string(32); + let access_token_hash = hash_token(&access_token); + let access_token_prefix = access_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&access_token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&access_token) + }; let refresh_token = rd_string(32); let token_family = sqlx::types::Uuid::new_v4(); let scopes = auth_code.scopes; // Create access token (rejects archived workspaces inline) let rows = sqlx::query!( - "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) - SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar - WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", - access_token, + "INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id) + SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar + WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)", + access_token_hash, + access_token_prefix, + plaintext as Option<&str>, auth_code.user_email, format!("mcp-oauth-{}", auth_code.client_id), MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(), @@ -411,13 +422,13 @@ async fn handle_authorization_code_grant( )); } - // Create refresh token + // Create refresh token — store the hash of the access token so we can delete it later let refresh_token_result = sqlx::query!( "INSERT INTO mcp_oauth_refresh_token - (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at) + (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", refresh_token, - access_token, + access_token_hash, auth_code.client_id, auth_code.user_email, auth_code.workspace_id, @@ -469,7 +480,7 @@ async fn handle_refresh_token_grant( AND used_at IS NULL AND NOT revoked AND expires_at > now() - RETURNING id, refresh_token, access_token, client_id, user_email, workspace_id, + RETURNING id, refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, created_at, expires_at, used_at, revoked", refresh_token_value, req.client_id @@ -504,10 +515,13 @@ async fn handle_refresh_token_grant( } }; - // Delete old access token - if let Err(e) = sqlx::query!("DELETE FROM token WHERE token = $1", token_row.access_token) - .execute(db) - .await + // Delete old access token using the stored hash + if let Err(e) = sqlx::query!( + "DELETE FROM token WHERE token_hash = $1", + token_row.access_token_hash + ) + .execute(db) + .await { tracing::error!("Failed to delete old access token: {}", e); // Non-fatal, continue with token creation @@ -515,15 +529,24 @@ async fn handle_refresh_token_grant( // Generate new tokens let new_access_token = rd_string(32); + let new_access_token_hash = hash_token(&new_access_token); + let new_access_token_prefix = new_access_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&new_access_token); + let new_plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&new_access_token) + }; let new_refresh_token = rd_string(32); let scopes = token_row.scopes; // Create new access token (rejects archived workspaces inline) let rows = sqlx::query!( - "INSERT INTO token (token, email, label, expiration, scopes, workspace_id) - SELECT $1::varchar, $2::varchar, $3::varchar, now() + ($4 || ' seconds')::interval, $5::text[], $6::varchar - WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $6 AND deleted = true)", - new_access_token, + "INSERT INTO token (token_hash, token_prefix, token, email, label, expiration, scopes, workspace_id) + SELECT $1::varchar, $2::varchar, $3::varchar, $4::varchar, $5::varchar, now() + ($6 || ' seconds')::interval, $7::text[], $8::varchar + WHERE NOT EXISTS(SELECT 1 FROM workspace WHERE id = $8 AND deleted = true)", + new_access_token_hash, + new_access_token_prefix, + new_plaintext as Option<&str>, token_row.user_email, format!("mcp-oauth-{}", token_row.client_id), MCP_OAUTH_TOKEN_EXPIRATION_SECS.to_string(), @@ -542,13 +565,13 @@ async fn handle_refresh_token_grant( )); } - // Create new refresh token (same token family for tracking) + // Create new refresh token (same token family for tracking) — store hash of access token if let Err(e) = sqlx::query!( "INSERT INTO mcp_oauth_refresh_token - (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at) + (refresh_token, access_token_hash, client_id, user_email, workspace_id, scopes, token_family, expires_at) VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)", new_refresh_token, - new_access_token, + new_access_token_hash, token_row.client_id, token_row.user_email, token_row.workspace_id, diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index 1cfbfd7085..0361dd37e7 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -151,11 +151,14 @@ pub async fn get_items sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen .and_where("o.draft_only IS NOT TRUE"); if item_type == "script" { - sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)"); + sqlb.and_where("o.auto_kind IS NULL"); } if let Some(prefix) = path_prefix { - let escaped = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); + let escaped = prefix + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); sqlb.and_where("o.path LIKE ? ESCAPE '\\'".bind(&format!("{}%", escaped))); } diff --git a/backend/windmill-api/src/triggers/http/handler.rs b/backend/windmill-api/src/triggers/http/handler.rs index 9d95ead840..fcb883f089 100644 --- a/backend/windmill-api/src/triggers/http/handler.rs +++ b/backend/windmill-api/src/triggers/http/handler.rs @@ -468,6 +468,7 @@ async fn route_job( .to_args_from_format( &trigger.route_path, &called_path, + &trigger.path, ¶ms, runnable_format, trigger.wrap_body, diff --git a/backend/windmill-api/src/triggers/http/http_trigger_args.rs b/backend/windmill-api/src/triggers/http/http_trigger_args.rs index 4866f9be34..7df1e9f200 100644 --- a/backend/windmill-api/src/triggers/http/http_trigger_args.rs +++ b/backend/windmill-api/src/triggers/http/http_trigger_args.rs @@ -68,6 +68,7 @@ struct HttpTriggerPreprocessorEvent<'a> { kind: String, route: &'a str, path: &'a str, + trigger_path: &'a str, body: Box, raw_string: Option, params: &'a HashMap, @@ -117,6 +118,7 @@ impl HttpTriggerArgs { self, route_path: &str, called_path: &str, + trigger_path: &str, params: &HashMap, format: RunnableFormat, wrap_body: bool, @@ -126,7 +128,14 @@ impl HttpTriggerArgs { match format { RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V2 } => { // we don't care about wrap_body in v2 - self.to_v2_preprocessor_args(route_path, called_path, params, headers, query) + self.to_v2_preprocessor_args( + route_path, + called_path, + trigger_path, + params, + headers, + query, + ) } RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V1 } => self .to_v1_preprocessor_args( @@ -177,6 +186,7 @@ impl HttpTriggerArgs { self, route_path: &str, called_path: &str, + trigger_path: &str, params: &HashMap, headers: HashMap>, query: HashMap>, @@ -193,6 +203,7 @@ impl HttpTriggerArgs { method: (&self.0.metadata.method).try_into()?, route: route_path, path: called_path, + trigger_path, params, }), ); diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index cd32587648..bccd3736ab 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -89,14 +89,20 @@ struct ScriptMetadata { pub restart_unless_cancelled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub visible_to_runner_only: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub no_main_func: Option, + // auto_kind is intentionally excluded from export — it is auto-detected by the + // parser at deploy time from the script content (workflow/task patterns for "wac", + // no main function for "lib"). + #[serde(skip_serializing)] + #[allow(dead_code)] + pub auto_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub codebase: Option, #[serde(skip_serializing_if = "Option::is_none")] pub has_preprocessor: Option, #[serde(skip_serializing_if = "Option::is_none")] pub on_behalf_of_email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub modules: Option>, #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, #[serde(flatten)] @@ -283,6 +289,8 @@ struct SimplifiedSettings { #[serde(skip_serializing_if = "Option::is_none")] operator_settings: Option, #[serde(skip_serializing_if = "Option::is_none")] + datatable: Option, + #[serde(skip_serializing_if = "Option::is_none")] slack_team_id: Option, #[serde(skip_serializing_if = "Option::is_none")] slack_name: Option, @@ -323,6 +331,8 @@ struct SimplifiedSettingsLegacy { #[serde(skip_serializing_if = "Option::is_none")] operator_settings: Option, #[serde(skip_serializing_if = "Option::is_none")] + datatable: Option, + #[serde(skip_serializing_if = "Option::is_none")] slack_team_id: Option, #[serde(skip_serializing_if = "Option::is_none")] slack_name: Option, @@ -347,6 +357,7 @@ struct SettingsRow { mute_critical_alerts: Option, color: Option, operator_settings: Option, + datatable: Option, slack_team_id: Option, slack_name: Option, slack_command_script: Option, @@ -498,10 +509,11 @@ pub(crate) async fn tarball_workspace( delete_after_use: script.delete_after_use, restart_unless_cancelled: script.restart_unless_cancelled, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, codebase: script.codebase, has_preprocessor: script.has_preprocessor, on_behalf_of_email: script.on_behalf_of_email, + modules: script.modules, }; let metadata_str = serde_json::to_string_pretty(&metadata).unwrap(); archive @@ -829,7 +841,7 @@ pub(crate) async fn tarball_workspace( let trigger_str = &to_string_without_metadata( &trigger, false, - Some(vec!["webhook_token_prefix"]), + Some(vec!["webhook_token_hash"]), ) .unwrap(); archive @@ -955,6 +967,7 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts, color, operator_settings, + datatable, slack_team_id, slack_name, slack_command_script @@ -983,6 +996,7 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts: row.mute_critical_alerts, color: row.color.clone(), operator_settings: row.operator_settings.clone(), + datatable: row.datatable.clone(), slack_team_id: row.slack_team_id.clone(), slack_name: row.slack_name.clone(), slack_command_script: row.slack_command_script.clone(), @@ -1045,6 +1059,7 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts: row.mute_critical_alerts, color: row.color, operator_settings: row.operator_settings, + datatable: row.datatable, slack_team_id: row.slack_team_id, slack_name: row.slack_name, slack_command_script: row.slack_command_script, diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 7024029c52..23604b6e3a 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -18,6 +18,12 @@ use crate::{ DB, }; +/// Hash a raw token using SHA-256 (hex-encoded, 64 chars). +/// Used to store and look up tokens without keeping plaintext in the DB. +pub fn hash_token(token: &str) -> String { + crate::utils::calculate_hash(token) +} + #[derive(Debug)] pub struct IdToken { token: String, @@ -26,6 +32,15 @@ pub struct IdToken { pub const TOKEN_PREFIX_LEN: usize = 10; +/// Safely extract the token prefix (first TOKEN_PREFIX_LEN chars). +/// Returns the full token if it's shorter than TOKEN_PREFIX_LEN, preventing panics. +pub fn safe_token_prefix(token: &str) -> String { + token + .get(..TOKEN_PREFIX_LEN) + .unwrap_or(token) + .to_string() +} + lazy_static::lazy_static! { // Cache for script hash permissions - (ApiAuthed hash, script_hash) -> permission result pub static ref HASH_PERMS_CACHE: PermsCache = PermsCache::new(); diff --git a/backend/windmill-common/src/cache.rs b/backend/windmill-common/src/cache.rs index 27af55ea01..8901055889 100644 --- a/backend/windmill-common/src/cache.rs +++ b/backend/windmill-common/src/cache.rs @@ -12,7 +12,7 @@ use crate::{ error, flows::{FlowNodeId, FlowValue}, schema::SchemaValidator, - scripts::{ScriptHash, ScriptLang}, + scripts::{ScriptHash, ScriptLang, ScriptModule}, }; use anyhow::anyhow; use serde_json::value::to_raw_value; @@ -335,6 +335,7 @@ impl FlowData { pub struct ScriptData { pub lock: Option, pub code: String, + pub modules: Option>, } #[derive(Debug, Clone)] @@ -357,6 +358,7 @@ pub struct RawScript { pub content: String, pub lock: Option, pub meta: Option, + pub modules: Option>, } #[derive(Debug, Deserialize, Serialize)] @@ -364,17 +366,28 @@ pub struct RawScriptApi { pub content: String, pub lock: Option, pub meta: Option, + pub modules: Option>, } impl From for RawScriptApi { fn from(value: RawScript) -> Self { - RawScriptApi { content: value.content, lock: value.lock, meta: value.meta } + RawScriptApi { + content: value.content, + lock: value.lock, + meta: value.meta, + modules: value.modules, + } } } impl From for RawScript { fn from(value: RawScriptApi) -> Self { - RawScript { content: value.content, lock: value.lock, meta: value.meta } + RawScript { + content: value.content, + lock: value.lock, + meta: value.meta, + modules: value.modules, + } } } @@ -632,7 +645,8 @@ pub mod script { schema AS \"schema: String\", \ schema_validation AS \"schema_validation: bool\", \ codebase LIKE '%.tar' as use_tar, \ - codebase LIKE '%.esm%' as is_esm \ + codebase LIKE '%.esm%' as is_esm, \ + modules AS \"modules: serde_json::Value\" \ FROM script WHERE hash = $1 LIMIT 1", hash.0 ) @@ -644,6 +658,7 @@ pub mod script { Ok(RawScript { content: r.content, lock: r.lock, + modules: r.modules.and_then(|v| serde_json::from_value(v).ok()), meta: Some(ScriptMetadata { language: r.language, envs: r.envs, @@ -822,6 +837,7 @@ pub mod job { _ => Ok(RawData::Script(Arc::new(ScriptData { code: code.unwrap_or_default(), lock, + modules: None, }))), }) }; @@ -999,7 +1015,7 @@ const _: () = { let content = src.get_utf8("code.txt")?; let lock = src.get_utf8("lock.txt").ok(); let meta = src.get_json("info.json").ok(); - Ok(Self { content, lock, meta }) + Ok(Self { content, lock, meta, modules: None }) } } @@ -1007,7 +1023,7 @@ const _: () = { type Untrusted = RawScript; fn resolve(src: Self::Untrusted) -> error::Result { - Ok(ScriptData { code: src.content, lock: src.lock }) + Ok(ScriptData { code: src.content, lock: src.lock, modules: src.modules }) } fn export(&self, dst: &impl Storage) -> error::Result<()> { @@ -1033,7 +1049,11 @@ const _: () = { return Err(error::Error::internal_err("Invalid script src".to_string())); }; Ok(ScriptFull { - data: Arc::new(ScriptData { code: src.content, lock: src.lock }), + data: Arc::new(ScriptData { + code: src.content, + lock: src.lock, + modules: src.modules, + }), meta: Arc::new(meta), }) } @@ -1063,7 +1083,11 @@ const _: () = { FlowData::from_raw(flow).map(Arc::new).map(Self::Flow) } RawNode { raw_code: Some(code), raw_lock: lock, .. } => { - Ok(Self::Script(Arc::new(ScriptData { code, lock }))) + Ok(Self::Script(Arc::new(ScriptData { + code, + lock, + modules: None, + }))) } _ => Err(error::Error::internal_err( "Invalid raw data src".to_string(), diff --git a/backend/windmill-common/src/git_sync_oss.rs b/backend/windmill-common/src/git_sync_oss.rs index a350b74156..4cd7a7c3d1 100644 --- a/backend/windmill-common/src/git_sync_oss.rs +++ b/backend/windmill-common/src/git_sync_oss.rs @@ -21,15 +21,14 @@ pub fn prepend_token_to_github_url( ) -> crate::error::Result { let url = Url::parse(github_url)?; - if url.host_str() != Some("github.com") { - return Err(crate::error::Error::BadRequest( - "Invalid: not a github URL".to_string(), - )); - } + let host = url.host_str().ok_or_else(|| { + crate::error::Error::BadRequest("Invalid GitHub URL: no host".to_string()) + })?; Ok(format!( - "https://x-access-token:{}@github.com{}", + "https://x-access-token:{}@{}{}", installation_token, + host, url.path() )) } diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 6d2f225479..1d2aeafce8 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -58,6 +58,7 @@ pub const OTEL_TRACING_PROXY_SETTING: &str = "otel_tracing_proxy"; pub const APP_WORKSPACED_ROUTE_SETTING: &str = "app_workspaced_route"; pub const SECRET_BACKEND_SETTING: &str = "secret_backend"; pub const MIN_KEEP_ALIVE_VERSION_SETTING: &str = "min_keep_alive_version"; +pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app"; pub const ENV_SETTINGS: &[&str] = &[ "DISABLE_NSJAIL", diff --git a/backend/windmill-common/src/indexer.rs b/backend/windmill-common/src/indexer.rs index 23bde13795..7b1ed407ab 100644 --- a/backend/windmill-common/src/indexer.rs +++ b/backend/windmill-common/src/indexer.rs @@ -21,9 +21,9 @@ pub struct TantivyIndexerSettings { impl Default for TantivyIndexerSettings { fn default() -> Self { TantivyIndexerSettings { - writer_memory_budget: 300_000_000, - commit_job_max_batch_size: 50_000, - commit_log_max_batch_size: 10_000, + writer_memory_budget: 150_000_000, + commit_job_max_batch_size: 10_000, + commit_log_max_batch_size: 5_000, refresh_index_period: 300, refresh_log_index_period: 300, max_indexed_job_log_size: 1_000_000, diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index fd1769dd55..11abecff9f 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -5,6 +5,7 @@ use tokio::sync::RwLock; // ============ Feature Definitions ============ +pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage"); pub const MIN_VERSION_SUPPORTS_SYNC_JOBS_DEBOUNCING: VC = vc(1, 602, 0, "Sync jobs debouncing"); pub const MIN_VERSION_SUPPORTS_DEBOUNCING_V2: VC = vc(1, 597, 0, "Debouncing V2"); pub const MIN_VERSION_IS_AT_LEAST_1_595: VC = vc(1, 595, 0, "Flow status separate table"); diff --git a/backend/windmill-common/src/oidc_oss.rs b/backend/windmill-common/src/oidc_oss.rs index 0a42cee2ed..e212558377 100644 --- a/backend/windmill-common/src/oidc_oss.rs +++ b/backend/windmill-common/src/oidc_oss.rs @@ -60,6 +60,8 @@ pub struct JobClaim { pub username: String, pub email: String, pub workspace: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_user_email: Option, } #[cfg(not(feature = "private"))] diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index cfff28dbcb..d3035b1b49 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -57,6 +57,13 @@ pub fn extract_workspace_dependencies_annotated_refs( None, runnable_path, ), + Powershell => WorkspaceDependenciesAnnotatedRefs::parse( + "#", + "modules_json", + code, + None, + runnable_path, + ), _ => return None, } } @@ -101,11 +108,12 @@ pub async fn prefetch_cached_script( delete_after_use: script.delete_after_use, restart_unless_cancelled: script.restart_unless_cancelled, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, codebase: script.codebase, has_preprocessor: script.has_preprocessor, on_behalf_of_email: script.on_behalf_of_email, assets: script.assets, + modules: script.modules, runnable_settings: ScriptRunnableSettingsInline { concurrency_settings: concurrency_settings.maybe_fallback( script.runnable_settings.concurrency_key, @@ -339,11 +347,12 @@ pub async fn fetch_script_for_update<'a>( delete_after_use, restart_unless_cancelled, visible_to_runner_only, - no_main_func, + auto_kind, codebase, has_preprocessor, on_behalf_of_email, - assets + assets, + modules FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1 FOR UPDATE", ) .bind(path) @@ -412,12 +421,13 @@ pub async fn clone_script<'c>( restart_unless_cancelled: s.restart_unless_cancelled, deployment_message, visible_to_runner_only: s.visible_to_runner_only, - no_main_func: s.no_main_func, + auto_kind: s.auto_kind, codebase: s.codebase, has_preprocessor: s.has_preprocessor, on_behalf_of_email: s.on_behalf_of_email, preserve_on_behalf_of: None, assets: s.assets, + modules: s.modules, }; let new_hash = hash_script(&ns); @@ -435,15 +445,15 @@ pub async fn clone_script<'c>( created_by, schema, is_template, extra_perms, lock, language, kind, tag, \ draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ - delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, \ - codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle) + delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ + codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules) SELECT workspace_id, $1, path, array_prepend($2::bigint, COALESCE(parent_hashes, '{}'::bigint[])), summary, description, \ content, created_by, schema, is_template, extra_perms, NULL, language, kind, tag, \ draft_only, envs, concurrent_limit, concurrency_time_window_s, cache_ttl, cache_ignore_s3_path, \ dedicated_worker, ws_error_handler_muted, priority, restart_unless_cancelled, \ - delete_after_use, timeout, concurrency_key, visible_to_runner_only, no_main_func, \ - codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle + delete_after_use, timeout, concurrency_key, visible_to_runner_only, auto_kind, \ + codebase, has_preprocessor, on_behalf_of_email, schema_validation, assets, debounce_key, debounce_delay_s, runnable_settings_handle, modules FROM script WHERE hash = $2 AND workspace_id = $3; ", new_hash, s.hash.0, w_id).execute(&mut *tx).await?; diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index b3876fbcfa..8b20d331c0 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -1361,7 +1361,7 @@ pub async fn fetch_raw_script_from_app_query( .await .map_err(Into::into) .and_then(unwrap_or_error(&loc, "Application script", id)) - .map(|r| RawScript { content: r.code, lock: r.lock, meta: None }) + .map(|r| RawScript { content: r.code, lock: r.lock, meta: None, modules: None }) } pub async fn insert_ping_query( diff --git a/backend/windmill-common/src/workspace_dependencies.rs b/backend/windmill-common/src/workspace_dependencies.rs index 051ed9db41..5ce5ca7f68 100644 --- a/backend/windmill-common/src/workspace_dependencies.rs +++ b/backend/windmill-common/src/workspace_dependencies.rs @@ -548,6 +548,23 @@ impl WorkspaceDependenciesPrefetched { }) } + pub fn get_powershell(&self) -> error::Result> { + use WorkspaceDependenciesPrefetchedInternal::*; + self.internal.assert_no_extra_mode().map_err(map_err)?; + Ok(match &self.internal { + Explicit(wdar @ WorkspaceDependenciesAnnotatedRefs { external, .. }) => { + wdar.assert_no_inline().map_err(map_err)?; + wdar.assert_external_less_than(2).map_err(map_err)?; + external + .get(0) + .map(|wd| wd.content.clone()) + .or(Some(r#"{"modules": {}}"#.to_owned())) + } + Implicit { workspace_dependencies, .. } => Some(workspace_dependencies.content.clone()), + None => Option::None, + }) + } + /// Is the runnable permitted to have external references pub fn is_external_references_permitted(runnable_path: &str) -> bool { !BLACKLIST.contains(runnable_path) && !runnable_path.starts_with("hub/") @@ -588,7 +605,9 @@ impl WorkspaceDependenciesPrefetched { use WorkspaceDependenciesPrefetchedInternal::*; match (self.language, &self.internal) { // These languages except for python had none of this functionality - (Php | Bun | Bunnative | Go, wdp) => wdp.assert_no_workspace_dependencies()?, + (Php | Bun | Bunnative | Go | Powershell, wdp) => { + wdp.assert_no_workspace_dependencies()? + } // Python, had #(extra_)requirements: // but it had no external requirements. @@ -1261,6 +1280,130 @@ def main(): assert!(result.external.is_empty()); assert!(result.inline.is_none()); } + #[test] + fn test_parse_annotation_powershell_modules_json_manual_mode() { + let code = r#" +# modules_json: default +param() +Write-Host "Hello" +"#; + + let result = WorkspaceDependenciesAnnotatedRefs::::parse( + "#", + "modules_json", + code, + None, + "", + ) + .unwrap(); + assert!(matches!(result.mode, Mode::manual)); + assert_eq!(result.external, vec!["default".to_owned()]); + assert!(result.inline.is_none()); + } + + #[test] + fn test_parse_annotation_powershell_modules_json_extra_mode() { + let code = r#" +# extra_modules_json: my_deps +param() +Write-Host "Hello" +"#; + + let result = WorkspaceDependenciesAnnotatedRefs::::parse( + "#", + "modules_json", + code, + None, + "", + ) + .unwrap(); + assert!(matches!(result.mode, Mode::extra)); + assert_eq!(result.external, vec!["my_deps".to_owned()]); + assert!(result.inline.is_none()); + } + + #[test] + fn test_parse_annotation_powershell_modules_json_extra_hyphen() { + let code = r#" +# extra-modules_json: my_deps +param() +Write-Host "Hello" +"#; + + let result = WorkspaceDependenciesAnnotatedRefs::::parse( + "#", + "modules_json", + code, + None, + "", + ) + .unwrap(); + assert!(matches!(result.mode, Mode::extra)); + assert_eq!(result.external, vec!["my_deps".to_owned()]); + } + + #[test] + fn test_parse_annotation_powershell_modules_json_multiple_refs() { + let code = r#" +# modules_json: default, extra_modules +param() +"#; + + let result = WorkspaceDependenciesAnnotatedRefs::::parse( + "#", + "modules_json", + code, + None, + "", + ) + .unwrap(); + assert!(matches!(result.mode, Mode::manual)); + assert_eq!( + result.external, + vec!["default".to_owned(), "extra_modules".to_owned()] + ); + } + + #[test] + fn test_parse_annotation_powershell_no_match() { + let code = r#" +param() +Import-Module PSWriteColor +Write-Host "Hello" +"#; + + let result = WorkspaceDependenciesAnnotatedRefs::::parse( + "#", + "modules_json", + code, + None, + "", + ); + assert!(result.is_none()); + } + + #[test] + fn test_parse_annotation_powershell_modules_json_with_inline() { + let code = r#" +# modules_json: default +#{ "modules": { "Extra": "1.0" } } +param() +"#; + + let result = WorkspaceDependenciesAnnotatedRefs::::parse( + "#", + "modules_json", + code, + None, + "", + ) + .unwrap(); + assert!(matches!(result.mode, Mode::manual)); + assert_eq!(result.external, vec!["default".to_owned()]); + assert!(result.inline.is_some()); + assert!(result.inline.as_ref().unwrap().contains("Extra")); + } + #[test] fn test_parse_annotation_blacklisted() { let code = r#" diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 5aba7162e1..6dc910e0d4 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -149,11 +149,16 @@ pub enum ObjectType { WorkspaceDependencies, } +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28160/sync-script-to-git-repo-windmill"; + #[derive(Serialize, Deserialize, Debug)] pub struct GitRepositorySettings { #[serde(skip_serializing_if = "Option::is_none")] pub exclude_types_override: Option>, - pub script_path: String, + /// None means auto-managed: always use LATEST_GIT_SYNC_SCRIPT_PATH. + /// Some(path) means pinned to a specific script. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub script_path: Option, pub git_repo_resource_path: String, pub use_individual_branch: Option, pub group_by_folder: Option, @@ -164,23 +169,26 @@ pub struct GitRepositorySettings { } impl GitRepositorySettings { + pub fn effective_script_path(&self) -> &str { + self.script_path + .as_deref() + .unwrap_or(LATEST_GIT_SYNC_SCRIPT_PATH) + } + pub fn is_script_meets_min_version(&self, min_version: u32) -> error::Result { + let path = self.effective_script_path(); // example: "hub/28102/sync-script-to-git-repo-windmill" - let current = self - .script_path + let current = path .split("/") // -> ["hub" "28102" "sync-script-to-git-repo-windmill"] .skip(1) // omit "hub" .next() // get numeric id .ok_or(Error::InternalErr(format!( "cannot get script version id from: {}", - &self.script_path + path )))? .parse() .unwrap_or_else(|e| { - tracing::warn!( - "cannot get script version id from: {}. e: {e}", - &self.script_path - ); + tracing::warn!("cannot get script version id from: {}. e: {e}", path); u32::MAX }); diff --git a/backend/windmill-common/tests/fixtures/base.sql b/backend/windmill-common/tests/fixtures/base.sql index 7db9918fba..412fa1029f 100644 --- a/backend/windmill-common/tests/fixtures/base.sql +++ b/backend/windmill-common/tests/fixtures/base.sql @@ -33,9 +33,9 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/windmill-common/tests/notify_events.rs b/backend/windmill-common/tests/notify_events.rs index 36302b8e66..b9f639caea 100644 --- a/backend/windmill-common/tests/notify_events.rs +++ b/backend/windmill-common/tests/notify_events.rs @@ -40,13 +40,20 @@ async fn count_events_for_channel(db: &Pool, channel: &str) -> i64 { #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_get_latest_event_id_returns_valid_id(db: Pool) { // Get current latest id - let latest_id = get_latest_event_id(&db).await.expect("Should get latest event id"); + let latest_id = get_latest_event_id(&db) + .await + .expect("Should get latest event id"); assert!(latest_id >= 0, "Latest id should be non-negative"); // Insert a new event and verify latest_id increases let new_id = insert_test_event(&db, "test_latest_id", "payload").await; - let new_latest_id = get_latest_event_id(&db).await.expect("Should get latest event id"); - assert!(new_latest_id >= new_id, "Latest id should be >= new event id"); + let new_latest_id = get_latest_event_id(&db) + .await + .expect("Should get latest event id"); + assert!( + new_latest_id >= new_id, + "Latest id should be >= new event id" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -55,7 +62,9 @@ async fn test_get_latest_event_id_with_events(db: Pool) { let _id2 = insert_test_event(&db, "test_channel_2", "payload2").await; let id3 = insert_test_event(&db, "test_channel_3", "payload3").await; - let latest_id = get_latest_event_id(&db).await.expect("Should get latest event id"); + let latest_id = get_latest_event_id(&db) + .await + .expect("Should get latest event id"); assert!(latest_id >= id3, "Latest id should be >= last inserted id"); } @@ -65,8 +74,13 @@ async fn test_poll_notify_events_no_new_events(db: Pool) { let latest_id = get_latest_event_id(&db).await.unwrap(); // Poll from the latest id - should return empty since no new events - let events = poll_notify_events(&db, latest_id).await.expect("Should poll events"); - assert!(events.is_empty(), "Should return empty vec when polling from latest id"); + let events = poll_notify_events(&db, latest_id) + .await + .expect("Should poll events"); + assert!( + events.is_empty(), + "Should return empty vec when polling from latest id" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -76,7 +90,9 @@ async fn test_poll_notify_events_returns_new_events(db: Pool) { let _id1 = insert_test_event(&db, "test_poll_channel", "payload1").await; let _id2 = insert_test_event(&db, "test_poll_channel", "payload2").await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); assert!(events.len() >= 2, "Should return at least 2 new events"); // Verify the events we inserted are present @@ -87,7 +103,10 @@ async fn test_poll_notify_events_returns_new_events(db: Pool) { assert_eq!(our_events.len(), 2, "Should have exactly our 2 test events"); // Verify ordering (ascending by id) - assert!(our_events[0].id < our_events[1].id, "Events should be ordered by id ascending"); + assert!( + our_events[0].id < our_events[1].id, + "Events should be ordered by id ascending" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -97,14 +116,19 @@ async fn test_poll_notify_events_respects_last_event_id(db: Pool) { let _id3 = insert_test_event(&db, "test_respect_id", "payload3").await; // Poll from id1 should only return id2 and id3 - let events = poll_notify_events(&db, id1).await.expect("Should poll events"); + let events = poll_notify_events(&db, id1) + .await + .expect("Should poll events"); let our_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_respect_id") .collect(); assert_eq!(our_events.len(), 2, "Should only return events after id1"); - assert!(our_events.iter().all(|e| e.id > id1), "All events should have id > id1"); + assert!( + our_events.iter().all(|e| e.id > id1), + "All events should have id > id1" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -124,21 +148,24 @@ async fn test_cleanup_old_events(db: Pool) { .expect("Failed to insert old event"); // Insert a recent event - sqlx::query( - "INSERT INTO notify_event (channel, payload) VALUES ($1, $2)", - ) - .bind(&recent_channel) - .bind("recent_payload") - .execute(&db) - .await - .expect("Failed to insert recent event"); + sqlx::query("INSERT INTO notify_event (channel, payload) VALUES ($1, $2)") + .bind(&recent_channel) + .bind("recent_payload") + .execute(&db) + .await + .expect("Failed to insert recent event"); // Count before cleanup let old_count_before = count_events_for_channel(&db, &old_channel).await; - assert_eq!(old_count_before, 1, "Should have 1 old event before cleanup"); + assert_eq!( + old_count_before, 1, + "Should have 1 old event before cleanup" + ); // Cleanup events older than 10 minutes - let deleted = cleanup_old_events(&db, 10).await.expect("Should cleanup events"); + let deleted = cleanup_old_events(&db, 10) + .await + .expect("Should cleanup events"); assert!(deleted >= 1, "Should delete at least 1 old event"); // Verify old event is gone @@ -167,13 +194,18 @@ async fn test_trigger_notify_config_change(db: Pool) { .await .expect("Failed to insert config"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let config_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_config_change" && e.payload == "test_config_trigger") .collect(); - assert!(!config_events.is_empty(), "Should have notify_config_change event"); + assert!( + !config_events.is_empty(), + "Should have notify_config_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -190,13 +222,18 @@ async fn test_trigger_notify_global_setting_change_insert(db: Pool) { .await .expect("Failed to insert global setting"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let setting_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name) .collect(); - assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on insert"); + assert!( + !setting_events.is_empty(), + "Should have notify_global_setting_change event on insert" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -220,13 +257,18 @@ async fn test_trigger_notify_global_setting_change_update(db: Pool) { .await .expect("Failed to update global setting"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let setting_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name) .collect(); - assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on update"); + assert!( + !setting_events.is_empty(), + "Should have notify_global_setting_change event on update" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -250,13 +292,18 @@ async fn test_trigger_notify_global_setting_change_delete(db: Pool) { .await .expect("Failed to delete global setting"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let setting_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_global_setting_change" && e.payload == setting_name) .collect(); - assert!(!setting_events.is_empty(), "Should have notify_global_setting_change event on delete"); + assert!( + !setting_events.is_empty(), + "Should have notify_global_setting_change event on delete" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -272,13 +319,18 @@ async fn test_trigger_notify_workspace_envs_change(db: Pool) { .await .expect("Failed to insert workspace env"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let env_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_workspace_envs_change" && e.payload == "test-workspace") .collect(); - assert!(!env_events.is_empty(), "Should have notify_workspace_envs_change event"); + assert!( + !env_events.is_empty(), + "Should have notify_workspace_envs_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -294,44 +346,57 @@ async fn test_trigger_notify_workspace_key_change(db: Pool) { .await .expect("Failed to insert workspace key"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let key_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_workspace_key_change" && e.payload == "test-workspace") .collect(); - assert!(!key_events.is_empty(), "Should have notify_workspace_key_change event"); + assert!( + !key_events.is_empty(), + "Should have notify_workspace_key_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] async fn test_trigger_notify_token_invalidation(db: Pool) { - // First insert a session token + // First insert a session token with token_hash and token_prefix let token = format!("test_token_{}", uuid::Uuid::new_v4()); + let token_hash = windmill_common::utils::calculate_hash(&token); + let token_prefix = &token[..10]; sqlx::query( - "INSERT INTO token (token, label, email, workspace_id, owner, expiration) - VALUES ($1, 'session', 'test@test.com', 'test-workspace', 'test-user', now() + interval '1 hour')", + "INSERT INTO token (token_hash, token_prefix, label, email, workspace_id, owner, expiration) + VALUES ($1, $2, 'session', 'test@test.com', 'test-workspace', 'test-user', now() + interval '1 hour')", ) - .bind(&token) + .bind(&token_hash) + .bind(token_prefix) .execute(&db) .await .expect("Failed to insert token"); let before_id = get_latest_event_id(&db).await.unwrap(); - // Delete the token (should trigger notification) - sqlx::query("DELETE FROM token WHERE token = $1") - .bind(&token) + // Delete the token (should trigger notification with prefix) + sqlx::query("DELETE FROM token WHERE token_hash = $1") + .bind(&token_hash) .execute(&db) .await .expect("Failed to delete token"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let token_events: Vec<_> = events .iter() - .filter(|e| e.channel == "notify_token_invalidation" && e.payload == token) + .filter(|e| e.channel == "notify_token_invalidation" && e.payload == token_prefix) .collect(); - assert!(!token_events.is_empty(), "Should have notify_token_invalidation event"); + assert!( + !token_events.is_empty(), + "Should have notify_token_invalidation event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -344,13 +409,18 @@ async fn test_trigger_notify_webhook_change(db: Pool) { .await .expect("Failed to update webhook"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let webhook_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_webhook_change" && e.payload == "test-workspace") .collect(); - assert!(!webhook_events.is_empty(), "Should have notify_webhook_change event"); + assert!( + !webhook_events.is_empty(), + "Should have notify_webhook_change event" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -363,13 +433,18 @@ async fn test_trigger_notify_workspace_premium_change(db: Pool) { .await .expect("Failed to update workspace premium"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let premium_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_workspace_premium_change" && e.payload == "test-workspace") .collect(); - assert!(!premium_events.is_empty(), "Should have notify_workspace_premium_change event"); + assert!( + !premium_events.is_empty(), + "Should have notify_workspace_premium_change event" + ); } // ============================================================================ @@ -392,14 +467,19 @@ async fn test_trigger_notify_http_trigger_change(db: Pool) { .await .expect("Failed to insert HTTP trigger"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let http_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_http_trigger_change") .filter(|e| e.payload.contains("test-workspace") && e.payload.contains(&trigger_path)) .collect(); - assert!(!http_events.is_empty(), "Should have notify_http_trigger_change event"); + assert!( + !http_events.is_empty(), + "Should have notify_http_trigger_change event" + ); } // ============================================================================ @@ -431,19 +511,27 @@ async fn test_trigger_notify_runnable_version_change_script(db: Pool) .await .expect("Failed to update script lock"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let script_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_runnable_version_change") .filter(|e| e.payload.contains("test-workspace") && e.payload.contains("script")) .collect(); - assert!(!script_events.is_empty(), "Should have notify_runnable_version_change event for script"); + assert!( + !script_events.is_empty(), + "Should have notify_runnable_version_change event for script" + ); // Verify payload format: workspace_id:source_type:path:kind let parts: Vec<&str> = script_events[0].payload.split(':').collect(); assert!(parts.len() >= 4, "Payload should have at least 4 parts"); - assert_eq!(parts[0], "test-workspace", "First part should be workspace_id"); + assert_eq!( + parts[0], "test-workspace", + "First part should be workspace_id" + ); assert_eq!(parts[1], "script", "Second part should be 'script'"); } @@ -472,19 +560,27 @@ async fn test_trigger_notify_runnable_version_change_flow(db: Pool) { .await .expect("Failed to update flow versions"); - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let flow_events: Vec<_> = events .iter() .filter(|e| e.channel == "notify_runnable_version_change") .filter(|e| e.payload.contains("test-workspace") && e.payload.contains("flow")) .collect(); - assert!(!flow_events.is_empty(), "Should have notify_runnable_version_change event for flow"); + assert!( + !flow_events.is_empty(), + "Should have notify_runnable_version_change event for flow" + ); // Verify payload format let parts: Vec<&str> = flow_events[0].payload.split(':').collect(); assert!(parts.len() >= 4, "Payload should have at least 4 parts"); - assert_eq!(parts[0], "test-workspace", "First part should be workspace_id"); + assert_eq!( + parts[0], "test-workspace", + "First part should be workspace_id" + ); assert_eq!(parts[1], "flow", "Second part should be 'flow'"); } @@ -521,13 +617,16 @@ async fn test_concurrent_event_insertion(db: Pool) { handle.await.expect("Task should complete"); } - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); - let concurrent_events: Vec<_> = events - .iter() - .filter(|e| e.channel == channel) - .collect(); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); + let concurrent_events: Vec<_> = events.iter().filter(|e| e.channel == channel).collect(); - assert_eq!(concurrent_events.len(), 10, "Should have all 10 concurrent events"); + assert_eq!( + concurrent_events.len(), + 10, + "Should have all 10 concurrent events" + ); // Verify all events have unique IDs let ids: std::collections::HashSet = concurrent_events.iter().map(|e| e.id).collect(); @@ -571,18 +670,45 @@ async fn test_polling_isolation(db: Pool) { .expect("Failed to insert event"); // Two different "consumers" polling from different points - let events_from_baseline = poll_notify_events(&db, baseline_id).await.expect("Should poll events"); - let events_from_id1 = poll_notify_events(&db, id1).await.expect("Should poll events"); - let events_from_id2 = poll_notify_events(&db, id2).await.expect("Should poll events"); + let events_from_baseline = poll_notify_events(&db, baseline_id) + .await + .expect("Should poll events"); + let events_from_id1 = poll_notify_events(&db, id1) + .await + .expect("Should poll events"); + let events_from_id2 = poll_notify_events(&db, id2) + .await + .expect("Should poll events"); // Filter to our test events - let from_baseline: Vec<_> = events_from_baseline.iter().filter(|e| e.channel == channel).collect(); - let from_id1: Vec<_> = events_from_id1.iter().filter(|e| e.channel == channel).collect(); - let from_id2: Vec<_> = events_from_id2.iter().filter(|e| e.channel == channel).collect(); + let from_baseline: Vec<_> = events_from_baseline + .iter() + .filter(|e| e.channel == channel) + .collect(); + let from_id1: Vec<_> = events_from_id1 + .iter() + .filter(|e| e.channel == channel) + .collect(); + let from_id2: Vec<_> = events_from_id2 + .iter() + .filter(|e| e.channel == channel) + .collect(); - assert_eq!(from_baseline.len(), 3, "Polling from baseline should include all 3 events"); - assert_eq!(from_id1.len(), 2, "Polling from id1 should include id2 and id3"); - assert_eq!(from_id2.len(), 1, "Polling from id2 should include only id3"); + assert_eq!( + from_baseline.len(), + 3, + "Polling from baseline should include all 3 events" + ); + assert_eq!( + from_id1.len(), + 2, + "Polling from id1 should include id2 and id3" + ); + assert_eq!( + from_id2.len(), + 1, + "Polling from id2 should include only id3" + ); } // ============================================================================ @@ -595,14 +721,23 @@ async fn test_empty_payload(db: Pool) { insert_test_event(&db, "test_empty_payload", "").await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let empty_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_empty_payload") .collect(); - assert_eq!(empty_events.len(), 1, "Should have event with empty payload"); - assert_eq!(empty_events[0].payload, "", "Payload should be empty string"); + assert_eq!( + empty_events.len(), + 1, + "Should have event with empty payload" + ); + assert_eq!( + empty_events[0].payload, "", + "Payload should be empty string" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -613,14 +748,24 @@ async fn test_large_payload(db: Pool) { let large_payload = "x".repeat(1024); insert_test_event(&db, "test_large_payload", &large_payload).await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let large_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_large_payload") .collect(); - assert_eq!(large_events.len(), 1, "Should have event with large payload"); - assert_eq!(large_events[0].payload.len(), 1024, "Payload should be preserved"); + assert_eq!( + large_events.len(), + 1, + "Should have event with large payload" + ); + assert_eq!( + large_events[0].payload.len(), + 1024, + "Payload should be preserved" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -630,14 +775,23 @@ async fn test_special_characters_in_payload(db: Pool) { let special_payload = r#"{"key": "value with \"quotes\" and 'apostrophes'", "unicode": "日本語", "newline": "line1\nline2"}"#; insert_test_event(&db, "test_special_chars", special_payload).await; - let events = poll_notify_events(&db, before_id).await.expect("Should poll events"); + let events = poll_notify_events(&db, before_id) + .await + .expect("Should poll events"); let special_events: Vec<_> = events .iter() .filter(|e| e.channel == "test_special_chars") .collect(); - assert_eq!(special_events.len(), 1, "Should have event with special characters"); - assert_eq!(special_events[0].payload, special_payload, "Special characters should be preserved"); + assert_eq!( + special_events.len(), + 1, + "Should have event with special characters" + ); + assert_eq!( + special_events[0].payload, special_payload, + "Special characters should be preserved" + ); } #[sqlx::test(migrations = "../migrations", fixtures("base"))] @@ -664,7 +818,9 @@ async fn test_cleanup_with_no_old_events(db: Pool) { assert_eq!(before_count, 2, "Should have 2 recent events"); // Cleanup old events (none of our events should be deleted since they're recent) - let _deleted = cleanup_old_events(&db, 10).await.expect("Should cleanup events"); + let _deleted = cleanup_old_events(&db, 10) + .await + .expect("Should cleanup events"); let after_count = count_events_for_channel(&db, &channel).await; assert_eq!(after_count, 2, "Recent events should not be deleted"); @@ -732,7 +888,11 @@ impl ServerProcess { } fn logs_contain(&self, needle: &str) -> bool { - self.log_lines.lock().unwrap().iter().any(|l| l.contains(needle)) + self.log_lines + .lock() + .unwrap() + .iter() + .any(|l| l.contains(needle)) } fn dump_logs(&self) -> String { @@ -783,12 +943,17 @@ async fn test_two_server_processes_both_receive_event() { let mut server_b = ServerProcess::start(19200, &db_url); // Wait for both servers to be ready - let (ready_a, ready_b) = tokio::join!( - wait_for_server(19100, 30), - wait_for_server(19200, 30), + let (ready_a, ready_b) = tokio::join!(wait_for_server(19100, 30), wait_for_server(19200, 30),); + assert!( + ready_a, + "Server A (port 19100) failed to start. Logs:\n{}", + server_a.dump_logs() + ); + assert!( + ready_b, + "Server B (port 19200) failed to start. Logs:\n{}", + server_b.dump_logs() ); - assert!(ready_a, "Server A (port 19100) failed to start. Logs:\n{}", server_a.dump_logs()); - assert!(ready_b, "Server B (port 19200) failed to start. Logs:\n{}", server_b.dump_logs()); // Give servers a moment to complete their first poll cycle tokio::time::sleep(std::time::Duration::from_secs(2)).await; diff --git a/backend/windmill-native-triggers/src/google/external.rs b/backend/windmill-native-triggers/src/google/external.rs index 756090fa02..f68706fb2f 100644 --- a/backend/windmill-native-triggers/src/google/external.rs +++ b/backend/windmill-native-triggers/src/google/external.rs @@ -11,7 +11,7 @@ use windmill_common::{ use windmill_queue::PushArgsOwned; use crate::{ - generate_webhook_service_url, get_token_by_prefix, + generate_webhook_service_url, rotate_webhook_token, sync::{SyncAction, SyncError, TriggerSyncInfo}, update_native_trigger_error, update_native_trigger_service_config, External, NativeTrigger, NativeTriggerData, ServiceName, @@ -309,14 +309,16 @@ impl Google { } /// Renew an expiring Google watch channel. - /// Stops the old channel and creates a new one with the same channel ID. - /// Returns the updated service_config with new expiration. + /// Rotates the webhook token (creating a new one with the same label), + /// stops the old channel and creates a new one with the same channel ID. + /// Returns (new_service_config, new_plaintext_token, old_token_hash). + /// Callers should delete old_token_hash after successfully updating the trigger. pub async fn renew_channel( &self, w_id: &str, trigger: &NativeTrigger, db: &DB, - ) -> Result { + ) -> Result<(serde_json::Value, String, String)> { let config: GoogleServiceConfig = trigger .service_config .as_ref() @@ -324,10 +326,15 @@ impl Google { .transpose()? .ok_or_else(|| Error::InternalErr("Missing service config".to_string()))?; - let webhook_token = get_token_by_prefix(db, &trigger.webhook_token_prefix) - .await? - .ok_or_else(|| Error::InternalErr("Webhook token not found".to_string()))?; - + let rotated = match rotate_webhook_token(db, &trigger.webhook_token_hash).await? { + Some(r) => r, + None => { + return Err(Error::InternalErr(format!( + "Cannot renew channel {}: webhook token no longer exists and no user context to create a fresh one", + trigger.external_id + ))); + } + }; let base_url = &*BASE_URL.read().await; // Reuse the same channel ID so external_id stays permanent let channel_id = trigger.external_id.clone(); @@ -338,7 +345,7 @@ impl Google { trigger.is_flow, Some(&channel_id), ServiceName::Google, - &webhook_token, + &rotated.new_token, ); tracing::info!( @@ -403,8 +410,9 @@ impl Google { new_config.google_resource_id = Some(resp.resource_id); new_config.expiration = Some(resp.expiration); - serde_json::to_value(&new_config) - .map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e))) + let config_value = serde_json::to_value(&new_config) + .map_err(|e| Error::internal_err(format!("Failed to serialize config: {}", e)))?; + Ok((config_value, rotated.new_token, rotated.old_token_hash)) } } @@ -461,17 +469,25 @@ async fn renew_expiring_channels( ); match handler.renew_channel(workspace_id, trigger, db).await { - Ok(new_config) => { + Ok((new_config, new_token, old_token_hash)) => { match update_native_trigger_service_config( db, workspace_id, ServiceName::Google, &trigger.external_id, &new_config, + Some(&new_token), ) .await { Ok(()) => { + // Trigger updated — clean up old token (best-effort) + if let Err(e) = crate::delete_token_by_hash(db, &old_token_hash).await { + tracing::warn!( + "Failed to delete old webhook token after channel renewal for {}: {}", + trigger.external_id, e + ); + } tracing::info!( "Renewed Google channel {} for '{}'", trigger.external_id, diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 9384bde8b6..b66a8ae3e0 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -1,6 +1,6 @@ use crate::{ - decrypt_oauth_data, delete_native_trigger, delete_token_by_prefix, get_native_trigger, - get_token_by_prefix, list_native_triggers, store_native_trigger, update_native_trigger_error, + decrypt_oauth_data, delete_native_trigger, delete_token_by_hash, get_native_trigger, + list_native_triggers, rotate_webhook_token, store_native_trigger, update_native_trigger_error, External, NativeTrigger, NativeTriggerConfig, NativeTriggerData, ServiceName, }; use axum::{ @@ -234,31 +234,48 @@ async fn update_native_trigger_handler( let runnable_changed = existing.script_path != data.script_path || existing.is_flow != data.is_flow; - let webhook_token = match get_token_by_prefix(&db, &existing.webhook_token_prefix).await? { - Some(token) if !runnable_changed => token, - existing_token => { - if let Some(_) = existing_token { - delete_token_by_prefix(&db, &existing.webhook_token_prefix).await?; - } else { - tracing::warn!( - "Webhook token not found for trigger {} (prefix: {}), recreating token", - external_id, - existing.webhook_token_prefix - ); + // Track old token hash so we can clean it up after everything succeeds + let mut old_token_hash_to_delete: Option = None; + + let webhook_token = if runnable_changed { + // Scopes change when the runnable changes — delete old, create fresh token + old_token_hash_to_delete = Some(existing.webhook_token_hash.clone()); + let token = new_webhook_token( + &mut *tx, + &db, + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + service_name, + ) + .await?; + tx.commit().await?; + tx = user_db.begin(&authed).await?; + token + } else { + // Same runnable — rotate the token keeping the same label + match rotate_webhook_token(&db, &existing.webhook_token_hash).await? { + Some(rotated) => { + old_token_hash_to_delete = Some(rotated.old_token_hash); + rotated.new_token + } + None => { + // Old token gone — create a fresh one + let token = new_webhook_token( + &mut *tx, + &db, + &authed, + &data.script_path, + data.is_flow, + &workspace_id, + service_name, + ) + .await?; + tx.commit().await?; + tx = user_db.begin(&authed).await?; + token } - let token = new_webhook_token( - &mut *tx, - &db, - &authed, - &data.script_path, - data.is_flow, - &workspace_id, - service_name, - ) - .await?; - tx.commit().await?; - tx = user_db.begin(&authed).await?; - token } }; @@ -303,6 +320,16 @@ async fn update_native_trigger_handler( tx.commit().await?; + // Everything succeeded — clean up old token (best-effort) + if let Some(old_hash) = old_token_hash_to_delete { + if let Err(e) = delete_token_by_hash(&db, &old_hash).await { + tracing::warn!( + "Failed to delete old webhook token after trigger update: {}", + e + ); + } + } + Ok(format!("Native trigger updated")) } @@ -428,12 +455,12 @@ async fn delete_native_trigger_handler( return Err(Error::NotFound(format!("Native trigger not found"))); } - // Delete the webhook token using its prefix - if !delete_token_by_prefix(&db, &existing.webhook_token_prefix).await? { + // Delete the webhook token using its hash + if !delete_token_by_hash(&db, &existing.webhook_token_hash).await? { tracing::warn!( - "Webhook token not found when deleting trigger {} (prefix: {})", + "Webhook token not found when deleting trigger {} (hash: {})", external_id, - existing.webhook_token_prefix + existing.webhook_token_hash ); } diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index ed22480a9a..25234ff3f0 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -190,7 +190,7 @@ pub struct NativeTrigger { pub service_name: ServiceName, pub script_path: String, pub is_flow: bool, - pub webhook_token_prefix: String, + pub webhook_token_hash: String, pub service_config: Option, pub error: Option, pub created_at: DateTime, @@ -443,10 +443,16 @@ pub async fn make_http_request( request = request.json(body_content); } - let response = request.send().await?.error_for_status()?; + let response = request.send().await?; + let status = response.status(); + let bytes = response.bytes().await?; + + if !status.is_success() { + let body = String::from_utf8_lossy(&bytes); + return Err(HttpRequestError::ApiError { status, body: body.into_owned() }); + } // Handle empty responses (e.g. 204 No Content from Google channels/stop) - let bytes = response.bytes().await?; if bytes.is_empty() { serde_json::from_str("null").map_err(HttpRequestError::Json) } else { @@ -458,6 +464,7 @@ pub async fn make_http_request( pub enum HttpRequestError { Reqwest(reqwest::Error), Json(serde_json::Error), + ApiError { status: StatusCode, body: String }, } impl std::fmt::Display for HttpRequestError { @@ -465,6 +472,9 @@ impl std::fmt::Display for HttpRequestError { match self { HttpRequestError::Reqwest(e) => write!(f, "{}", e), HttpRequestError::Json(e) => write!(f, "JSON decode error: {}", e), + HttpRequestError::ApiError { status, body } => { + write!(f, "HTTP {} error: {}", status.as_u16(), body) + } } } } @@ -474,6 +484,7 @@ impl std::error::Error for HttpRequestError { match self { HttpRequestError::Reqwest(e) => Some(e), HttpRequestError::Json(e) => Some(e), + HttpRequestError::ApiError { .. } => None, } } } @@ -489,6 +500,7 @@ impl HttpRequestError { match self { HttpRequestError::Reqwest(e) => e.status(), HttpRequestError::Json(_) => None, + HttpRequestError::ApiError { status, .. } => Some(*status), } } } @@ -719,41 +731,85 @@ async fn update_oauth_token_resource( } } -/// Look up the full token from the token table using its prefix -pub async fn get_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>( - db: E, - token_prefix: &str, -) -> Result> { - let token = sqlx::query_scalar!( - r#" - SELECT token as "token!" - FROM token - WHERE token LIKE concat($1::text, '%') - LIMIT 1 - "#, - token_prefix +/// Create a new webhook token that keeps the same label as the old one. +/// The old token is **not** deleted — callers must call `delete_token_by_hash` +/// on `old_token_hash` after the trigger row has been successfully updated. +/// This ensures the trigger keeps working if the external service call or +/// subsequent DB update fails. +/// +/// Returns `Ok(None)` if the old token no longer exists (e.g. manually deleted by user). +/// In that case, `renew_channel` returns an error which `renew_expiring_channels` writes +/// to the trigger's `error` column — visible in the UI so the user can re-create the trigger. +pub async fn rotate_webhook_token(db: &DB, old_token_hash: &str) -> Result> { + use windmill_common::auth::{hash_token, TOKEN_PREFIX_LEN}; + use windmill_common::min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH; + use windmill_common::utils::rd_string; + + let old = match sqlx::query!( + "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1", + old_token_hash ) .fetch_optional(db) - .await?; + .await? + { + Some(row) => row, + None => { + tracing::warn!( + "Webhook token not found for hash {}, caller should create a fresh token", + old_token_hash + ); + return Ok(None); + } + }; - Ok(token) -} + let new_token = rd_string(32); + let new_hash = hash_token(&new_token); + let new_prefix = new_token.get(..TOKEN_PREFIX_LEN).unwrap_or(&new_token); + let plaintext: Option<&str> = if MIN_VERSION_SUPPORTS_TOKEN_HASH.met().await { + None + } else { + Some(&new_token) + }; -/// Delete a token from the token table using its prefix -pub async fn delete_token_by_prefix<'c, E: sqlx::Executor<'c, Database = Postgres>>( - db: E, - token_prefix: &str, -) -> Result { - let deleted = sqlx::query!( - r#" - DELETE FROM token - WHERE token LIKE concat($1::text, '%') - "#, - token_prefix + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes, workspace_id, owner, expiration) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + new_hash, + new_prefix, + plaintext as Option<&str>, + old.email, + old.label, + old.super_admin, + old.scopes.as_deref(), + old.workspace_id, + old.owner, + old.expiration, ) .execute(db) - .await? - .rows_affected(); + .await?; + + Ok(Some(RotatedToken { + new_token, + old_token_hash: old_token_hash.to_string(), + })) +} + +pub struct RotatedToken { + pub new_token: String, + /// Hash of the old token — callers should delete this after the + /// trigger row has been successfully updated to point at the new token. + pub old_token_hash: String, +} + +/// Delete a token from the token table using its hash (exact match). +pub async fn delete_token_by_hash<'c, E: sqlx::Executor<'c, Database = Postgres>>( + db: E, + token_hash: &str, +) -> Result { + let deleted = sqlx::query!("DELETE FROM token WHERE token_hash = $1", token_hash) + .execute(db) + .await? + .rows_affected(); Ok(deleted > 0) } @@ -766,8 +822,9 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> config: &NativeTriggerConfig, service_config: C, ) -> Result<()> { - // Store only the first 10 characters of the webhook token as a prefix - let webhook_token_prefix: String = config.webhook_token.chars().take(10).collect(); + use windmill_common::auth::hash_token; + + let webhook_token_hash = hash_token(&config.webhook_token); sqlx::query!( r#" @@ -777,20 +834,20 @@ pub async fn store_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres> service_name, script_path, is_flow, - webhook_token_prefix, + webhook_token_hash, service_config ) VALUES ( $1, $2, $3, $4, $5, $6, $7 ) ON CONFLICT (external_id, workspace_id, service_name) - DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_prefix = $6, service_config = $7, error = NULL, updated_at = NOW() + DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW() "#, external_id, workspace_id, service_name as ServiceName, config.script_path, config.is_flow, - webhook_token_prefix, + webhook_token_hash, sqlx::types::Json(service_config) as _, ) .execute(db) @@ -807,13 +864,14 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres config: &NativeTriggerConfig, service_config: Option<&RawValue>, ) -> Result<()> { - // Store only the first 10 characters of the webhook token as a prefix - let webhook_token_prefix: String = config.webhook_token.chars().take(10).collect(); + use windmill_common::auth::hash_token; + + let webhook_token_hash = hash_token(&config.webhook_token); sqlx::query!( r#" UPDATE native_trigger - SET script_path = $1, is_flow = $2, webhook_token_prefix = $3, service_config = $4, error = NULL, updated_at = NOW() + SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW() WHERE workspace_id = $5 AND service_name = $6 @@ -821,7 +879,7 @@ pub async fn update_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres "#, config.script_path, config.is_flow, - webhook_token_prefix, + webhook_token_hash, service_config.map(sqlx::types::Json) as _, workspace_id, service_name as ServiceName, @@ -872,7 +930,7 @@ pub async fn get_native_trigger<'c, E: sqlx::Executor<'c, Database = Postgres>>( service_name AS "service_name!: ServiceName", script_path, is_flow, - webhook_token_prefix, + webhook_token_hash, service_config, error, created_at, @@ -910,7 +968,7 @@ pub async fn get_native_trigger_by_script<'c, E: sqlx::Executor<'c, Database = P service_name AS "service_name!: ServiceName", script_path, is_flow, - webhook_token_prefix, + webhook_token_hash, service_config, error, created_at, @@ -956,7 +1014,7 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> nt.service_name AS "service_name!: ServiceName", nt.script_path, nt.is_flow, - nt.webhook_token_prefix, + nt.webhook_token_hash, nt.service_config, nt.error, nt.created_at, @@ -1033,11 +1091,16 @@ pub async fn update_native_trigger_service_config< service_name: ServiceName, external_id: &str, service_config: &serde_json::Value, + new_webhook_token: Option<&str>, ) -> Result<()> { + let new_hash = new_webhook_token.map(windmill_common::auth::hash_token); + sqlx::query!( r#" UPDATE native_trigger - SET service_config = $1, updated_at = NOW() + SET service_config = $1, + webhook_token_hash = COALESCE($5, webhook_token_hash), + updated_at = NOW() WHERE workspace_id = $2 AND service_name = $3 @@ -1047,6 +1110,7 @@ pub async fn update_native_trigger_service_config< workspace_id, service_name as ServiceName, external_id, + new_hash.as_deref(), ) .execute(db) .await?; diff --git a/backend/windmill-native-triggers/src/sync.rs b/backend/windmill-native-triggers/src/sync.rs index 873ba22557..292d00de2a 100644 --- a/backend/windmill-native-triggers/src/sync.rs +++ b/backend/windmill-native-triggers/src/sync.rs @@ -390,6 +390,7 @@ pub async fn reconcile_with_external_state( service_name, &trigger.external_id, external_service_config, + None, ) .await { diff --git a/backend/windmill-native-triggers/src/workspace_integrations.rs b/backend/windmill-native-triggers/src/workspace_integrations.rs index 3d3db5eea5..87d40d5b05 100644 --- a/backend/windmill-native-triggers/src/workspace_integrations.rs +++ b/backend/windmill-native-triggers/src/workspace_integrations.rs @@ -34,8 +34,8 @@ use windmill_api_auth::ApiAuthed; #[cfg(feature = "native_trigger")] use crate::{ - decrypt_oauth_data, delete_token_by_prefix, delete_workspace_integration, - nextcloud::OcsResponse, resolve_endpoint, store_workspace_integration, ServiceName, + decrypt_oauth_data, delete_token_by_hash, delete_workspace_integration, nextcloud::OcsResponse, + resolve_endpoint, store_workspace_integration, ServiceName, }; #[cfg(feature = "native_trigger")] @@ -253,7 +253,7 @@ async fn fetch_nextcloud_user_id(base_url: &str, access_token: &str) -> anyhow:: #[cfg(feature = "native_trigger")] async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: ServiceName) { let triggers = sqlx::query!( - "SELECT external_id, webhook_token_prefix FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", + "SELECT external_id, webhook_token_hash FROM native_trigger WHERE workspace_id = $1 AND service_name = $2", workspace_id, service_name as ServiceName ) @@ -303,10 +303,10 @@ async fn delete_triggers_for_service(db: &DB, workspace_id: &str, service_name: // Delete all associated webhook tokens for trigger in &triggers { - if let Err(e) = delete_token_by_prefix(db, &trigger.webhook_token_prefix).await { + if let Err(e) = delete_token_by_hash(db, &trigger.webhook_token_hash).await { tracing::error!( - "Failed to delete webhook token with prefix {}: {e}", - trigger.webhook_token_prefix + "Failed to delete webhook token with hash {}: {e}", + trigger.webhook_token_hash ); } } diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 324ea6c0ed..655569dda1 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -466,6 +466,7 @@ pub async fn push_init_job<'c>( dedicated_worker: None, concurrency_settings: ConcurrencySettingsWithCustom::default(), debouncing_settings: DebouncingSettings::default(), + modules: None, }), PushArgs::from(&ehm), worker_name, @@ -523,6 +524,7 @@ pub async fn push_periodic_bash_job<'c>( dedicated_worker: None, concurrency_settings: ConcurrencySettingsWithCustom::default(), debouncing_settings: DebouncingSettings::default(), + modules: None, }), PushArgs::from(&ehm), worker_name, @@ -3030,8 +3032,16 @@ impl PulledJobResult { if let Some(s) = str_o.as_ref() { match serde_json::from_str::>>(s) { Ok(ref mut vec) => accumulated_arg.append(vec), - Err(e) => { - return Err(error::Error::ArgumentErr(format!("cannot consolidate arguments of non-list type. Type provided for argument `{arg_name_to_accumulate}` is not a list\nUnwrapped Error: {e}"))); + Err(_) => { + // Value is not an array — wrap the scalar into a + // single-element array. This supports union types + // like T | T[] where the caller may pass a bare T. + match RawValue::from_string(s.to_string()) { + Ok(raw) => accumulated_arg.push(raw), + Err(e) => { + return Err(error::Error::ArgumentErr(format!("cannot consolidate argument `{arg_name_to_accumulate}`: value is neither a valid list nor a valid JSON value\nUnwrapped Error: {e}"))); + } + } } } } @@ -4443,7 +4453,7 @@ async fn push_inner<'c, 'd>( mut tx: PushIsolationLevel<'c>, workspace_id: &str, job_payload: JobPayload, - args: PushArgs<'d>, + mut args: PushArgs<'d>, user: &str, mut email: &str, mut permissioned_as: String, @@ -4798,19 +4808,34 @@ async fn push_inner<'c, 'd>( dedicated_worker, concurrency_settings, debouncing_settings, - }) => JobPayloadUntagged { - runnable_id: hash, - runnable_path: path, - raw_code_tuple: Some((content, lock)), - job_kind: JobKind::Preview, - language: Some(language), - concurrency_settings: concurrency_settings.into(), - debouncing_settings, - cache_ttl, - cache_ignore_s3_path, - dedicated_worker, - ..Default::default() - }, + modules, + }) => { + // Inject modules into job args as _MODULES so the worker can extract them + if let Some(ref modules) = modules { + match serde_json::to_string(modules).and_then(|s| RawValue::from_string(s)) { + Ok(raw) => { + let extra = args.extra.get_or_insert_with(HashMap::new); + extra.insert("_MODULES".to_string(), raw); + } + Err(e) => { + tracing::warn!("Failed to serialize modules for preview job: {e}"); + } + } + } + JobPayloadUntagged { + runnable_id: hash, + runnable_path: path, + raw_code_tuple: Some((content, lock)), + job_kind: JobKind::Preview, + language: Some(language), + concurrency_settings: concurrency_settings.into(), + debouncing_settings, + cache_ttl, + cache_ignore_s3_path, + dedicated_worker, + ..Default::default() + } + } JobPayload::Dependencies { hash, language, diff --git a/backend/windmill-queue/tests/fixtures/base.sql b/backend/windmill-queue/tests/fixtures/base.sql index 7db9918fba..412fa1029f 100644 --- a/backend/windmill-queue/tests/fixtures/base.sql +++ b/backend/windmill-queue/tests/fixtures/base.sql @@ -33,9 +33,9 @@ INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES ('test-workspace', 'test3@windmill.dev', 'test-user-3', false, 'User'); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN', 'test@windmill.dev', 'test token', true); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); -insert INTO token(token, email, label, super_admin) VALUES ('SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_2'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_2', 'test2@windmill.dev', 'test token 2', false); +insert INTO token(token_hash, token_prefix, token, email, label, super_admin) VALUES (encode(sha256('SECRET_TOKEN_3'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN_3', 'test3@windmill.dev', 'test token 3', false); GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin; GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user; diff --git a/backend/windmill-runtime-nativets/src/windmill-client.js b/backend/windmill-runtime-nativets/src/windmill-client.js index 33e51577e6..24e781343e 100644 --- a/backend/windmill-runtime-nativets/src/windmill-client.js +++ b/backend/windmill-runtime-nativets/src/windmill-client.js @@ -259,8 +259,8 @@ var $Script = { visible_to_runner_only: { type: "boolean", }, - no_main_func: { - type: "boolean", + auto_kind: { + type: "string", }, codebase: { type: "string", @@ -281,7 +281,7 @@ var $Script = { "language", "kind", "starred", - "no_main_func", + "auto_kind", ], }; var $NewScript = { @@ -381,8 +381,8 @@ var $NewScript = { visible_to_runner_only: { type: "boolean", }, - no_main_func: { - type: "boolean", + auto_kind: { + type: "string", }, codebase: { type: "string", diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 6b414ca10f..531a31ea07 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -883,6 +883,15 @@ async fn delete_resource( } let mut tx = user_db.begin(&authed).await?; + // Fetch the resource value before deleting, so we can find linked $var: references + let resource_value: Option> = sqlx::query_scalar( + "SELECT value FROM resource WHERE path = $1 AND workspace_id = $2", + ) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + let deleted_path = sqlx::query_scalar!( "DELETE FROM resource WHERE path = $1 AND workspace_id = $2 RETURNING path", path, @@ -891,13 +900,32 @@ async fn delete_resource( .fetch_optional(&mut *tx) .await?; not_found_if_none(deleted_path, "Resource", &path)?; - sqlx::query!( - "DELETE FROM variable WHERE path = $1 AND workspace_id = $2", - path, - w_id - ) - .execute(&mut *tx) - .await?; + + // Collect all $var: paths referenced in the resource value + let mut linked_var_paths: Vec = Vec::new(); + if let Some(Some(value)) = resource_value { + collect_var_refs(&value, &mut linked_var_paths); + } + + // Delete linked variables that are actually referenced in the resource value + let deleted_linked_variables: Vec = if linked_var_paths.is_empty() { + Vec::new() + } else { + let placeholders: Vec = linked_var_paths + .iter() + .enumerate() + .map(|(i, _)| format!("${}", i + 2)) + .collect(); + let query = format!( + "DELETE FROM variable WHERE workspace_id = $1 AND path IN ({}) RETURNING path", + placeholders.join(", ") + ); + let mut q = sqlx::query_scalar::<_, String>(&query).bind(&w_id); + for var_path in &linked_var_paths { + q = q.bind(var_path); + } + q.fetch_all(&mut *tx).await? + }; audit_log( &mut *tx, &authed, @@ -924,12 +952,62 @@ async fn delete_resource( webhook.send_message( w_id.clone(), - WebhookMessage::DeleteResource { workspace: w_id, path: path.to_owned() }, + WebhookMessage::DeleteResource { workspace: w_id.clone(), path: path.to_owned() }, ); + for var_path in &deleted_linked_variables { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Variable { + path: var_path.clone(), + parent_path: Some(var_path.clone()), + }, + Some(format!( + "Variable '{}' deleted (linked resource deleted)", + var_path + )), + true, + None, + ) + .await?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::DeleteVariable { + workspace: w_id.clone(), + path: var_path.clone(), + }, + ); + } + Ok(format!("resource {} deleted", path)) } +/// Recursively collect all `$var:path` references from a JSON value. +fn collect_var_refs(value: &serde_json::Value, out: &mut Vec) { + match value { + serde_json::Value::String(s) => { + if let Some(var_path) = s.strip_prefix("$var:") { + out.push(var_path.to_string()); + } + } + serde_json::Value::Object(m) => { + for v in m.values() { + collect_var_refs(v, out); + } + } + serde_json::Value::Array(arr) => { + for v in arr { + collect_var_refs(v, out); + } + } + _ => {} + } +} + async fn delete_resources_bulk( authed: ApiAuthed, Extension(db): Extension, diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 233115896f..aedd22e7ce 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -536,12 +536,12 @@ async fn delete_variable( ) .execute(&mut *tx) .await?; - sqlx::query!( - "DELETE FROM resource WHERE path = $1 AND workspace_id = $2", + let deleted_linked_resource = sqlx::query_scalar!( + "DELETE FROM resource WHERE path = $1 AND workspace_id = $2 RETURNING path", path, w_id ) - .execute(&mut *tx) + .fetch_optional(&mut *tx) .await?; audit_log( &mut *tx, @@ -575,9 +575,34 @@ async fn delete_variable( webhook.send_message( w_id.clone(), - WebhookMessage::DeleteVariable { workspace: w_id, path: path.to_owned() }, + WebhookMessage::DeleteVariable { workspace: w_id.clone(), path: path.to_owned() }, ); + if deleted_linked_resource.is_some() { + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + DeployedObject::Resource { + path: path.to_string(), + parent_path: Some(path.to_string()), + }, + Some(format!( + "Resource '{}' deleted (linked variable deleted)", + path + )), + true, + None, + ) + .await?; + + webhook.send_message( + w_id.clone(), + WebhookMessage::DeleteResource { workspace: w_id, path: path.to_owned() }, + ); + } + Ok(format!("variable {} deleted", path)) } diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 5d5c80b470..8e83eae1da 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -623,11 +623,12 @@ pub async fn assert_lockfile( deployment_message: None, concurrency_key: None, visible_to_runner_only: None, - no_main_func: None, + auto_kind: None, codebase: None, has_preprocessor: None, on_behalf_of_email: None, assets: vec![], + modules: None, }, ) .await @@ -720,11 +721,12 @@ pub async fn run_deployed_relative_imports( deployment_message: None, concurrency_key: None, visible_to_runner_only: None, - no_main_func: None, + auto_kind: None, codebase: None, has_preprocessor: None, on_behalf_of_email: None, assets: vec![], + modules: None, }, ) .await @@ -810,6 +812,7 @@ pub async fn run_preview_relative_imports( windmill_common::runnable_settings::ConcurrencySettings::default().into(), debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, })) .push(&db2) .await; diff --git a/backend/windmill-trigger-websocket/src/listener.rs b/backend/windmill-trigger-websocket/src/listener.rs index f6fba882a8..8aab61d9c6 100644 --- a/backend/windmill-trigger-websocket/src/listener.rs +++ b/backend/windmill-trigger-websocket/src/listener.rs @@ -322,7 +322,7 @@ impl Listener for WebsocketTrigger { db: &DB, listening_trigger: &ListeningTrigger, payload: Self::Payload, - trigger_info: HashMap>, + mut trigger_info: HashMap>, extra: Option, ) -> Result<()> { let ListeningTrigger { @@ -338,6 +338,7 @@ impl Listener for WebsocketTrigger { let WebsocketConfig { url, .. } = trigger_config; + trigger_info.insert("trigger_path".to_string(), to_raw_value(path)); let args = WebsocketTrigger::build_job_args( &script_path, *is_flow, diff --git a/backend/windmill-trigger/src/listener.rs b/backend/windmill-trigger/src/listener.rs index d09bb5e55a..04e1df102d 100644 --- a/backend/windmill-trigger/src/listener.rs +++ b/backend/windmill-trigger/src/listener.rs @@ -21,6 +21,7 @@ use windmill_common::{ jobs::JobTriggerKind, triggers::{TriggerKind, TriggerMetadata}, utils::report_critical_error, + worker::to_raw_value, DB, INSTANCE_NAME, }; @@ -467,9 +468,13 @@ pub trait Listener: TriggerCrud + TriggerJobArgs { db: &DB, listening_trigger: &ListeningTrigger, payload: Self::Payload, - trigger_info: HashMap>, + mut trigger_info: HashMap>, _extra: Option, ) -> Result<()> { + trigger_info.insert( + "trigger_path".to_string(), + to_raw_value(&listening_trigger.path), + ); let args = Self::build_job_args( &listening_trigger.script_path, listening_trigger.is_flow, @@ -552,6 +557,11 @@ pub trait Listener: TriggerCrud + TriggerJobArgs { return Ok(()); } + let mut trigger_info = trigger_info; + trigger_info.insert( + "trigger_path".to_string(), + to_raw_value(&listening_trigger.path), + ); let (main_args, preprocessor_args) = Self::build_capture_payloads(&payload, trigger_info); if let Err(err) = insert_capture_payload( db, diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index ffd3cc6ef3..28c3242695 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -10,7 +10,7 @@ use crate::{ flow_status::{FlowStatus, RestartedFrom}, flows::{FlowNodeId, FlowValue, Retry}, runnable_settings::{ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings}, - scripts::{ScriptHash, ScriptLang}, + scripts::{ScriptHash, ScriptLang, ScriptModule}, }; #[derive(Debug, Deserialize, Clone)] @@ -469,6 +469,7 @@ pub struct RawCode { pub concurrency_settings: ConcurrencySettingsWithCustom, #[serde(flatten)] pub debouncing_settings: DebouncingSettings, + pub modules: Option>, } impl JobPayload { diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 28d3a1e449..5eca2be82c 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -9,11 +9,21 @@ use itertools::Itertools; use serde::de::Error as _; use serde::{ser::SerializeSeq, Deserialize, Deserializer, Serialize}; +use std::collections::HashMap; + use crate::{ assets::AssetWithAltAccessType, runnable_settings::{ConcurrencySettings, DebouncingSettings}, }; +#[derive(Serialize, Deserialize, Debug, Clone, Hash)] +pub struct ScriptModule { + pub content: String, + pub language: ScriptLang, + #[serde(skip_serializing_if = "Option::is_none")] + pub lock: Option, +} + #[derive( Serialize, Deserialize, @@ -96,6 +106,7 @@ impl ScriptLang { Python3 => "requirements.in", // Go => "go.mod", Php => "composer.json", + Powershell => "modules.json", _ => return None, } .to_owned(), @@ -350,7 +361,7 @@ pub struct Script { #[serde(skip_serializing_if = "Option::is_none")] pub visible_to_runner_only: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub no_main_func: Option, + pub auto_kind: Option, #[serde(skip_serializing_if = "Option::is_none")] pub codebase: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -360,6 +371,9 @@ pub struct Script { #[serde(skip_serializing_if = "Option::is_none")] #[sqlx(json(nullable))] pub assets: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[sqlx(json(nullable))] + pub modules: Option>, #[serde(flatten)] #[sqlx(flatten)] pub runnable_settings: SR, @@ -418,7 +432,7 @@ pub struct ListableScript { pub has_deploy_errors: bool, pub ws_error_handler_muted: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub no_main_func: Option, + pub auto_kind: Option, #[serde(skip_serializing_if = "is_false")] pub use_codebase: bool, #[sqlx(default)] @@ -454,7 +468,7 @@ impl Hash for Schema { } } -#[derive(Serialize, Deserialize, Hash, Debug)] +#[derive(Serialize, Deserialize, Debug)] pub struct NewScript { pub path: String, pub parent_hash: Option, @@ -487,13 +501,60 @@ pub struct NewScript { pub deployment_message: Option, #[serde(skip_serializing_if = "Option::is_none")] pub visible_to_runner_only: Option, - pub no_main_func: Option, + pub auto_kind: Option, pub codebase: Option, pub has_preprocessor: Option, pub on_behalf_of_email: Option, pub preserve_on_behalf_of: Option, #[serde(skip_serializing_if = "Option::is_none")] pub assets: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub modules: Option>, +} + +// IMPORTANT: update this Hash impl when adding fields to NewScript +impl Hash for NewScript { + fn hash(&self, state: &mut H) { + self.path.hash(state); + self.parent_hash.hash(state); + self.summary.hash(state); + self.description.hash(state); + self.content.hash(state); + self.schema.hash(state); + self.is_template.hash(state); + self.lock.hash(state); + self.language.hash(state); + self.kind.hash(state); + self.tag.hash(state); + self.draft_only.hash(state); + self.envs.hash(state); + self.concurrency_settings.hash(state); + self.debouncing_settings.hash(state); + self.cache_ttl.hash(state); + self.cache_ignore_s3_path.hash(state); + self.dedicated_worker.hash(state); + self.ws_error_handler_muted.hash(state); + self.priority.hash(state); + self.timeout.hash(state); + self.delete_after_use.hash(state); + self.restart_unless_cancelled.hash(state); + self.deployment_message.hash(state); + self.visible_to_runner_only.hash(state); + self.auto_kind.hash(state); + self.codebase.hash(state); + self.has_preprocessor.hash(state); + self.on_behalf_of_email.hash(state); + self.preserve_on_behalf_of.hash(state); + self.assets.hash(state); + if let Some(modules) = &self.modules { + let mut sorted: Vec<_> = modules.iter().collect(); + sorted.sort_by_key(|(k, _)| *k); + for (k, v) in sorted { + k.hash(state); + v.hash(state); + } + } + } } fn lock_deserialize<'de, D>(deserializer: D) -> Result, D::Error> diff --git a/backend/windmill-worker/Cargo.toml b/backend/windmill-worker/Cargo.toml index 23927753ee..ed82298038 100644 --- a/backend/windmill-worker/Cargo.toml +++ b/backend/windmill-worker/Cargo.toml @@ -143,5 +143,8 @@ hyper-tls = { workspace = true, optional = true } hyper-util = { workspace = true, optional = true } rcgen = { workspace = true, optional = true } +[dev-dependencies] +tempfile.workspace = true + [build-dependencies] libffi-sys = { workspace = true, optional = true } diff --git a/backend/windmill-worker/loader.bun.js b/backend/windmill-worker/loader.bun.js index a697ba1d53..f64e6d769b 100644 --- a/backend/windmill-worker/loader.bun.js +++ b/backend/windmill-worker/loader.bun.js @@ -77,6 +77,18 @@ const p = { if (args.importer?.startsWith(cdirNodeModules)) { return undefined; } + + // Check if the import resolves to a local module file (written by write_module_files). + // Only check relative paths — absolute/bare specifiers should fall through to the + // remote resolver, matching the Windows loader pattern. + if (args.path.startsWith(".")) { + const localPath = resolve(cdir, args.path); + try { + readFileSync(localPath); + return { path: localPath }; + } catch {} + } + const file_path = args.importer == "./main.ts" || args.importer == resolve("./main.ts") ? current_path diff --git a/backend/windmill-worker/loader.bun.windows.js b/backend/windmill-worker/loader.bun.windows.js index 227c68a56c..fedef5fc5a 100644 --- a/backend/windmill-worker/loader.bun.windows.js +++ b/backend/windmill-worker/loader.bun.windows.js @@ -106,6 +106,14 @@ const p = { if (importerFwd.startsWith(cdirNodeModules)) { return undefined; } + // Check if the import resolves to a local module file (written by write_module_files) + if (args.path.startsWith(".")) { + const cwdPath = resolve(cdir, args.path); + try { + readFileSync(cwdPath); + return { path: cwdPath }; + } catch {} + } const isMainTs = args.importer == "./main.ts" || importerFwd.endsWith("/main.ts"); const file_path = isMainTs diff --git a/backend/windmill-worker/nsjail/run.ansible.config.proto b/backend/windmill-worker/nsjail/run.ansible.config.proto index 215484870f..4e63927a66 100644 --- a/backend/windmill-worker/nsjail/run.ansible.config.proto +++ b/backend/windmill-worker/nsjail/run.ansible.config.proto @@ -3,6 +3,7 @@ name: "ansible run script" mode: ONCE hostname: "ansible" log_level: ERROR +time_limit: {TIMEOUT} rlimit_as: 4096 rlimit_cpu: 1000 diff --git a/backend/windmill-worker/nsjail/run.bash.config.proto b/backend/windmill-worker/nsjail/run.bash.config.proto index 51917710e8..5091882e37 100644 --- a/backend/windmill-worker/nsjail/run.bash.config.proto +++ b/backend/windmill-worker/nsjail/run.bash.config.proto @@ -3,6 +3,7 @@ name: "bash run script" mode: ONCE hostname: "bash" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/nsjail/run.bun.config.proto b/backend/windmill-worker/nsjail/run.bun.config.proto index afd5c42ba9..b245ea2f2c 100644 --- a/backend/windmill-worker/nsjail/run.bun.config.proto +++ b/backend/windmill-worker/nsjail/run.bun.config.proto @@ -3,6 +3,7 @@ name: "{LANG} run script" mode: ONCE hostname: "{LANG}" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/nsjail/run.csharp.config.proto b/backend/windmill-worker/nsjail/run.csharp.config.proto index 7cd078c92f..f66d2b0991 100644 --- a/backend/windmill-worker/nsjail/run.csharp.config.proto +++ b/backend/windmill-worker/nsjail/run.csharp.config.proto @@ -3,6 +3,7 @@ name: "csharp run script" mode: ONCE hostname: "csharp" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/nsjail/run.go.config.proto b/backend/windmill-worker/nsjail/run.go.config.proto index bdd7ebbbb8..fabc4054a4 100644 --- a/backend/windmill-worker/nsjail/run.go.config.proto +++ b/backend/windmill-worker/nsjail/run.go.config.proto @@ -3,6 +3,7 @@ name: "go run script" mode: ONCE hostname: "go" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/nsjail/run.java.config.proto b/backend/windmill-worker/nsjail/run.java.config.proto index 8b123432e2..7c3f15f7e5 100644 --- a/backend/windmill-worker/nsjail/run.java.config.proto +++ b/backend/windmill-worker/nsjail/run.java.config.proto @@ -3,6 +3,7 @@ name: "java run script" mode: ONCE hostname: "java" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/nsjail/run.nu.config.proto b/backend/windmill-worker/nsjail/run.nu.config.proto index 3fe7dc0cba..4cbfdece91 100644 --- a/backend/windmill-worker/nsjail/run.nu.config.proto +++ b/backend/windmill-worker/nsjail/run.nu.config.proto @@ -3,6 +3,7 @@ name: "nu run script" mode: ONCE hostname: "nu" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/nsjail/run.php.config.proto b/backend/windmill-worker/nsjail/run.php.config.proto index 1cf61dbfb2..46b03ce872 100644 --- a/backend/windmill-worker/nsjail/run.php.config.proto +++ b/backend/windmill-worker/nsjail/run.php.config.proto @@ -3,6 +3,7 @@ name: "php run script" mode: ONCE hostname: "php" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/nsjail/run.powershell.config.proto b/backend/windmill-worker/nsjail/run.powershell.config.proto index afe9d5df9f..ebbcb26ed4 100644 --- a/backend/windmill-worker/nsjail/run.powershell.config.proto +++ b/backend/windmill-worker/nsjail/run.powershell.config.proto @@ -3,6 +3,7 @@ name: "powershell run script" mode: ONCE hostname: "powershell" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/nsjail/run.python3.config.proto b/backend/windmill-worker/nsjail/run.python3.config.proto index 91cedb591d..bdeace81e8 100644 --- a/backend/windmill-worker/nsjail/run.python3.config.proto +++ b/backend/windmill-worker/nsjail/run.python3.config.proto @@ -3,6 +3,7 @@ name: "python run script" mode: ONCE hostname: "python" log_level: ERROR +time_limit: {TIMEOUT} rlimit_as: 4096 rlimit_cpu: 1000 diff --git a/backend/windmill-worker/nsjail/run.ruby.config.proto b/backend/windmill-worker/nsjail/run.ruby.config.proto index ec53c55809..1b43ee8b2f 100644 --- a/backend/windmill-worker/nsjail/run.ruby.config.proto +++ b/backend/windmill-worker/nsjail/run.ruby.config.proto @@ -3,6 +3,7 @@ name: "ruby run script" mode: ONCE hostname: "ruby" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/nsjail/run.rust.config.proto b/backend/windmill-worker/nsjail/run.rust.config.proto index 28959f1c94..cb5c099a03 100644 --- a/backend/windmill-worker/nsjail/run.rust.config.proto +++ b/backend/windmill-worker/nsjail/run.rust.config.proto @@ -3,6 +3,7 @@ name: "rust run script" mode: ONCE hostname: "rust" log_level: ERROR +time_limit: {TIMEOUT} disable_rl: true diff --git a/backend/windmill-worker/src/ai/types.rs b/backend/windmill-worker/src/ai/types.rs index c11c92adb3..62a13d958a 100644 --- a/backend/windmill-worker/src/ai/types.rs +++ b/backend/windmill-worker/src/ai/types.rs @@ -193,6 +193,9 @@ pub struct ProviderResource { /// Enable 1M context window for Anthropic #[serde(alias = "enable_1M_context", default)] pub enable_1m_context: bool, + /// Custom HTTP headers to include in AI requests + #[serde(default)] + pub headers: HashMap, } #[derive(Deserialize, Debug)] @@ -244,6 +247,10 @@ impl ProviderWithResource { pub fn get_enable_1m_context(&self) -> bool { self.resource.enable_1m_context } + + pub fn get_headers(&self) -> &HashMap { + &self.resource.headers + } } /// Token usage information from the AI provider diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index e0271ecb8a..710daacef2 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -762,8 +762,10 @@ pub async fn run_agent( .map(|m| m.clamp(1, HARD_MAX_AGENT_ITERATIONS)) .unwrap_or(DEFAULT_MAX_AGENT_ITERATIONS); + // Main agent loop for i in 0..max_iterations { + if used_structured_output_tool { break; } @@ -830,6 +832,8 @@ pub async fn run_agent( .await .0; + let resource_headers = args.provider.get_headers(); + // Helper to build HTTP request with headers let build_http_request = |body: String| { let mut req = HTTP_CLIENT @@ -845,6 +849,10 @@ pub async fn run_agent( req = req.header(header_name.as_str(), header_value.as_str()); } + for (header_name, header_value) in resource_headers { + req = req.header(header_name.as_str(), header_value.as_str()); + } + req.body(body) }; @@ -1030,9 +1038,28 @@ pub async fn run_agent( if tool_calls.is_empty() { break; } else if i == max_iterations - 1 { - return Err(Error::internal_err( - "AI agent reached max iterations, but there are still tool calls" - .to_string(), + #[derive(serde::Serialize)] + struct MaxIterError<'a> { + message: String, + name: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + step_id: Option<&'a str>, + result: MaxIterPartialResult<'a>, + } + #[derive(serde::Serialize)] + struct MaxIterPartialResult<'a> { + messages: &'a [OpenAIMessage], + } + return Err(Error::ExecutionRawError( + serde_json::value::to_raw_value(&MaxIterError { + message: format!( + "AI agent reached max iterations ({}), you can either increase max_iterations or enable the \"continue on error\" option from the advanced options of the step.", + max_iterations + ), + name: "ExecutionErr", + step_id: effective_flow_step_id, + result: MaxIterPartialResult { messages: &messages }, + })?, )); } diff --git a/backend/windmill-worker/src/ansible_executor.rs b/backend/windmill-worker/src/ansible_executor.rs index 253384f6f2..5369a40ee9 100644 --- a/backend/windmill-worker/src/ansible_executor.rs +++ b/backend/windmill-worker/src/ansible_executor.rs @@ -30,7 +30,8 @@ use crate::{ bash_executor::BIN_BASH, common::{ build_command_with_isolation, check_executor_binary_exists, get_reserved_variables, - read_and_check_result, start_child_process, transform_json, OccupancyMetrics, + read_and_check_result, resolve_nsjail_timeout, start_child_process, transform_json, + OccupancyMetrics, }, handle_child::handle_child, is_sandboxing_enabled, @@ -1180,6 +1181,8 @@ mount {{ ) }) .join("\n"); + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; let _ = write_file( job_dir, "run.config.proto", @@ -1193,7 +1196,8 @@ mount {{ .replace( "{ADDITIONAL_PYTHON_PATHS}", additional_python_paths_folders.as_str(), - ), + ) + .replace("{TIMEOUT}", &nsjail_timeout), )?; } else { reserved_variables.insert("PYTHONPATH".to_string(), additional_python_paths_folders); diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 7d92607af5..a9947faaf5 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -41,7 +41,8 @@ use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ common::{ build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, + read_file_content, resolve_nsjail_timeout, start_child_process, OccupancyMetrics, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -192,6 +193,8 @@ exit $exit_status // Use nsjail if globally enabled OR if script has #sandbox annotation let nsjail = (is_sandboxing_enabled() || annotation.sandbox) && is_regular_job; let child = if nsjail { + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; let _ = write_file( job_dir, "run.config.proto", @@ -200,7 +203,8 @@ exit $exit_status .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL), + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut cmd_args = vec![ "--config", diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 2fd194229a..792e864d06 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -15,8 +15,9 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob, PrecomputedAgentInf use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - parse_npm_config, read_file, read_file_content, read_result, start_child_process, - write_file_binary, MaybeLock, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, + parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout, + start_child_process, write_file_binary, MaybeLock, OccupancyMetrics, StreamNotifier, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -205,6 +206,7 @@ pub async fn gen_bun_lockfile( workspace_dependencies: &WorkspaceDependenciesPrefetched, npm_mode: bool, occupancy_metrics: &mut Option<&mut OccupancyMetrics>, + quiet: bool, ) -> Result> { let common_bun_proc_envs: HashMap = get_common_bun_proc_envs(None).await; @@ -253,7 +255,8 @@ pub async fn gen_bun_lockfile( let mut child_process = start_child_process(child_cmd, &*BUN_PATH, false).await?; if let Some(db) = db { - handle_child( + let mut quiet_buf = String::new(); + let result = handle_child( job_id, db, mem_peak, @@ -266,10 +269,20 @@ pub async fn gen_bun_lockfile( None, false, occupancy_metrics, - None, + if quiet { Some(&mut quiet_buf) } else { None }, None, ) - .await?; + .await; + if quiet && result.is_err() { + append_logs( + job_id, + w_id, + format!("\n--- BUN BUILD (failed) ---\n{quiet_buf}"), + db, + ) + .await; + } + result?; } else { Box::into_pin(child_process.wait()).await?; } @@ -293,11 +306,14 @@ pub async fn gen_bun_lockfile( common_bun_proc_envs, npm_mode, occupancy_metrics, + quiet, ) .await?; } else { - if let Some(db) = db { - append_logs(job_id, w_id, "\nempty dependencies, skipping install", db).await; + if !quiet { + if let Some(db) = db { + append_logs(job_id, w_id, "\nempty dependencies, skipping install", db).await; + } } } @@ -424,6 +440,7 @@ pub async fn install_bun_lockfile( common_bun_proc_envs: HashMap, npm_mode: bool, occupancy_metrics: &mut Option<&mut OccupancyMetrics>, + quiet: bool, ) -> Result<()> { let mut child_cmd = Command::new(if npm_mode { &*NPM_PATH } else { &*BUN_PATH }); @@ -511,7 +528,7 @@ pub async fn install_bun_lockfile( false }; - if npm_mode || no_cache { + if !quiet && (npm_mode || no_cache) { if let Some(db) = db { append_logs(&job_id.clone(), w_id, npm_logs, db).await; } @@ -523,7 +540,8 @@ pub async fn install_bun_lockfile( let mut child_process = start_child_process(child_cmd, &*BUN_PATH, false).await?; if let Some(db) = db { - handle_child( + let mut quiet_buf = String::new(); + let result = handle_child( job_id, db, mem_peak, @@ -536,11 +554,22 @@ pub async fn install_bun_lockfile( None, false, occupancy_metrics, - None, + if quiet { Some(&mut quiet_buf) } else { None }, None, ) .warn_after_seconds(10) - .await?; + .await; + if quiet && result.is_err() { + // On failure, flush suppressed install output so the user can diagnose + append_logs( + job_id, + w_id, + format!("\n--- BUN INSTALL (failed) ---\n{quiet_buf}"), + db, + ) + .await; + } + result?; } else { Box::into_pin(child_process.wait()).await?; } @@ -1021,6 +1050,7 @@ pub async fn handle_bun_job( occupancy_metrics: &mut OccupancyMetrics, precomputed_agent_info: Option, has_stream: &mut bool, + modules: &Option>, ) -> error::Result> { let mut annotation = windmill_common::worker::TypeScriptAnnotations::parse(inner_content); @@ -1087,6 +1117,23 @@ pub async fn handle_bun_job( let is_wac_v2 = main_override.is_none() && crate::wac_executor::is_wac_v2_ts(inner_content); + // Detect WAC v2 replay (resumed from suspend) to suppress verbose logs. + // The actual step name is logged later by handle_wac_v2_output. + let wac_replay_info: Option = if is_wac_v2 { + if let Connection::Sql(db) = conn { + let checkpoint = crate::wac_executor::load_checkpoint(db, &job.id).await?; + if !checkpoint.completed_steps.is_empty() { + Some(String::new()) + } else { + None + } + } else { + None + } + } else { + None + }; + // For WAC v2, inject variable names into unnamed task() calls so the // runtime can use them for step naming (timeline, graph). // `const double = task(async ...` → `const double = task("double", async ...` @@ -1177,14 +1224,17 @@ pub async fn handle_bun_job( common_bun_proc_envs.clone(), annotation.npm, &mut Some(occupancy_metrics), + wac_replay_info.is_some(), ) .await?; } } MaybeLock::Unresolved { ref workspace_dependencies } => { // if is_sandboxing_enabled() || !empty_trusted_deps || has_custom_config_registry { - let logs1 = "\n\n--- BUN INSTALL ---\n".to_string(); - append_logs(&job.id, &job.workspace_id, logs1, conn).await; + if wac_replay_info.is_none() { + let logs1 = "\n\n--- BUN INSTALL ---\n".to_string(); + append_logs(&job.id, &job.workspace_id, logs1, conn).await; + } gen_bun_lockfile( mem_peak, canceled_by, @@ -1200,6 +1250,7 @@ pub async fn handle_bun_job( workspace_dependencies, annotation.npm, &mut Some(occupancy_metrics), + wac_replay_info.is_some(), ) .await?; @@ -1212,7 +1263,13 @@ pub async fn handle_bun_job( annotation.nodejs = true } - let mut init_logs = if annotation.native { + let mut init_logs = if let Some(ref replay_header) = wac_replay_info { + // WAC v2 replay: use concise header, but still write main.ts if needed + if !annotation.native && !has_bundle_cache && codebase.is_none() { + write_file(job_dir, "main.ts", &remove_pinned_imports(inner_content)?)?; + } + replay_header.clone() + } else if annotation.native { "\n\n--- NATIVE CODE EXECUTION ---\n".to_string() } else if has_bundle_cache { if annotation.nodejs { @@ -1233,6 +1290,18 @@ pub async fn handle_bun_job( "\n\n--- NODE CODE EXECUTION ---\n".to_string() } else { write_file(job_dir, "main.ts", &remove_pinned_imports(inner_content)?)?; + // Module inlining has two phases: + // 1. BUILD phase: loader.bun.js checks for local module files on disk (written by + // write_module_files) and resolves them directly, so they get inlined into the bundle. + // 2. RUN phase: overwrite main.ts with the bundled output below. The runtime wrapper + // imports main.ts, which now contains the inlined modules from the build step. + if modules.as_ref().is_some_and(|m| !m.is_empty()) { + let bundle_path = std::path::Path::new(job_dir).join("out").join("main.js"); + if bundle_path.exists() { + let bundled = std::fs::read_to_string(&bundle_path)?; + write_file(job_dir, "main.ts", &bundled)?; + } + } "\n\n--- BUN CODE EXECUTION ---\n".to_string() }; @@ -1394,7 +1463,7 @@ async function run() {{ return {{ type: "complete", result: dispatch.result ?? null }}; }} if (dispatch.mode === "inline_checkpoint") {{ - return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null }}; + return {{ type: "inline_checkpoint", key: dispatch.key, result: dispatch.result ?? null, started_at: dispatch.started_at, duration_ms: dispatch.duration_ms }}; }} if (dispatch.mode === "approval") {{ return {{ type: "approval", key: dispatch.key, timeout: dispatch.timeout, form: dispatch.form }}; @@ -1410,6 +1479,9 @@ async function run() {{ try {{ const output = await run(); + if (output.type === "complete") {{ + console.log(`\n--- WAC: complete ---`); + }} const output_json = JSON.stringify(output, (key, value) => typeof value === 'undefined' ? null : value ); @@ -1774,6 +1846,8 @@ try {{ //do not cache local dependencies let child = if is_sandboxing_enabled() || annotation.sandbox { + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; let _ = write_file( job_dir, "run.config.proto", @@ -1793,7 +1867,8 @@ try {{ ), ) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL), + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); @@ -1954,18 +2029,37 @@ try {{ // WAC v2 post-execution: parse output and handle dispatch/suspend if is_wac_v2 { - return handle_wac_v2_output(result, job, conn).await; + return handle_wac_v2_output(result, job, conn, modules).await; } Ok(result) } +/// Resolve a module file from the parent script's modules map. +/// For Script jobs, fetches from the `script` table by hash. +/// For Preview jobs, fetches from `v2_job.raw_code` (modules stored inline). +fn resolve_parent_module( + modules: &Option>, + module_key: &str, +) -> error::Result { + if let Some(modules) = modules { + if let Some(module) = modules.get(module_key) { + return Ok(module.clone()); + } + } + Err(error::Error::ExecutionErr(format!( + "Module '{}' not found in script modules", + module_key + ))) +} + /// Handle WAC v2 output after bun/python exits. Parse result as WacOutput, /// dispatch child jobs on suspend, or return the final result. pub async fn handle_wac_v2_output( result: Box, job: &MiniPulledJob, conn: &Connection, + modules: &Option>, ) -> error::Result> { use crate::wac_executor::{ add_completed_step, load_checkpoint, parse_wac_output, update_checkpoint_for_dispatch, @@ -2148,6 +2242,7 @@ pub async fn handle_wac_v2_output( dedicated_worker: None, concurrency_settings: ConcurrencySettingsWithCustom::default(), debouncing_settings: DebouncingSettings::default(), + modules: None, })) } _ => Err(error::Error::internal_err(format!( @@ -2241,6 +2336,33 @@ pub async fn handle_wac_v2_output( for (step, (_, child_uuid)) in steps.iter().zip(job_ids.iter()) { // Resolve job payload based on dispatch_type let (job_payload, child_args, is_external) = match step.dispatch_type.as_str() { + "script" if step.script.starts_with("./") => { + // Module-relative path: resolve from parent script's modules + let module_key = step.script.strip_prefix("./").unwrap(); + let module = resolve_parent_module(modules, module_key)?; + let payload = JobPayload::Code(RawCode { + content: module.content, + path: job.runnable_path.clone(), + hash: None, + language: module.language, + lock: module.lock, + cache_ttl: job.cache_ttl, + cache_ignore_s3_path: job.cache_ignore_s3_path, + dedicated_worker: None, + concurrency_settings: ConcurrencySettingsWithCustom::default(), + debouncing_settings: DebouncingSettings::default(), + modules: None, + }); + let step_args: HashMap> = step + .args + .iter() + .map(|(k, v)| { + let raw = serde_json::value::to_raw_value(v).unwrap(); + (k.clone(), raw) + }) + .collect(); + (payload, step_args, true) + } "script" => { // Resolve script path to job payload (handles hash, lang, etc.) let (payload, _, _, _, _) = script_path_to_payload( @@ -2626,7 +2748,7 @@ pub async fn handle_wac_v2_output( job.id, seconds, key ))) } - WacOutput::InlineCheckpoint { key, result: value } => { + WacOutput::InlineCheckpoint { key, result: value, started_at, duration_ms } => { let db = match conn { Connection::Sql(db) => db, _ => { @@ -2661,7 +2783,7 @@ pub async fn handle_wac_v2_output( add_completed_step(&mut checkpoint, &key, value); - // Save checkpoint + reset running in a single transaction + // Save checkpoint + write step timeline entry + reset running in a single transaction { let mut tx = db.begin().await?; let status_json = serde_json::to_value(&checkpoint).map_err(|e| { @@ -2685,6 +2807,34 @@ pub async fn handle_wac_v2_output( error::Error::internal_err(format!("Failed to save WAC checkpoint: {e}")) })?; + // Write timeline entry for the inline step (keyed as _step/) + if let Some(ref sa) = started_at { + let mut timeline_val = serde_json::json!({ + "scheduled_for": sa, + "started_at": sa, + "name": key, + }); + if let Some(dur) = duration_ms { + timeline_val["duration_ms"] = serde_json::json!(dur); + } + let step_timeline_key = format!("_step/{}", key); + sqlx::query( + "UPDATE v2_job_status SET workflow_as_code_status = jsonb_set( + COALESCE(workflow_as_code_status, '{}'::jsonb), + ARRAY[$2], + $3 + ) WHERE id = $1", + ) + .bind(&job.id) + .bind(&step_timeline_key) + .bind(&timeline_val) + .execute(&mut *tx) + .await + .map_err(|e| { + error::Error::internal_err(format!("Failed to write step timeline: {e}")) + })?; + } + // Reset running=false so the job is immediately eligible for pickup. // Unlike dispatch (which sets suspend>0), inline checkpoints don't suspend — // the job should be re-run right away to continue past the cached step. @@ -3114,6 +3264,7 @@ pub async fn start_worker( common_bun_proc_envs.clone(), annotation.npm, &mut None, + false, ) .await?; } @@ -3209,6 +3360,7 @@ pub async fn start_worker( common_bun_proc_envs.clone(), annotation.npm, &mut None, + false, ) .await?; tracing::info!("dedicated worker requirements installed: {reqs}"); @@ -3238,6 +3390,7 @@ pub async fn start_worker( .await?, annotation.npm, &mut None, + false, ) .await?; } diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 846c421024..bdecb0526f 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -112,17 +112,20 @@ pub async fn create_args_and_out_file( conn: &Connection, ) -> Result<(), Error> { if let Some(args) = job.args.as_ref() { - if let Some(x) = transform_json(client, &job.workspace_id, &args.0, job, conn).await? { + if let Some(mut x) = transform_json(client, &job.workspace_id, &args.0, job, conn).await? { + x.remove("_MODULES"); write_file( job_dir, "args.json", &serde_json::to_string(&x).unwrap_or_else(|_| "{}".to_string()), )?; } else { + let mut filtered = args.0.clone(); + filtered.remove("_MODULES"); write_file( job_dir, "args.json", - &serde_json::to_string(&args).unwrap_or_else(|_| "{}".to_string()), + &serde_json::to_string(&filtered).unwrap_or_else(|_| "{}".to_string()), )?; } } else { @@ -805,6 +808,17 @@ pub async fn resolve_job_timeout( } } +/// Compute the nsjail timeout (in seconds) with a 15s buffer so handle_child fires first. +pub async fn resolve_nsjail_timeout( + conn: &Connection, + w_id: &str, + job_id: Uuid, + custom_timeout: Option, +) -> String { + let (duration, _, _) = resolve_job_timeout(conn, w_id, job_id, custom_timeout).await; + (duration.as_secs() + 15).to_string() +} + async fn hash_args( #[allow(unused)] db: &DB, #[allow(unused)] client: &AuthedClient, diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index 22f46e724b..2359aa0b83 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -27,7 +27,8 @@ use windmill_queue::CanceledBy; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, start_child_process, DEV_CONF_NSJAIL, + get_reserved_variables, read_result, resolve_nsjail_timeout, start_child_process, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -582,6 +583,8 @@ pub async fn handle_csharp_job( get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; let child = if is_sandboxing_enabled() { + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; write_file( job_dir, "run.config.proto", @@ -592,7 +595,8 @@ pub async fn handle_csharp_job( .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL), + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd diff --git a/backend/windmill-worker/src/go_executor.rs b/backend/windmill-worker/src/go_executor.rs index 0315bf25c3..d57b757e40 100644 --- a/backend/windmill-worker/src/go_executor.rs +++ b/backend/windmill-worker/src/go_executor.rs @@ -22,7 +22,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, capitalize, create_args_and_out_file, get_reserved_variables, - read_result, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, start_child_process, OccupancyMetrics, + DEV_CONF_NSJAIL, }, handle_child::handle_child, is_sandboxing_enabled, read_ee_registry, DISABLE_NUSER, GOPRIVATE, GOPROXY, GO_BIN_CACHE_DIR, @@ -338,6 +339,8 @@ func Run(req Req) (interface{{}}, error){{ get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; let child = if is_sandboxing_enabled() { + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; let _ = write_file( job_dir, "run.config.proto", @@ -346,7 +349,8 @@ func Run(req Req) (interface{{}}, error){{ .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL), + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd diff --git a/backend/windmill-worker/src/handle_child.rs b/backend/windmill-worker/src/handle_child.rs index 205da6700a..00b3aa4084 100644 --- a/backend/windmill-worker/src/handle_child.rs +++ b/backend/windmill-worker/src/handle_child.rs @@ -128,17 +128,22 @@ pub async fn handle_child( let pid = child.id(); #[cfg(target_os = "linux")] if let Some(pid) = pid { - //set the highest oom priority - if let Some(mut file) = File::create(format!("/proc/{pid}/oom_score_adj")) - .await - .map_err(|e| { - tracing::error!("Could not create oom_score_file to pid {pid}: {e:#}"); - e - }) - .ok() - { - let _ = file.write_all(b"1000").await; - let _ = file.sync_all().await; + //set the highest oom priority so OOM killer targets this job, not the worker + match File::create(format!("/proc/{pid}/oom_score_adj")).await { + Ok(mut file) => { + if let Err(e) = file.write_all(b"1000").await { + tracing::error!("Failed to write oom_score_adj for pid {pid}: {e:#}"); + } + if let Err(e) = file.sync_all().await { + tracing::warn!("Failed to sync oom_score_adj for pid {pid}: {e:#}"); + } + } + Err(e) => { + tracing::error!( + "Could not open /proc/{pid}/oom_score_adj: {e:#}. \ + OOM killer may target the worker instead of this job" + ); + } } } else { tracing::info!("could not get child pid"); @@ -353,6 +358,7 @@ pub async fn handle_child( } pub const OTEL_PREFIX: &str = "OTEL: "; +pub const WAC_STEP_PREFIX: &str = "WM_WAC_STEP: "; pub async fn write_lines( output: impl stream::Stream> + Send, @@ -437,6 +443,19 @@ pub async fn write_lines( tracing::event!(tracing::Level::INFO, otel_suffix); } } + if let Some(step_json) = line.strip_prefix(WAC_STEP_PREFIX) { + // Real-time WAC step start marker — fire-and-forget DB write + let conn = conn.clone(); + let job_id = job_id.clone(); + let step_json = step_json.to_string(); + tokio::spawn(async move { + if let Err(e) = handle_wac_step_marker(&conn, &job_id, &step_json).await + { + tracing::warn!(%job_id, "Failed to write WAC step marker: {e}"); + } + }); + continue; + } if let Some(stream) = extract_stream_from_logs(&line) { let len = stream.len(); if log_remaining >= len { @@ -564,6 +583,58 @@ pub async fn write_lines( } } +/// Handle a real-time WAC step start marker emitted via stdout. +/// Writes a timeline entry (with started_at but no duration_ms) to workflow_as_code_status +/// so the frontend can show the step immediately while it's still running. +async fn handle_wac_step_marker( + conn: &Connection, + job_id: &Uuid, + json_str: &str, +) -> error::Result<()> { + #[derive(serde::Deserialize)] + struct StepMarker { + key: String, + started_at: String, + } + let marker: StepMarker = serde_json::from_str(json_str).map_err(|e| { + error::Error::internal_err(format!("Failed to parse WM_WAC_STEP marker: {e}")) + })?; + + let step_timeline_key = format!("_step/{}", marker.key); + let timeline_val = serde_json::json!({ + "scheduled_for": marker.started_at, + "started_at": marker.started_at, + "name": marker.key, + }); + + match conn { + Connection::Sql(db) => { + sqlx::query( + "INSERT INTO v2_job_status (id, workflow_as_code_status) + VALUES ($1, jsonb_build_object($2, $3::jsonb)) + ON CONFLICT (id) DO UPDATE SET + workflow_as_code_status = jsonb_set( + COALESCE(v2_job_status.workflow_as_code_status, '{}'::jsonb), + ARRAY[$2], + $3::jsonb + )", + ) + .bind(job_id) + .bind(&step_timeline_key) + .bind(&timeline_val) + .execute(db) + .await + .map_err(|e| { + error::Error::internal_err(format!("DB error writing WAC step marker: {e}")) + })?; + } + Connection::Http(_) => { + // Agent workers don't support WAC v2 yet + } + } + Ok(()) +} + pub(crate) async fn get_mem_peak(pid: Option, nsjail: bool) -> i32 { if pid.is_none() { return -1; diff --git a/backend/windmill-worker/src/java_executor.rs b/backend/windmill-worker/src/java_executor.rs index 6f558e3328..5a43ac44b7 100644 --- a/backend/windmill-worker/src/java_executor.rs +++ b/backend/windmill-worker/src/java_executor.rs @@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, start_child_process, OccupancyMetrics, + read_result, resolve_nsjail_timeout, start_child_process, OccupancyMetrics, }, handle_child, is_sandboxing_enabled, read_ee_registry, universal_pkg_installer::{par_install_language_dependencies_all_at_once, RequiredDependency}, @@ -600,6 +600,8 @@ async fn run<'a>( ) .await; + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; write_file( job_dir, "run.config.proto", @@ -608,7 +610,8 @@ async fn run<'a>( .replace("{CACHE_DIR}", &*JAVA_CACHE_DIR) .replace("{SHARED_MOUNT}", &shared_mount) // .replace("{CACHED_TARGET}", &shared_mount) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.env_clear() diff --git a/backend/windmill-worker/src/nu_executor.rs b/backend/windmill-worker/src/nu_executor.rs index 28ac27c925..8a6adf4a80 100644 --- a/backend/windmill-worker/src/nu_executor.rs +++ b/backend/windmill-worker/src/nu_executor.rs @@ -14,7 +14,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, start_child_process, OccupancyMetrics, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH, PATH_ENV, TRACING_PROXY_CA_CERT_PATH, @@ -245,6 +246,8 @@ async fn run<'a>( ) .await; + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; write_file( job_dir, "run.config.proto", @@ -254,7 +257,8 @@ async fn run<'a>( .replace("{SHARED_MOUNT}", &shared_mount) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL), + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd diff --git a/backend/windmill-worker/src/php_executor.rs b/backend/windmill-worker/src/php_executor.rs index df006fef7f..f88d237c10 100644 --- a/backend/windmill-worker/src/php_executor.rs +++ b/backend/windmill-worker/src/php_executor.rs @@ -19,7 +19,8 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, start_child_process, MaybeLock, OccupancyMetrics, + get_reserved_variables, read_result, resolve_nsjail_timeout, start_child_process, + MaybeLock, OccupancyMetrics, }, handle_child::handle_child, is_sandboxing_enabled, COMPOSER_CACHE_DIR, COMPOSER_PATH, DISABLE_NUSER, NSJAIL_PATH, PHP_PATH, @@ -294,13 +295,16 @@ try {{ let (reserved_variables, _) = tokio::try_join!(reserved_variables_args_out_f, write_wrapper_f)?; let child = if is_sandboxing_enabled() { + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; let _ = write_file( job_dir, "run.config.proto", &NSJAIL_CONFIG_RUN_PHP_CONTENT .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) - .replace("{SHARED_MOUNT}", shared_mount), + .replace("{SHARED_MOUNT}", shared_mount) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); diff --git a/backend/windmill-worker/src/pwsh_executor.rs b/backend/windmill-worker/src/pwsh_executor.rs index 961a49a58a..bfe30ba26f 100644 --- a/backend/windmill-worker/src/pwsh_executor.rs +++ b/backend/windmill-worker/src/pwsh_executor.rs @@ -18,13 +18,14 @@ const NSJAIL_CONFIG_RUN_POWERSHELL_CONTENT: &str = include_str!("../nsjail/run.powershell.config.proto"); lazy_static::lazy_static! { - static ref RE_POWERSHELL_IMPORTS: Regex = Regex::new(r#"^Import-Module\s+(?:-Name\s+)?"?([^\s"]+)"?(?:\s+-RequiredVersion\s+"?([^\s"]+)"?)?"#).unwrap(); + static ref RE_POWERSHELL_IMPORTS: Regex = Regex::new(r#"^\s*Import-Module\s+(?:-Name\s+)?"?([^\s"]+)"?(?:\s+-RequiredVersion\s+"?([^\s"]+)"?)?"#).unwrap(); } use crate::{ common::{ build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, start_child_process, OccupancyMetrics, + read_file_content, resolve_nsjail_timeout, start_child_process, MaybeLock, + OccupancyMetrics, }, handle_child::handle_child, is_sandboxing_enabled, read_ee_registry, DISABLE_NUSER, HOME_ENV, NSJAIL_PATH, PATH_ENV, @@ -196,17 +197,41 @@ async fn get_module_versions(module_path: &str) -> Result, Error> { .to_string(); // Check if this looks like a version (contains dots and numbers) + // and verify a module manifest (.psd1) or script (.psm1) actually exists if version.chars().any(|c| c.is_numeric()) && version.contains('.') { - versions.push(version); + let has_module_files = fs::read_dir(&version_path) + .map(|entries| { + entries.filter_map(|e| e.ok()).any(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.ends_with(".psd1") || name.ends_with(".psm1") + }) + }) + .unwrap_or(false); + if has_module_files { + versions.push(version); + } } } } } } - // If no version subdirectories found, treat as single version installation + // If no version subdirectories found, check if module files exist directly + // in the module directory (flat/single-version installation) if versions.is_empty() { - versions.push("unknown".to_string()); + let has_module_files = fs::read_dir(module_path) + .map(|entries| { + entries.filter_map(|e| e.ok()).any(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.ends_with(".psd1") || name.ends_with(".psm1") + }) + }) + .unwrap_or(false); + if has_module_files { + versions.push("unknown".to_string()); + } } Ok(versions) @@ -237,8 +262,67 @@ struct ModuleRequest { version: Option, } +/// Parse Import-Module statements from PowerShell code into module requests. +fn parse_script_imports(code: &str) -> Vec { + let mut modules = Vec::new(); + for line in code.lines() { + for cap in RE_POWERSHELL_IMPORTS.captures_iter(line) { + let name = cap.get(1).unwrap().as_str().to_string(); + let version = cap.get(2).map(|m| m.as_str().to_string()); + modules.push(ModuleRequest { name, version }); + } + } + modules +} + +/// Parse a modules.json workspace dependencies content into module requests. +/// Format: { "modules": { "ModuleName": "1.0.0", "Another": null } } +fn parse_modules_json(content: &str) -> Result, Error> { + let parsed: serde_json::Value = serde_json::from_str(content).map_err(|e| { + Error::internal_err(format!("Failed to parse PowerShell modules.json: {e}")) + })?; + let modules = parsed + .get("modules") + .and_then(|m| m.as_object()) + .ok_or_else(|| { + Error::internal_err( + "PowerShell modules.json must have a \"modules\" object".to_string(), + ) + })?; + let mut result = Vec::new(); + for (name, version) in modules { + let version = match version { + serde_json::Value::String(v) if v != "*" => Some(v.clone()), + _ => None, + }; + result.push(ModuleRequest { name: name.clone(), version }); + } + Ok(result) +} + +/// Merge workspace dependency modules with script import modules. +/// Workspace dependency versions take precedence on overlap. +fn merge_module_requests( + workspace_modules: Vec, + script_modules: Vec, +) -> Vec { + let mut seen: HashMap = HashMap::new(); + // Script imports first (lower priority) + for m in script_modules { + let key = m.name.to_lowercase(); + seen.entry(key).or_insert(m); + } + // Workspace deps override + for m in workspace_modules { + let key = m.name.to_lowercase(); + seen.insert(key, m); + } + seen.into_values().collect() +} + #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_powershell_job( + maybe_lock: MaybeLock, mem_peak: &mut i32, canceled_by: &mut Option, job: &MiniPulledJob, @@ -303,18 +387,29 @@ pub async fn handle_powershell_job( .join(" ") }; - // First, collect all imported modules - let mut imported_modules: Vec<(String, Option)> = Vec::new(); - for line in content.lines() { - for cap in RE_POWERSHELL_IMPORTS.captures_iter(line) { - let module_name = cap.get(1).unwrap().as_str().to_string(); - let required_version = cap.get(2).map(|m| m.as_str().to_string()); - imported_modules.push((module_name, required_version)); + // Resolve modules from workspace dependencies and/or script imports + let all_modules = match &maybe_lock { + MaybeLock::Resolved { lock } if !lock.is_empty() => { + // Deployed script with lock: parse workspace deps from lock, merge with script imports + let ws_modules = parse_modules_json(lock)?; + let script_modules = parse_script_imports(content); + merge_module_requests(ws_modules, script_modules) } - } + MaybeLock::Unresolved { workspace_dependencies } => { + let script_modules = parse_script_imports(content); + match workspace_dependencies.get_powershell()? { + Some(modules_json) => { + let ws_modules = parse_modules_json(&modules_json)?; + merge_module_requests(ws_modules, script_modules) + } + None => script_modules, + } + } + _ => parse_script_imports(content), + }; // Only scan the top-level cache directory if there are modules to check - let module_dirs = if !imported_modules.is_empty() { + let module_dirs = if !all_modules.is_empty() { scan_module_directories().await? } else { HashMap::new() @@ -323,19 +418,20 @@ pub async fn handle_powershell_job( let mut modules_to_install: Vec = Vec::new(); let mut logs1 = String::new(); - for (module_name, required_version) in imported_modules { + for module_req in all_modules { // Check if this specific module is already installed, only scanning versions if needed - let (is_installed, installed_versions) = - check_module_installed(&module_dirs, &module_name, required_version.as_deref()).await?; + let (is_installed, installed_versions) = check_module_installed( + &module_dirs, + &module_req.name, + module_req.version.as_deref(), + ) + .await?; if !is_installed { - modules_to_install.push(ModuleRequest { - name: module_name.clone(), - version: required_version.clone(), - }); + modules_to_install.push(module_req); } else { // Log what versions are actually installed - let version_info = if let Some(version) = &required_version { + let version_info = if let Some(version) = &module_req.version { format!(" version {} found in cache", version) } else if installed_versions.len() == 1 { format!(" (version {}) found in cache", installed_versions[0]) @@ -347,7 +443,7 @@ pub async fn handle_powershell_job( } else { " found in cache".to_string() }; - logs1.push_str(&format!("\n{}{}", module_name, version_info)); + logs1.push_str(&format!("\n{}{}", module_req.name, version_info)); } } @@ -466,8 +562,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", let strict_termination_end = "\n\ } catch {\n\ - Write-Output \"An error occurred:\n\"\ - Write-Output $_ + Write-Output \"An error occurred:\"\n\ + Write-Output $_\n\ exit 1\n\ }\n"; @@ -518,6 +614,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", let nsjail = is_sandboxing_enabled() && is_regular_job; let child = if nsjail { + let nsjail_timeout = + resolve_nsjail_timeout(db, &job.workspace_id, job.id, job.timeout).await; let _ = write_file( job_dir, "run.config.proto", @@ -525,7 +623,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", .replace("{JOB_DIR}", job_dir) .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{SHARED_MOUNT}", shared_mount) - .replace("{CACHE_DIR}", &*POWERSHELL_CACHE_DIR), + .replace("{CACHE_DIR}", &*POWERSHELL_CACHE_DIR) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let cmd_args = vec![ "--config", @@ -672,3 +771,317 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", "No result.out, result2.out or result.json found" ))) } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + // --- RE_POWERSHELL_IMPORTS regex tests --- + + fn match_import(line: &str) -> Option<(String, Option)> { + RE_POWERSHELL_IMPORTS.captures(line).map(|cap| { + let name = cap.get(1).unwrap().as_str().to_string(); + let version = cap.get(2).map(|m| m.as_str().to_string()); + (name, version) + }) + } + + #[test] + fn test_import_module_basic() { + let (name, version) = match_import("Import-Module WindmillClient").unwrap(); + assert_eq!(name, "WindmillClient"); + assert_eq!(version, None); + } + + #[test] + fn test_import_module_with_leading_whitespace() { + let (name, _) = match_import(" Import-Module WindmillClient").unwrap(); + assert_eq!(name, "WindmillClient"); + } + + #[test] + fn test_import_module_with_tab_indent() { + let (name, _) = match_import("\tImport-Module WindmillClient").unwrap(); + assert_eq!(name, "WindmillClient"); + } + + #[test] + fn test_import_module_with_name_flag() { + let (name, _) = match_import("Import-Module -Name WindmillClient").unwrap(); + assert_eq!(name, "WindmillClient"); + } + + #[test] + fn test_import_module_with_required_version() { + let (name, version) = + match_import(r#"Import-Module WindmillClient -RequiredVersion "1.655.0""#).unwrap(); + assert_eq!(name, "WindmillClient"); + assert_eq!(version, Some("1.655.0".to_string())); + } + + #[test] + fn test_import_module_quoted_name() { + let (name, _) = match_import(r#"Import-Module "WindmillClient""#).unwrap(); + assert_eq!(name, "WindmillClient"); + } + + #[test] + fn test_import_module_name_flag_quoted_with_version() { + let (name, version) = + match_import(r#"Import-Module -Name "WindmillClient" -RequiredVersion "2.0.0""#) + .unwrap(); + assert_eq!(name, "WindmillClient"); + assert_eq!(version, Some("2.0.0".to_string())); + } + + #[test] + fn test_import_module_indented_with_version() { + let (name, version) = + match_import(r#" Import-Module WindmillClient -RequiredVersion 1.0.0"#).unwrap(); + assert_eq!(name, "WindmillClient"); + assert_eq!(version, Some("1.0.0".to_string())); + } + + #[test] + fn test_commented_import_not_matched() { + assert!(match_import("# Import-Module WindmillClient").is_none()); + } + + // --- get_module_versions / check_module_installed tests --- + + #[tokio::test] + async fn test_empty_module_dir_not_installed() { + let tmp = TempDir::new().unwrap(); + let module_dir = tmp.path().join("WindmillClient"); + fs::create_dir(&module_dir).unwrap(); + + let versions = get_module_versions(module_dir.to_str().unwrap()) + .await + .unwrap(); + assert!(versions.is_empty(), "empty dir should have no versions"); + } + + #[tokio::test] + async fn test_empty_version_subdir_not_installed() { + let tmp = TempDir::new().unwrap(); + let module_dir = tmp.path().join("WindmillClient"); + let version_dir = module_dir.join("1.655.0"); + fs::create_dir_all(&version_dir).unwrap(); + + let versions = get_module_versions(module_dir.to_str().unwrap()) + .await + .unwrap(); + assert!( + versions.is_empty(), + "version dir without .psd1/.psm1 should not count" + ); + } + + #[tokio::test] + async fn test_valid_versioned_module_detected() { + let tmp = TempDir::new().unwrap(); + let module_dir = tmp.path().join("WindmillClient"); + let version_dir = module_dir.join("1.655.0"); + fs::create_dir_all(&version_dir).unwrap(); + fs::write(version_dir.join("WindmillClient.psd1"), "# manifest").unwrap(); + fs::write(version_dir.join("WindmillClient.psm1"), "# module").unwrap(); + + let versions = get_module_versions(module_dir.to_str().unwrap()) + .await + .unwrap(); + assert_eq!(versions, vec!["1.655.0"]); + } + + #[tokio::test] + async fn test_flat_module_with_files_detected() { + let tmp = TempDir::new().unwrap(); + let module_dir = tmp.path().join("MyModule"); + fs::create_dir(&module_dir).unwrap(); + fs::write(module_dir.join("MyModule.psm1"), "# module").unwrap(); + + let versions = get_module_versions(module_dir.to_str().unwrap()) + .await + .unwrap(); + assert_eq!(versions, vec!["unknown"]); + } + + #[tokio::test] + async fn test_flat_module_without_files_not_detected() { + let tmp = TempDir::new().unwrap(); + let module_dir = tmp.path().join("MyModule"); + fs::create_dir(&module_dir).unwrap(); + fs::write(module_dir.join("readme.txt"), "not a module").unwrap(); + + let versions = get_module_versions(module_dir.to_str().unwrap()) + .await + .unwrap(); + assert!(versions.is_empty()); + } + + #[tokio::test] + async fn test_check_module_installed_empty_dir_returns_false() { + let tmp = TempDir::new().unwrap(); + let module_dir = tmp.path().join("WindmillClient"); + fs::create_dir(&module_dir).unwrap(); + + let mut dirs = HashMap::new(); + dirs.insert( + "windmillclient".to_string(), + module_dir.to_str().unwrap().to_string(), + ); + + let (installed, _) = check_module_installed(&dirs, "WindmillClient", None) + .await + .unwrap(); + assert!( + !installed, + "empty module dir should not be considered installed" + ); + } + + #[tokio::test] + async fn test_check_module_installed_valid_module_returns_true() { + let tmp = TempDir::new().unwrap(); + let module_dir = tmp.path().join("WindmillClient"); + let version_dir = module_dir.join("1.655.0"); + fs::create_dir_all(&version_dir).unwrap(); + fs::write(version_dir.join("WindmillClient.psd1"), "# manifest").unwrap(); + + let mut dirs = HashMap::new(); + dirs.insert( + "windmillclient".to_string(), + module_dir.to_str().unwrap().to_string(), + ); + + let (installed, versions) = check_module_installed(&dirs, "WindmillClient", None) + .await + .unwrap(); + assert!(installed); + assert_eq!(versions, vec!["1.655.0"]); + } + + #[tokio::test] + async fn test_check_module_installed_wrong_version_returns_false() { + let tmp = TempDir::new().unwrap(); + let module_dir = tmp.path().join("WindmillClient"); + let version_dir = module_dir.join("1.0.0"); + fs::create_dir_all(&version_dir).unwrap(); + fs::write(version_dir.join("WindmillClient.psd1"), "# manifest").unwrap(); + + let mut dirs = HashMap::new(); + dirs.insert( + "windmillclient".to_string(), + module_dir.to_str().unwrap().to_string(), + ); + + let (installed, _) = check_module_installed(&dirs, "WindmillClient", Some("2.0.0")) + .await + .unwrap(); + assert!(!installed, "wrong version should not match"); + } + + // --- parse_modules_json tests --- + + #[test] + fn test_parse_modules_json_basic() { + let json = r#"{"modules": {"PSWriteColor": "1.0.0", "ImportExcel": null}}"#; + let modules = parse_modules_json(json).unwrap(); + assert_eq!(modules.len(), 2); + let by_name: HashMap> = + modules.into_iter().map(|m| (m.name, m.version)).collect(); + assert_eq!(by_name["PSWriteColor"], Some("1.0.0".to_string())); + assert_eq!(by_name["ImportExcel"], None); + } + + #[test] + fn test_parse_modules_json_wildcard_treated_as_none() { + let json = r#"{"modules": {"Mod": "*"}}"#; + let modules = parse_modules_json(json).unwrap(); + assert_eq!(modules[0].version, None); + } + + #[test] + fn test_parse_modules_json_empty() { + let json = r#"{"modules": {}}"#; + let modules = parse_modules_json(json).unwrap(); + assert!(modules.is_empty()); + } + + #[test] + fn test_parse_modules_json_missing_modules_key() { + let json = r#"{"deps": {}}"#; + assert!(parse_modules_json(json).is_err()); + } + + #[test] + fn test_parse_modules_json_invalid_json() { + assert!(parse_modules_json("not json").is_err()); + } + + // --- parse_script_imports tests --- + + #[test] + fn test_parse_script_imports() { + let code = r#"Import-Module PSWriteColor +Import-Module ImportExcel -RequiredVersion "7.8.6" +# Import-Module Commented +Write-Host "Hello""#; + let modules = parse_script_imports(code); + assert_eq!(modules.len(), 2); + assert_eq!(modules[0].name, "PSWriteColor"); + assert_eq!(modules[0].version, None); + assert_eq!(modules[1].name, "ImportExcel"); + assert_eq!(modules[1].version, Some("7.8.6".to_string())); + } + + // --- merge_module_requests tests --- + + #[test] + fn test_merge_workspace_overrides_script() { + let ws = vec![ModuleRequest { name: "Mod".to_string(), version: Some("2.0".to_string()) }]; + let script = + vec![ModuleRequest { name: "Mod".to_string(), version: Some("1.0".to_string()) }]; + let merged = merge_module_requests(ws, script); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].version, Some("2.0".to_string())); + } + + #[test] + fn test_merge_combines_distinct_modules() { + let ws = vec![ModuleRequest { name: "WsMod".to_string(), version: None }]; + let script = vec![ModuleRequest { name: "ScriptMod".to_string(), version: None }]; + let merged = merge_module_requests(ws, script); + assert_eq!(merged.len(), 2); + } + + #[test] + fn test_merge_case_insensitive() { + let ws = + vec![ModuleRequest { name: "MyModule".to_string(), version: Some("2.0".to_string()) }]; + let script = + vec![ModuleRequest { name: "mymodule".to_string(), version: Some("1.0".to_string()) }]; + let merged = merge_module_requests(ws, script); + assert_eq!(merged.len(), 1); + // Workspace version wins + assert_eq!(merged[0].version, Some("2.0".to_string())); + } + + #[tokio::test] + async fn test_multiple_versions_detected() { + let tmp = TempDir::new().unwrap(); + let module_dir = tmp.path().join("WindmillClient"); + for ver in &["1.0.0", "1.655.0"] { + let version_dir = module_dir.join(ver); + fs::create_dir_all(&version_dir).unwrap(); + fs::write(version_dir.join("WindmillClient.psd1"), "# manifest").unwrap(); + } + + let mut versions = get_module_versions(module_dir.to_str().unwrap()) + .await + .unwrap(); + versions.sort(); + assert_eq!(versions, vec!["1.0.0", "1.655.0"]); + } +} diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 3a040234b6..4dfccacdb8 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -128,7 +128,8 @@ use windmill_object_store::OBJECT_STORE_SETTINGS; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_file, - read_result, start_child_process, OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, start_child_process, OccupancyMetrics, StreamNotifier, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -542,6 +543,32 @@ async fn postinstall( Ok(()) } +/// Compute the directory (relative to job_dir) where Python writes the main script. +/// Module files must be placed in this same directory for relative imports to work. +pub fn compute_python_module_dir(script_path: &str) -> String { + let script_path_splitted = script_path.split("/").map(|x| { + if x.starts_with(|x: char| x.is_ascii_digit()) { + format!("_{}", x) + } else { + x.to_string() + } + }); + let dirs_full = script_path_splitted + .clone() + .take(script_path_splitted.clone().count() - 1) + .join("/") + .replace("-", "_") + .replace("@", "."); + if dirs_full.len() > 0 { + dirs_full + .strip_prefix("/") + .unwrap_or(&dirs_full) + .to_string() + } else { + "tmp".to_string() + } +} + #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_python_job( requirements_o: Option<&String>, @@ -562,6 +589,7 @@ pub async fn handle_python_job( occupancy_metrics: &mut OccupancyMetrics, precomputed_agent_info: Option, has_stream: &mut bool, + modules: &Option>, ) -> windmill_common::error::Result> { let script_path = crate::common::use_flow_root_path(job.runnable_path()); @@ -679,6 +707,7 @@ pub async fn handle_python_job( } else { String::new() }; + let main_override = main_name.unwrap_or_else(|| "main".to_string()); let res_to_json_body = python_res_to_json_body(postprocessor); let wrapper_content: String = if is_wac_v2 { @@ -696,8 +725,8 @@ from wmill.client import _run_workflow with open("args.json") as f: kwargs = json.load(f, strict=False) -args = {{}} {transforms} +args = kwargs with open("checkpoint.json") as f: checkpoint = json.load(f, strict=False) @@ -721,6 +750,9 @@ for k, v in list(args.items()): try: output = _run_workflow(workflow_fn, checkpoint, args) + if isinstance(output, dict) and output.get("type") == "complete": + print("") + print("--- WAC: complete ---") output_json = json.dumps(output, separators=(',', ':'), default=str) with open(result_json, 'w') as f: f.write(output_json) @@ -875,6 +907,8 @@ mount {{ ) }) .join("\n"); + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; let _ = write_file( job_dir, "run.config.proto", @@ -891,7 +925,8 @@ mount {{ additional_python_paths_folders.as_str(), ) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) - .replace("#{DEV}", DEV_CONF_NSJAIL), + .replace("#{DEV}", DEV_CONF_NSJAIL) + .replace("{TIMEOUT}", &nsjail_timeout), )?; } else { reserved_variables.insert("PYTHONPATH".to_string(), additional_python_paths_folders); @@ -1010,7 +1045,10 @@ mount {{ // WAC v2 post-execution: parse output and handle dispatch/suspend. // Box::pin to avoid bloating handle_python_job's async state machine (stack overflow). if is_wac_v2 { - return Box::pin(crate::bun_executor::handle_wac_v2_output(result, job, conn)).await; + return Box::pin(crate::bun_executor::handle_wac_v2_output( + result, job, conn, modules, + )) + .await; } Ok(result) @@ -1065,6 +1103,7 @@ async fn prepare_wrapper( let relative_imports = RELATIVE_IMPORT_REGEX.is_match(&inner_content); + let dirs = compute_python_module_dir(script_path); let script_path_splitted = script_path.split("/").map(|x| { if x.starts_with(|x: char| x.is_ascii_digit()) { format!("_{}", x) @@ -1072,20 +1111,6 @@ async fn prepare_wrapper( x.to_string() } }); - let dirs_full = script_path_splitted - .clone() - .take(script_path_splitted.clone().count() - 1) - .join("/") - .replace("-", "_") - .replace("@", "."); - let dirs = if dirs_full.len() > 0 { - dirs_full - .strip_prefix("/") - .unwrap_or(&dirs_full) - .to_string() - } else { - "tmp".to_string() - }; let last = script_path_splitted .clone() .last() @@ -2618,3 +2643,56 @@ for line in sys.stdin: ) .await } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_python_module_dir_nested_path() { + assert_eq!( + compute_python_module_dir("f/my_folder/my_script"), + "f/my_folder" + ); + } + + #[test] + fn test_compute_python_module_dir_deep_path() { + assert_eq!(compute_python_module_dir("f/a/b/c/script"), "f/a/b/c"); + } + + #[test] + fn test_compute_python_module_dir_root_level() { + // Root-level script (no parent dirs) should fall back to "tmp" + assert_eq!(compute_python_module_dir("my_script"), "tmp"); + } + + #[test] + fn test_compute_python_module_dir_single_folder() { + assert_eq!(compute_python_module_dir("f/script"), "f"); + } + + #[test] + fn test_compute_python_module_dir_digit_prefix() { + // Dirs starting with digits get underscore-prefixed + assert_eq!( + compute_python_module_dir("1st_folder/script"), + "_1st_folder" + ); + } + + #[test] + fn test_compute_python_module_dir_hyphens_replaced() { + // Hyphens are replaced with underscores + assert_eq!( + compute_python_module_dir("my-folder/sub-dir/script"), + "my_folder/sub_dir" + ); + } + + #[test] + fn test_compute_python_module_dir_at_replaced() { + // @ is replaced with . + assert_eq!(compute_python_module_dir("u/@admin/script"), "u/.admin"); + } +} diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index e26633fc9a..e6faf74f69 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -16,6 +16,10 @@ use windmill_common::otel_oss::FutureExt; use uuid::Uuid; +/// Set by the result processor when a WAC child completion makes suspend reach 0, +/// signaling the worker main loop to check for suspended jobs immediately. +pub static WAC_SUSPEND_READY: AtomicBool = AtomicBool::new(false); + use windmill_common::{ add_time, error::{self, Error}, @@ -422,11 +426,14 @@ pub fn start_background_processor( } async fn send_job_completed(job_completed_tx: JobCompletedSender, jc: JobCompleted) { - job_completed_tx + if let Err(e) = job_completed_tx .send_job(jc, true) .with_context(windmill_common::otel_oss::otel_ctx()) .await - .expect("send job completed") + { + tracing::error!("send job completed failed, triggering worker shutdown: {e:#}"); + job_completed_tx.send_worker_killpill(); + } } pub async fn process_result( @@ -1011,6 +1018,7 @@ pub(crate) async fn handle_wac_child_completion( parent_job = %parent_job_id, "WAC v2 all child jobs completed, unsuspending parent" ); + WAC_SUSPEND_READY.store(true, Ordering::Relaxed); } Ok(Some(())) diff --git a/backend/windmill-worker/src/ruby_executor.rs b/backend/windmill-worker/src/ruby_executor.rs index d283b2a1c4..ce8fccb2f1 100644 --- a/backend/windmill-worker/src/ruby_executor.rs +++ b/backend/windmill-worker/src/ruby_executor.rs @@ -23,7 +23,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; use crate::{ common::{ build_command_with_isolation, create_args_and_out_file, get_reserved_variables, - read_result, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, + read_result, resolve_nsjail_timeout, start_child_process, OccupancyMetrics, + DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::{self}, @@ -792,6 +793,8 @@ mount {{ }) .join("\n"); + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; write_file( job_dir, "run.config.proto", @@ -801,7 +804,8 @@ mount {{ .replace("{SHARED_DEPENDENCIES}", &shared_deps) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()), + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut cmd = Command::new(NSJAIL_PATH.as_str()); cmd.env_clear() diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index dae8e08766..2ecd618e12 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -23,8 +23,8 @@ use windmill_queue::{append_logs, CanceledBy}; use crate::{ common::{ build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file, - get_reserved_variables, read_result, start_child_process, OccupancyMetrics, - DEV_CONF_NSJAIL, + get_reserved_variables, read_result, resolve_nsjail_timeout, start_child_process, + OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -682,6 +682,8 @@ pub async fn handle_rust_job( append_logs(&job.id, &job.workspace_id, logs2, conn).await; let child = if is_sandboxing_enabled() { + let nsjail_timeout = + resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; let _ = write_file( job_dir, "run.config.proto", @@ -692,7 +694,8 @@ pub async fn handle_rust_job( .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) .replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH) .replace("#{DEV}", DEV_CONF_NSJAIL) - .replace("{SHARED_MOUNT}", shared_mount), + .replace("{SHARED_MOUNT}", shared_mount) + .replace("{TIMEOUT}", &nsjail_timeout), )?; let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); nsjail_cmd diff --git a/backend/windmill-worker/src/wac_executor.rs b/backend/windmill-worker/src/wac_executor.rs index 7c350ac92b..1e7ed4b090 100644 --- a/backend/windmill-worker/src/wac_executor.rs +++ b/backend/windmill-worker/src/wac_executor.rs @@ -47,7 +47,14 @@ pub enum WacOutput { /// An inline step executed in the parent process — persist result to /// checkpoint and re-run immediately (no child job, no suspend). #[serde(rename = "inline_checkpoint")] - InlineCheckpoint { key: String, result: Value }, + InlineCheckpoint { + key: String, + result: Value, + #[serde(default)] + started_at: Option, + #[serde(default)] + duration_ms: Option, + }, /// Suspend the workflow waiting for an external approval event. /// No child job is dispatched — the parent suspends directly and resumes /// when a user hits the resume/cancel endpoint. @@ -285,16 +292,20 @@ pub async fn prepare_checkpoint_for_resume( /// Detect WAC v2 patterns in TypeScript/Bun code. /// Checks for `import ... from "windmill-client"` containing workflow/task, -/// skipping comment lines. +/// skipping comment lines. Handles both single-line and multi-line imports. pub fn is_wac_v2_ts(code: &str) -> bool { let mut has_wac_import = false; let mut has_workflow = false; let mut has_task = false; + let mut in_import_block = false; + let mut import_block_has_workflow = false; + let mut import_block_has_task = false; for line in code.lines() { let trimmed = line.trim(); if trimmed.starts_with("//") { continue; } + // Single-line import: import { workflow, task } from "windmill-client" if trimmed.contains("windmill-client") && (trimmed.starts_with("import") || trimmed.starts_with("from")) { @@ -305,6 +316,37 @@ pub fn is_wac_v2_ts(code: &str) -> bool { if trimmed.contains("task") { has_task = true; } + in_import_block = false; + } + // Start of multi-line import: import { + else if trimmed.starts_with("import") && trimmed.contains("{") && !trimmed.contains("}") { + in_import_block = true; + import_block_has_workflow = trimmed.contains("workflow"); + import_block_has_task = trimmed.contains("task"); + } + // Inside multi-line import block + else if in_import_block { + if trimmed.contains("workflow") { + import_block_has_workflow = true; + } + if trimmed.contains("task") { + import_block_has_task = true; + } + // End of multi-line import: } from "windmill-client" + if trimmed.contains("windmill-client") { + has_wac_import = true; + if import_block_has_workflow { + has_workflow = true; + } + if import_block_has_task { + has_task = true; + } + in_import_block = false; + } + // End of import block but not windmill-client + if trimmed.contains("}") { + in_import_block = false; + } } if trimmed.contains("export") && trimmed.contains("workflow(") { has_workflow = true; diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 3ea0dd0f6b..ebc8495a24 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -24,6 +24,7 @@ use windmill_common::runtime_assets::init_runtime_asset_loop; use windmill_common::runtime_assets::register_runtime_asset; use windmill_common::scripts::hash_to_codebase_id; use windmill_common::scripts::is_special_codebase_hash; +use windmill_common::scripts::ScriptModule; use windmill_common::utils::report_critical_error; use windmill_common::utils::retrieve_common_worker_prefix; use windmill_common::worker::error_to_value; @@ -303,7 +304,7 @@ pub struct PowershellRepo { lazy_static::lazy_static! { - pub static ref SLEEP_QUEUE: u64 = std::env::var("SLEEP_QUEUE") + static ref SLEEP_QUEUE_BASE: u64 = std::env::var("SLEEP_QUEUE") .ok() .and_then(|x| x.parse::().ok()) .unwrap_or_else(|| { @@ -647,6 +648,14 @@ lazy_static::lazy_static! { pub static ref FLOW_RUNNER_RUNNING: Mutex = Mutex::new(false); } +pub fn sleep_queue() -> u64 { + if NATIVE_MODE_RESOLVED.load(std::sync::atomic::Ordering::Relaxed) { + 300 + } else { + *SLEEP_QUEUE_BASE + } +} + type Envs = Vec<(String, String)>; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -887,6 +896,19 @@ impl JobCompletedSender { pub fn is_sql(&self) -> bool { matches!(self, Self::Sql(_)) } + + pub fn set_worker_killpill(&mut self, killpill_tx: KillpillSender) { + if let Self::Sql(sql) = self { + sql.worker_killpill_tx = Some(killpill_tx); + } + } + + pub fn send_worker_killpill(&self) { + if let Self::Sql(SqlJobCompletedSender { worker_killpill_tx: Some(killpill_tx), .. }) = self + { + killpill_tx.send(); + } + } } #[derive(Clone)] @@ -894,6 +916,7 @@ pub struct SqlJobCompletedSender { sender: flume::Sender, unbounded_sender: flume::Sender, killpill_tx: broadcast::Sender<()>, + worker_killpill_tx: Option, } pub struct JobCompletedReceiver { @@ -918,7 +941,12 @@ impl JobCompletedSender { let (unbounded_sender, unbounded_rx) = flume::unbounded::(); let (killpill_tx, killpill_rx) = broadcast::channel::<()>(10); ( - Self::Sql(SqlJobCompletedSender { sender, unbounded_sender, killpill_tx }), + Self::Sql(SqlJobCompletedSender { + sender, + unbounded_sender, + killpill_tx, + worker_killpill_tx: None, + }), JobCompletedReceiver { bounded_rx: receiver, killpill_rx, unbounded_rx }, ) } @@ -1373,7 +1401,7 @@ fn start_interactive_worker_shell( { Duration::from_secs(WORKER_SHELL_NAP_TIME_DURATION) } - _ => Duration::from_millis(*SLEEP_QUEUE * 10), + _ => Duration::from_millis(sleep_queue() * 10), }; tokio::select! { _ = tokio::time::sleep(nap_time) => { @@ -1386,7 +1414,7 @@ fn start_interactive_worker_shell( Err(err) => { tracing::error!(worker = %worker_name, hostname = %hostname, "Failed to pull jobs: {}", err); - tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 20)).await; + tokio::time::sleep(Duration::from_millis(sleep_queue() * 20)).await; } }; } @@ -1714,7 +1742,8 @@ pub async fn run_worker( let (same_worker_tx, mut same_worker_rx) = mpsc::channel::(5); - let (job_completed_tx, job_completed_rx) = JobCompletedSender::new(&conn, 10); + let (mut job_completed_tx, job_completed_rx) = JobCompletedSender::new(&conn, 10); + job_completed_tx.set_worker_killpill(killpill_tx.clone()); let same_worker_queue_size = Arc::new(AtomicU16::new(0)); let same_worker_tx = SameWorkerSender(same_worker_tx, same_worker_queue_size.clone()); @@ -2035,6 +2064,7 @@ pub async fn run_worker( } } + let mut was_suspended_job = false; let next_job = { // println!("2: {:?}", instant.elapsed()); #[cfg(feature = "benchmark")] @@ -2118,7 +2148,9 @@ pub async fn run_worker( let suspend_first = suspend_first_success || rand::random::() < likelihood_of_suspend - || last_suspend_first.elapsed().as_secs_f64() > 5.0; + || last_suspend_first.elapsed().as_secs_f64() > 5.0 + || crate::result_processor::WAC_SUSPEND_READY + .swap(false, Ordering::Relaxed); if suspend_first { last_suspend_first = Instant::now(); @@ -2191,6 +2223,7 @@ pub async fn run_worker( } } + was_suspended_job = job.as_ref().is_ok_and(|j| j.suspended); if let Ok(j) = job.as_ref() { let suspend_success = j.suspended; if suspend_first { @@ -2408,7 +2441,9 @@ pub async fn run_worker( .expect("send job completed END"); add_time!(bench, "sent job completed"); } else { - add_outstanding_wait_time(&conn, &job, *OUTSTANDING_WAIT_TIME_THRESHOLD_MS); + if !was_suspended_job { + add_outstanding_wait_time(&conn, &job, *OUTSTANDING_WAIT_TIME_THRESHOLD_MS); + } #[cfg(feature = "prometheus")] register_metric( @@ -2699,7 +2734,7 @@ pub async fn run_worker( None }; - tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE)).await; + tokio::time::sleep(Duration::from_millis(sleep_queue())).await; #[cfg(feature = "benchmark")] { @@ -2720,7 +2755,7 @@ pub async fn run_worker( } Err(err) => { tracing::error!(worker = %worker_name, hostname = %hostname, "Failed to pull jobs: {}", err); - tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 5)).await; + tokio::time::sleep(Duration::from_millis(sleep_queue() * 5)).await; } }; } @@ -3278,10 +3313,22 @@ pub async fn handle_queued_job( "none" }; - logs.push_str(&format!( - "job={} {}={} worker={} hostname={} isolation={}\n", - &job.id, *LOG_TAG_NAME, &job.tag, &worker_name, &hostname, isolation_label - )); + // Skip verbose job header for WAC v2 replays (checkpoint has completed steps) + let is_wac_replay = if let Connection::Sql(db) = conn { + crate::wac_executor::load_checkpoint(db, &job.id) + .await + .map(|c| !c.completed_steps.is_empty()) + .unwrap_or(false) + } else { + false + }; + + if !is_wac_replay { + logs.push_str(&format!( + "job={} {}={} worker={} hostname={} isolation={}\n", + &job.id, *LOG_TAG_NAME, &job.tag, &worker_name, &hostname, isolation_label + )); + } if *NO_LOGS_AT_ALL { logs.push_str("Logs are fully disabled for this worker\n"); @@ -3588,6 +3635,7 @@ pub struct ContentReqLangEnvs { pub envs: Option>, pub codebase: Option, pub schema: Option, + pub modules: Option>, } pub async fn get_hub_script_content_and_requirements( @@ -3607,6 +3655,7 @@ pub async fn get_hub_script_content_and_requirements( envs: None, codebase: None, schema: Some(script.schema.get().to_string()), + modules: None, }) } @@ -3627,6 +3676,7 @@ pub async fn get_script_content_by_hash( Some(_) => Some(script_hash.to_string()), }, schema: None, + modules: data.modules.clone(), }) } @@ -3756,7 +3806,7 @@ async fn handle_code_execution_job( // Box::pin the script fetching match to prevent large enum on stack let ( - ScriptData { code, lock }, + ScriptData { code, lock, modules: modules_from_data }, ScriptMetadata { language, envs, codebase, schema_validator, schema }, ) = match job.kind { JobKind::Preview => { @@ -3781,14 +3831,14 @@ async fn handle_code_execution_job( } } JobKind::Script_Hub => { - let ContentReqLangEnvs { content, lockfile, language, envs, codebase, schema } = + let ContentReqLangEnvs { content, lockfile, language, envs, codebase, schema, .. } = Box::pin(get_hub_script_content_and_requirements( job.runnable_path.as_ref(), conn.as_sql(), )) .await?; - data = ScriptData { code: content, lock: lockfile }; + data = ScriptData { code: content, lock: lockfile, modules: None }; metadata = ScriptMetadata { language, envs, codebase, schema, schema_validator: None }; (&data, &metadata) } @@ -3833,13 +3883,20 @@ async fn handle_code_execution_job( .as_ref() .ok_or_else(|| Error::internal_err("expected script path".to_string()))?; if script_path.starts_with("hub/") { - let ContentReqLangEnvs { content, lockfile, language, envs, codebase, schema } = - Box::pin(get_hub_script_content_and_requirements( - Some(script_path), - conn.as_sql(), - )) - .await?; - data = ScriptData { code: content, lock: lockfile }; + let ContentReqLangEnvs { + content, + lockfile, + language, + envs, + codebase, + schema, + .. + } = Box::pin(get_hub_script_content_and_requirements( + Some(script_path), + conn.as_sql(), + )) + .await?; + data = ScriptData { code: content, lock: lockfile, modules: None }; metadata = ScriptMetadata { language, envs, codebase, schema, schema_validator: None }; (&data, &metadata) @@ -3870,6 +3927,16 @@ async fn handle_code_execution_job( ), }; + // For preview jobs, extract modules from args._MODULES if not already set + let modules = modules_from_data.clone().or_else(|| { + job.args.as_ref().and_then(|args| { + args.get("_MODULES").and_then(|raw| { + serde_json::from_str::>(raw.get()) + .ok() + }) + }) + }); + try_validate_schema( job, conn, @@ -3903,11 +3970,50 @@ async fn handle_code_execution_job( envs, codebase, lock, + &modules, false, ) .await } +pub async fn write_module_files( + job_dir: &str, + modules: &std::collections::HashMap, + base_dir: Option<&str>, +) -> error::Result<()> { + for (relpath, module) in modules { + // Reject path traversal attempts in module paths + if relpath.contains("..") { + tracing::warn!("Skipping module with path traversal: {relpath}"); + continue; + } + let full_path = match base_dir { + Some(dir) => format!("{}/{}/{}", job_dir, dir, relpath), + None => format!("{}/{}", job_dir, relpath), + }; + if let Some(parent) = std::path::Path::new(&full_path).parent() { + tokio::fs::create_dir_all(parent).await?; + } + // For Python modules, create __init__.py in each intermediate directory + // between base_dir and the module's parent so that relative imports work. + if let Some(dir) = base_dir { + let rel = std::path::Path::new(relpath); + let base = std::path::Path::new(job_dir).join(dir); + let mut current = base.clone(); + for component in rel.parent().into_iter().flat_map(|p| p.components()) { + current = current.join(component); + let init_py = current.join("__init__.py"); + if !init_py.exists() { + tokio::fs::write(&init_py, "").await?; + } + } + } + tracing::debug!("Writing module file: {full_path}"); + tokio::fs::write(&full_path, &module.content).await?; + } + Ok(()) +} + pub async fn run_language_executor( job: &MiniPulledJob, conn: &Connection, @@ -3930,8 +4036,24 @@ pub async fn run_language_executor( envs: &Option>, codebase: &Option, lock: &Option, + modules: &Option>, run_inline: bool, ) -> error::Result> { + if let Some(modules) = modules { + #[cfg(feature = "python")] + let base_dir = if language == Some(ScriptLang::Python3) { + let script_path = crate::common::use_flow_root_path(job.runnable_path()); + Some(crate::python_executor::compute_python_module_dir( + &script_path, + )) + } else { + None + }; + #[cfg(not(feature = "python"))] + let base_dir: Option = None; + write_module_files(job_dir, modules, base_dir.as_deref()).await?; + } + if language == Some(ScriptLang::Postgresql) { return Box::pin(do_postgresql( job, @@ -4405,6 +4527,7 @@ mount {{ occupancy_metrics, precomputed_agent_info, has_stream, + modules, )) .await } @@ -4468,6 +4591,7 @@ mount {{ occupancy_metrics, precomputed_agent_info, has_stream, + modules, )) .await } @@ -4534,7 +4658,17 @@ mount {{ "Inline execution is not yet supported for this language".to_string(), )); } + let maybe_lock = resolve_maybe_lock( + &lock, + &code, + language, + &job.workspace_id, + job.runnable_path(), + conn.clone(), + ) + .await?; Box::pin(handle_powershell_job( + maybe_lock, mem_peak, canceled_by, job, @@ -4970,6 +5104,7 @@ pub fn init_worker_internal_server_inline_utils( &None, &None, &None, + &None, true, ) .await @@ -5050,6 +5185,7 @@ pub fn init_worker_internal_server_inline_utils( &content_info.envs, &content_info.codebase, &content_info.lockfile, + &content_info.modules, true, ) .await diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index dfc2bbeed9..402df2a0a9 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -5035,6 +5035,7 @@ pub fn raw_script_to_payload( concurrency_settings, // TODO: Should this have debouncing? debouncing_settings: DebouncingSettings::default(), + modules: None, }), tag, delete_after_use, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 8636105243..daba455f1c 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -182,13 +182,62 @@ pub async fn handle_dependency_job( let (deployment_message, parent_path) = get_deployment_msg_and_parent_path_from_args(job.args.clone()); + // Generate lockfiles for module files (if any) + let updated_modules = if let Some(modules) = &script_data.modules { + let mut updated = modules.clone(); + for (module_path, module) in updated.iter_mut() { + if module.content.is_empty() { + continue; + } + match capture_dependency_job( + &job.id, + &module.language, + &module.content, + mem_peak, + canceled_by, + job_dir, + db, + worker_name, + &job.workspace_id, + worker_dir, + base_internal_url, + token, + script_path, + occupancy_metrics, + &raw_workspace_dependencies_o, + module.lock.as_deref(), + triggered_by_relative_import, + script_path, + None, + "script", + ) + .await + { + Ok(lock) => { + module.lock = Some(lock); + } + Err(e) => { + tracing::warn!( + "Failed to generate lockfile for module {module_path}: {e}" + ); + } + } + } + Some(updated) + } else { + None + }; + // We do not create new row for this update // That means we can keep current hash and just update lock // Also store lockfile hash for dependency change detection let lockfile_hash = windmill_common::scripts::hash_script(&content); + let updated_modules_json = updated_modules + .as_ref() + .and_then(|m| serde_json::to_value(m).ok()); sqlx::query!( "WITH update_lock AS ( - UPDATE script SET lock = $1 WHERE hash = $2 AND workspace_id = $3 + UPDATE script SET lock = $1, modules = COALESCE($6, modules) WHERE hash = $2 AND workspace_id = $3 ) INSERT INTO lock_hash (workspace_id, path, lockfile_hash) VALUES ($3, $4, $5) @@ -197,7 +246,8 @@ pub async fn handle_dependency_job( ¤t_hash.0, w_id, script_path, - &lockfile_hash + &lockfile_hash, + updated_modules_json ) .execute(db) .await?; @@ -2535,6 +2585,7 @@ async fn capture_dependency_job( &workspace_dependencies, windmill_common::worker::TypeScriptAnnotations::parse(job_raw_code).npm, &mut Some(occupancy_metrics), + false, ) .await? { diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 207d9ec7b8..6e6459b2c9 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.654.0"; +export const VERSION = "v1.658.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index 3aa8305542..f048aac616 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -275,6 +275,9 @@ const command = new Command() "Default TypeScript runtime (bun or deno)" ) .action(async (opts: any, appFolder: string | undefined) => { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); const { generateLocksCommand } = await import("./app_metadata.ts"); await generateLocksCommand(opts, appFolder); }); diff --git a/cli/src/commands/app/app_metadata.ts b/cli/src/commands/app/app_metadata.ts index 3a28770225..253dc99f3b 100644 --- a/cli/src/commands/app/app_metadata.ts +++ b/cli/src/commands/app/app_metadata.ts @@ -93,7 +93,16 @@ async function generateAppHash( } /** - * Updates locks for inline scripts in an app + * Result of generating app locks, including which scripts were updated + */ +export interface AppLocksResult { + path: string; + updatedScripts: string[]; +} + +/** + * Updates locks for inline scripts in an app. + * Returns the path if dry-run, or AppLocksResult with updated scripts if actual update occurred. */ export async function generateAppLocksInternal( appFolder: string, @@ -105,7 +114,7 @@ export async function generateAppLocksInternal( }, justUpdateMetadataLock?: boolean, noStaleMessage?: boolean -): Promise { +): Promise { if (appFolder.endsWith(SEP)) { appFolder = appFolder.substring(0, appFolder.length - 1); } @@ -157,7 +166,7 @@ export async function generateAppLocksInternal( return remote_path; } - if (Object.keys(filteredDeps).length > 0) { + if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) { log.info( (await blueColor())( `Found workspace dependencies (${workspaceDependenciesLanguages @@ -167,6 +176,8 @@ export async function generateAppLocksInternal( ); } + let updatedScripts: string[] = []; + if (!justUpdateMetadataLock) { const changedScripts = []; // Find hashes that do not correspond to previous hashes @@ -180,9 +191,11 @@ export async function generateAppLocksInternal( } if (changedScripts.length > 0) { - log.info( - `Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}` - ); + if (!noStaleMessage) { + log.info( + `Recomputing locks of ${changedScripts.join(", ")} in ${appFolder}` + ); + } if (rawApp) { const runnablesPath = path.join(appFolder, APP_BACKEND_FOLDER); @@ -199,13 +212,14 @@ export async function generateAppLocksInternal( replaceInlineScripts(runnables, runnablesPath + SEP, false); // Update the app runnables with new locks (writes to separate files) - await updateRawAppRunnables( + updatedScripts = await updateRawAppRunnables( workspace, runnables, remote_path, appFolder, filteredDeps, - opts.defaultTs + opts.defaultTs, + noStaleMessage ); // Note: updateRawAppRunnables now writes each runnable to its own file } else { @@ -215,14 +229,17 @@ export async function generateAppLocksInternal( replaceInlineScripts(normalAppFile.value, appFolder + SEP, false); // Update the app value with new locks - normalAppFile.value = await updateAppInlineScripts( + const result = await updateAppInlineScripts( workspace, normalAppFile.value, remote_path, appFolder, filteredDeps, - opts.defaultTs + opts.defaultTs, + noStaleMessage ); + normalAppFile.value = result.value; + updatedScripts = result.updatedScripts; // Write the updated app file (only for normal apps, raw apps use separate files) writeIfChanged( @@ -230,7 +247,7 @@ export async function generateAppLocksInternal( yamlStringify(appFile as Record, yamlOptions) ); } - } else { + } else if (!noStaleMessage) { log.info(colors.gray(`No scripts changed in ${appFolder}`)); } } @@ -246,7 +263,11 @@ export async function generateAppLocksInternal( for (const [scriptPath, hash] of Object.entries(hashes)) { await updateMetadataGlobalLock(appFolder, hash, scriptPath); } - log.info(colors.green(`App ${remote_path} lockfiles updated`)); + if (!noStaleMessage) { + log.info(colors.green(`App ${remote_path} lockfiles updated`)); + } + + return { path: remote_path, updatedScripts }; } /** @@ -336,6 +357,7 @@ async function traverseAndProcessInlineScripts( * Updates locks for all runnables in a raw app, generating locks inline script by inline script. * Writes each runnable to its own YAML file in the backend folder (new format). * Also writes content and lock files to the runnables folder. + * Returns the list of runnable IDs that had their locks updated. */ async function updateRawAppRunnables( workspace: Workspace, @@ -343,8 +365,10 @@ async function updateRawAppRunnables( remotePath: string, appFolder: string, rawDeps?: Record, - defaultTs: "bun" | "deno" = "bun" -): Promise { + defaultTs: "bun" | "deno" = "bun", + noStaleMessage?: boolean +): Promise { + const updatedRunnables: string[] = []; const runnablesFolder = path.join(appFolder, APP_BACKEND_FOLDER); // Ensure runnables folder exists @@ -410,12 +434,11 @@ async function updateRawAppRunnables( continue; } - log.info( - colors.gray( - `Generating lock for runnable ${runnableId} (${language}) - }` - ) - ); + if (!noStaleMessage) { + log.info( + colors.gray(`Generating lock for runnable ${runnableId} (${language})`) + ); + } try { const lock = await generateInlineScriptLock( @@ -455,11 +478,15 @@ async function updateRawAppRunnables( // Write the runnable to its own YAML file writeRunnableToBackend(runnablesFolder, runnableId, simplifiedRunnable); - log.info( - colors.gray( - ` Written ${runnableId}.yaml, ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}` - ) - ); + updatedRunnables.push(runnableId); + + if (!noStaleMessage) { + log.info( + colors.gray( + ` Written ${runnableId}.yaml, ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}` + ) + ); + } } catch (error: any) { log.error( colors.red( @@ -470,11 +497,14 @@ async function updateRawAppRunnables( writeRunnableToBackend(runnablesFolder, runnableId, runnable); } } + + return updatedRunnables; } /** * Updates locks for all inline scripts in a normal app, similar to updateRawAppRunnables - * but for the app.value structure instead of app.runnables + * but for the app.value structure instead of app.runnables. + * Returns a tuple of [updated app value, list of script names that were updated]. */ async function updateAppInlineScripts( workspace: Workspace, @@ -482,9 +512,11 @@ async function updateAppInlineScripts( remotePath: string, appFolder: string, rawDeps?: Record, - defaultTs: "bun" | "deno" = "bun" -): Promise { + defaultTs: "bun" | "deno" = "bun", + noStaleMessage?: boolean +): Promise<{ value: any; updatedScripts: string[] }> { const pathAssigner = newPathAssigner(defaultTs, { skipInlineScriptSuffix: getNonDottedPaths() }); + const updatedScripts: string[] = []; const processor: InlineScriptProcessor = async (inlineScript, context) => { const language = inlineScript.language as SupportedLanguage; @@ -514,13 +546,15 @@ async function updateAppInlineScripts( try { let lock: string | undefined; if (language !== "frontend") { - log.info( - colors.gray( - `Generating lock for inline script "${scriptName}" at ${context.path.join( - "." - )} (${language})` - ) - ); + if (!noStaleMessage) { + log.info( + colors.gray( + `Generating lock for inline script "${scriptName}" at ${context.path.join( + "." + )} (${language})` + ) + ); + } lock = await generateInlineScriptLock( workspace, @@ -549,11 +583,18 @@ async function updateAppInlineScripts( const inlineLockRef = lock && lock !== "" ? `!inline ${basePath}lock` : ""; - log.info( - colors.gray( - ` Written ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}` - ) - ); + if (!noStaleMessage) { + log.info( + colors.gray( + ` Written ${basePath}${ext}${lock ? ` and ${basePath}lock` : ""}` + ) + ); + } + + // Track that this script was updated (only for non-frontend scripts that needed locks) + if (language !== "frontend") { + updatedScripts.push(scriptName); + } return { ...inlineScript, @@ -573,7 +614,8 @@ async function updateAppInlineScripts( } }; - return await traverseAndProcessInlineScripts(appValue, processor); + const updatedValue = await traverseAndProcessInlineScripts(appValue, processor); + return { value: updatedValue, updatedScripts }; } /** @@ -767,7 +809,7 @@ export async function inferRunnableSchemaFromFile( } } -function getAppFolders(elems: Record, extension: string) { +export function getAppFolders(elems: Record, extension: string) { return Object.keys(elems) .filter((p) => p.endsWith(SEP + extension)) .map((p) => p.substring(0, p.length - (SEP + extension).length)); diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index eff742ee68..3b01385fb9 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -56,13 +56,20 @@ export async function pushFlow( } const localFlow = (await yamlParseFile(localPath + "flow.yaml")) as FlowFile; + const fileReader = async (path: string) => await readFile(localPath + path, "utf-8"); await replaceInlineScripts( localFlow.value.modules, - async (path: string) => await readFile(localPath + path, "utf-8"), + fileReader, log, localPath, SEP ); + if (localFlow.value.failure_module) { + await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, localPath, SEP); + } + if (localFlow.value.preprocessor_module) { + await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, localPath, SEP); + } if (flow) { if (isSuperset(localFlow, flow)) { @@ -252,13 +259,20 @@ async function preview( const localFlow = (await yamlParseFile(flowPath + "flow.yaml")) as FlowFile; // Replace inline scripts with their actual content + const fileReader = async (path: string) => await readFile(flowPath + path, "utf-8"); await replaceInlineScripts( localFlow.value.modules, - async (path: string) => await readFile(flowPath + path, "utf-8"), + fileReader, log, flowPath, SEP ); + if (localFlow.value.failure_module) { + await replaceInlineScripts([localFlow.value.failure_module], fileReader, log, flowPath, SEP); + } + if (localFlow.value.preprocessor_module) { + await replaceInlineScripts([localFlow.value.preprocessor_module], fileReader, log, flowPath, SEP); + } const input = opts.data ? await resolve(opts.data) : {}; @@ -294,12 +308,15 @@ async function preview( } } -async function generateLocks( +export async function generateLocks( opts: GlobalOptions & { yes?: boolean; } & SyncOptions, folder: string | undefined ) { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); const workspace = await resolveWorkspace(opts); await requireLogin(opts); opts = await mergeConfigWithConfigFile(opts); diff --git a/cli/src/commands/flow/flow_metadata.ts b/cli/src/commands/flow/flow_metadata.ts index cb2e5336e6..6a9040094f 100644 --- a/cli/src/commands/flow/flow_metadata.ts +++ b/cli/src/commands/flow/flow_metadata.ts @@ -19,6 +19,7 @@ import { } from "../../utils/metadata.ts"; import { ScriptLanguage } from "../../utils/script_common.ts"; import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { newPathAssigner } from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts"; import { generateHash, getHeaders, writeIfChanged } from "../../utils/utils.ts"; import { exts } from "../script/script.ts"; @@ -28,7 +29,10 @@ import { FlowFile } from "./flow.ts"; import { FlowValue } from "../../../gen/types.gen.ts"; import { replaceInlineScripts } from "../../../windmill-utils-internal/src/inline-scripts/replacer.ts"; import { workspaceDependenciesLanguages } from "../../utils/script_common.ts"; -import { extractNameFromFolder, getFolderSuffix } from "../../utils/resource_folders.ts"; +import { + extractNameFromFolder, + getNonDottedPaths, +} from "../../utils/resource_folders.ts"; const TOP_HASH = "__flow_hash"; async function generateFlowHash( @@ -50,6 +54,14 @@ async function generateFlowHash( } return { ...hashes, [TOP_HASH]: await generateHash(JSON.stringify(hashes)) }; } +/** + * Result of generating flow locks, including which scripts were updated + */ +export interface FlowLocksResult { + path: string; + updatedScripts: string[]; +} + export async function generateFlowLockInternal( folder: string, dryRun: boolean, @@ -59,7 +71,7 @@ export async function generateFlowLockInternal( }, justUpdateMetadataLock?: boolean, noStaleMessage?: boolean -): Promise { +): Promise { if (folder.endsWith(SEP)) { folder = folder.substring(0, folder.length - 1); } @@ -97,7 +109,7 @@ export async function generateFlowLockInternal( return remote_path; } - if (Object.keys(filteredDeps).length > 0) { + if (Object.keys(filteredDeps).length > 0 && !noStaleMessage) { log.info( (await blueColor())( `Found workspace dependencies (${workspaceDependenciesLanguages @@ -108,8 +120,9 @@ export async function generateFlowLockInternal( } + let changedScripts: string[] = []; + if (!justUpdateMetadataLock) { - const changedScripts = []; //find hashes that do not correspond to previous hashes for (const [path, hash] of Object.entries(hashes)) { if (path == TOP_HASH) { @@ -120,15 +133,24 @@ export async function generateFlowLockInternal( } } - log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + if (!noStaleMessage) { + log.info(`Recomputing locks of ${changedScripts.join(", ")} in ${folder}`); + } + const fileReader = async (path: string) => await readFile(folder + SEP + path, "utf-8"); await replaceInlineScripts( flowValue.value.modules, - async (path: string) => await readFile(folder + SEP + path, "utf-8"), + fileReader, log, folder + SEP!, SEP, changedScripts ); + if (flowValue.value.failure_module) { + await replaceInlineScripts([flowValue.value.failure_module], fileReader, log, folder + SEP!, SEP, changedScripts); + } + if (flowValue.value.preprocessor_module) { + await replaceInlineScripts([flowValue.value.preprocessor_module], fileReader, log, folder + SEP!, SEP, changedScripts); + } //removeChangedLocks flowValue.value = await updateFlow( @@ -138,12 +160,22 @@ export async function generateFlowLockInternal( filteredDeps ); + const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun", { + skipInlineScriptSuffix: getNonDottedPaths(), + }); const inlineScripts = extractInlineScriptsForFlows( flowValue.value.modules, {}, SEP, - opts.defaultTs + opts.defaultTs, + lockAssigner ); + if (flowValue.value.failure_module) { + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.failure_module], {}, SEP, opts.defaultTs, lockAssigner)); + } + if (flowValue.value.preprocessor_module) { + inlineScripts.push(...extractInlineScriptsForFlows([flowValue.value.preprocessor_module], {}, SEP, opts.defaultTs, lockAssigner)); + } inlineScripts.forEach((s) => { writeIfChanged(process.cwd() + SEP + folder + SEP + s.path, s.content); }); @@ -164,7 +196,16 @@ export async function generateFlowLockInternal( for (const [path, hash] of Object.entries(hashes)) { await updateMetadataGlobalLock(folder, hash, path); } - log.info(colors.green(`Flow ${remote_path} lockfiles updated`)); + if (!noStaleMessage) { + log.info(colors.green(`Flow ${remote_path} lockfiles updated`)); + } + + // Return the list of updated scripts (extract just the filename from the path) + const updatedScripts = changedScripts.map(p => { + const parts = p.split(SEP); + return parts[parts.length - 1].replace(/\.[^.]+$/, ""); // Remove extension + }); + return { path: remote_path, updatedScripts }; } /** @@ -176,7 +217,15 @@ async function filterWorkspaceDependenciesForFlow( rawWorkspaceDependencies: Record, folder: string ): Promise> { - const inlineScripts = extractInlineScriptsForFlows(structuredClone(flowValue.modules), {}, SEP, undefined); + const clonedValue = structuredClone(flowValue); + const depAssigner = newPathAssigner("bun"); + const inlineScripts = extractInlineScriptsForFlows(clonedValue.modules, {}, SEP, undefined, depAssigner); + if (clonedValue.failure_module) { + inlineScripts.push(...extractInlineScriptsForFlows([clonedValue.failure_module], {}, SEP, undefined, depAssigner)); + } + if (clonedValue.preprocessor_module) { + inlineScripts.push(...extractInlineScriptsForFlows([clonedValue.preprocessor_module], {}, SEP, undefined, depAssigner)); + } // Filter out lock files and map to common interface const scripts = inlineScripts diff --git a/cli/src/commands/generate-metadata/generate-metadata.ts b/cli/src/commands/generate-metadata/generate-metadata.ts new file mode 100644 index 0000000000..c523996cdc --- /dev/null +++ b/cli/src/commands/generate-metadata/generate-metadata.ts @@ -0,0 +1,345 @@ +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { colors } from "@cliffy/ansi/colors"; +import { sep as SEP } from "node:path"; +import { GlobalOptions } from "../../types.ts"; +import { SyncOptions, mergeConfigWithConfigFile } from "../../core/conf.ts"; +import { resolveWorkspace } from "../../core/context.ts"; +import { requireLogin } from "../../core/auth.ts"; +import * as log from "../../core/log.ts"; +import { + generateScriptMetadataInternal, + getRawWorkspaceDependencies, +} from "../../utils/metadata.ts"; +import { generateFlowLockInternal, FlowLocksResult } from "../flow/flow_metadata.ts"; +import { generateAppLocksInternal, getAppFolders, AppLocksResult } from "../app/app_metadata.ts"; +import { + elementsToMap, + FSFSElement, + ignoreF, +} from "../sync/sync.ts"; +import { exts } from "../script/script.ts"; +import { isFlowPath, isAppPath, isRawAppPath, isScriptModulePath, isModuleEntryPoint } from "../../utils/resource_folders.ts"; +import { listSyncCodebases } from "../../utils/codebase.ts"; + +interface StaleItem { + type: "script" | "flow" | "app"; + path: string; + folder: string; + isRawApp?: boolean; +} + +async function generateMetadata( + opts: GlobalOptions & { + yes?: boolean; + lockOnly?: boolean; + schemaOnly?: boolean; + dryRun?: boolean; + skipScripts?: boolean; + skipFlows?: boolean; + skipApps?: boolean; + } & SyncOptions, + folder?: string +) { + if (folder === "") { + folder = undefined; + } + + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + opts = await mergeConfigWithConfigFile(opts); + + const rawWorkspaceDependencies = await getRawWorkspaceDependencies(); + const codebases = await listSyncCodebases(opts); + const ignore = await ignoreF(opts); + + const staleItems: StaleItem[] = []; + + // --schema-only implies skipping flows and apps (they only have locks, no schemas) + const skipScripts = opts.skipScripts ?? false; + const skipFlows = opts.skipFlows ?? opts.schemaOnly ?? false; + const skipApps = opts.skipApps ?? opts.schemaOnly ?? false; + + const checking: string[] = []; + if (!skipScripts) checking.push("scripts"); + if (!skipFlows) checking.push("flows"); + if (!skipApps) checking.push("apps"); + + if (checking.length === 0) { + log.info(colors.yellow("Nothing to check (all types skipped)")); + return; + } + + log.info(colors.gray(`Checking ${checking.join(", ")}...`)); + + // === Collect stale scripts === + if (!skipScripts) { + // TODO: run elementsToMap only once but for all runnable types. + const scriptElems = await elementsToMap( + await FSFSElement(process.cwd(), codebases, false), + (p, isD) => { + return ( + (!isD && !exts.some((ext) => p.endsWith(ext))) || + ignore(p, isD) || + isFlowPath(p) || + isAppPath(p) || + isRawAppPath(p) || + (isScriptModulePath(p) && !isModuleEntryPoint(p)) + ); + }, + false, + {} + ); + + for (const e of Object.keys(scriptElems)) { + const candidate = await generateScriptMetadataInternal( + e, + workspace, + opts, + true, // dryRun + true, // noStaleMessage + rawWorkspaceDependencies, + codebases, + false + ); + if (candidate) { + staleItems.push({ type: "script", path: candidate, folder: e }); + } + } + } + + // === Collect stale flows === + if (!skipFlows) { + const flowElems = Object.keys( + await elementsToMap( + await FSFSElement(process.cwd(), [], true), + (p, isD) => { + return ( + ignore(p, isD) || + (!isD && + !p.endsWith(SEP + "flow.yaml") && + !p.endsWith(SEP + "flow.json")) + ); + }, + false, + {} + ) + ).map((x) => x.substring(0, x.lastIndexOf(SEP))); + + for (const folder of flowElems) { + const candidate = await generateFlowLockInternal( + folder, + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "flow", path: candidate, folder }); + } + } + } + + // === Collect stale apps === + if (!skipApps) { + const elems = await elementsToMap( + await FSFSElement(process.cwd(), [], true), + (p, isD) => { + return ( + ignore(p, isD) || + (!isD && + !p.endsWith(SEP + "raw_app.yaml") && + !p.endsWith(SEP + "app.yaml")) + ); + }, + false, + {} + ); + + const rawAppFolders = getAppFolders(elems, "raw_app.yaml"); + const appFolders = getAppFolders(elems, "app.yaml"); + + for (const appFolder of rawAppFolders) { + const candidate = await generateAppLocksInternal( + appFolder, + true, // rawApp + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: true }); + } + } + + for (const appFolder of appFolders) { + const candidate = await generateAppLocksInternal( + appFolder, + false, // rawApp + true, // dryRun + workspace, + opts, + false, + true // noStaleMessage + ); + if (candidate) { + staleItems.push({ type: "app", path: candidate, folder: appFolder, isRawApp: false }); + } + } + } + + // === Filter by folder if specified === + let filteredItems = staleItems; + if (folder) { + // Normalize to forward slashes (Windows users may use backslashes) + folder = folder.replaceAll("\\", "/"); + // Strip trailing slash to match deprecated flow/app handler behavior + if (folder.endsWith("/")) { + folder = folder.substring(0, folder.length - 1); + } + // Normalize item.folder for comparison (Windows file paths use backslashes) + filteredItems = staleItems.filter((item) => { + const normalizedFolder = item.folder.replaceAll("\\", "/"); + return normalizedFolder === folder || normalizedFolder.startsWith(folder + "/"); + }); + } + + // === Show stale items and confirm === + if (filteredItems.length === 0) { + log.info(colors.green("All metadata up-to-date")); + return; + } + + // Group items by type for display + const scripts = filteredItems.filter((i) => i.type === "script"); + const flows = filteredItems.filter((i) => i.type === "flow"); + const apps = filteredItems.filter((i) => i.type === "app"); + + log.info(""); + log.info(`Found ${filteredItems.length} item(s) with stale metadata:`); + + if (scripts.length > 0) { + log.info(colors.gray(` Scripts (${scripts.length}):`)); + for (const item of scripts) { + log.info(colors.yellow(` ${item.path}`)); + } + } + if (flows.length > 0) { + log.info(colors.gray(` Flows (${flows.length}):`)); + for (const item of flows) { + log.info(colors.yellow(` ${item.path}`)); + } + } + if (apps.length > 0) { + log.info(colors.gray(` Apps (${apps.length}):`)); + for (const item of apps) { + log.info(colors.yellow(` ${item.path}`)); + } + } + + if (opts.dryRun) { + return; + } + + log.info(""); + + if ( + !opts.yes && + !(await Confirm.prompt({ + message: "Update metadata?", + default: true, + })) + ) { + return; + } + + log.info(""); + + // === Process all stale items with progress counter === + const total = filteredItems.length; + const maxWidth = `[${total}/${total}]`.length; + let current = 0; + + const formatProgress = (n: number) => { + const bracket = `[${n}/${total}]`; + return colors.gray(bracket.padEnd(maxWidth, " ")); + }; + + // Process scripts + for (const item of scripts) { + current++; + log.info(`${formatProgress(current)} script ${colors.cyan(item.path)}`); + await generateScriptMetadataInternal( + item.folder, + workspace, + opts, + false, // dryRun + true, // noStaleMessage - we handle output + rawWorkspaceDependencies, + codebases, + false + ); + } + + // Process flows + for (const item of flows) { + current++; + const result = await generateFlowLockInternal( + item.folder, + false, // dryRun + workspace, + opts, + false, + true // noStaleMessage - we handle output + ) as FlowLocksResult | void; + const scriptsInfo = result?.updatedScripts?.length + ? `: ${colors.gray(result.updatedScripts.join(", "))}` + : ""; + log.info(`${formatProgress(current)} flow ${colors.cyan(item.path)}${scriptsInfo}`); + } + // Process apps + for (const item of apps) { + current++; + const result = await generateAppLocksInternal( + item.folder, + item.isRawApp!, // rawApp + false, // dryRun + workspace, + opts, + false, + true // noStaleMessage - we handle output + ) as AppLocksResult | void; + const scriptsInfo = result?.updatedScripts?.length + ? `: ${colors.gray(result.updatedScripts.join(", "))}` + : ""; + log.info(`${formatProgress(current)} app ${colors.cyan(item.path)}${scriptsInfo}`); + } + + log.info(""); + log.info(colors.green(`Done. Updated ${total} item(s).`)); +} + +const command = new Command() + .description("Generate metadata (locks, schemas) for all scripts, flows, and apps") + .arguments("[folder:string]") + .option("--yes", "Skip confirmation prompt") + .option("--dry-run", "Show what would be updated without making changes") + .option("--lock-only", "Re-generate only the lock files") + .option("--schema-only", "Re-generate only script schemas (skips flows and apps)") + .option("--skip-scripts", "Skip processing scripts") + .option("--skip-flows", "Skip processing flows") + .option("--skip-apps", "Skip processing apps") + .option( + "-i --includes ", + "Comma separated patterns to specify which files to include" + ) + .option( + "-e --excludes ", + "Comma separated patterns to specify which files to exclude" + ) + .action(generateMetadata as any); + +export default command; diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index 807f2fb31b..5883967b77 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -252,6 +252,16 @@ async function initAction(opts: InitOptions) { } } + // Read nonDottedPaths from config to specialize generated skills + let nonDottedPaths = true; // default for new inits + try { + const { readConfigFile } = await import("../../core/conf.ts"); + const config = await readConfigFile(); + nonDottedPaths = config.nonDottedPaths ?? true; + } catch { + // If config can't be read, use default + } + // Create guidance files (AGENTS.md, CLAUDE.md, and Claude skills) try { // Generate skills reference section for AGENTS.md @@ -290,6 +300,20 @@ async function initAction(opts: InitOptions) { let skillContent = SKILL_CONTENT[skill.name]; if (skillContent) { + // Replace placeholders with actual suffixes based on nonDottedPaths + if (nonDottedPaths) { + skillContent = skillContent + .replaceAll("{{FLOW_SUFFIX}}", "__flow") + .replaceAll("{{APP_SUFFIX}}", "__app") + .replaceAll("{{RAW_APP_SUFFIX}}", "__raw_app") + .replaceAll("{{INLINE_SCRIPT_NAMING}}", "Inline script files should NOT include `.inline_script.` in their names (e.g. use `a.ts`, not `a.inline_script.ts`)."); + } else { + skillContent = skillContent + .replaceAll("{{FLOW_SUFFIX}}", ".flow") + .replaceAll("{{APP_SUFFIX}}", ".app") + .replaceAll("{{RAW_APP_SUFFIX}}", ".raw_app") + .replaceAll("{{INLINE_SCRIPT_NAMING}}", "Inline script files use the `.inline_script.` naming convention (e.g. `a.inline_script.ts`)."); + } // Check if this skill has schemas that need to be appended const schemaMappings = SCHEMA_MAPPINGS[skill.name]; if (schemaMappings && schemaMappings.length > 0) { diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 9c6f094b41..68ff946cee 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -9,6 +9,7 @@ import { Confirm } from "@cliffy/prompt/confirm"; import { Table } from "@cliffy/table"; import * as log from "../../core/log.ts"; import { sep as SEP } from "node:path"; +import * as path from "node:path"; import { stringify as yamlStringify } from "yaml"; import { deepEqual } from "../../utils/utils.ts"; import * as wmill from "../../../gen/services.gen.ts"; @@ -51,13 +52,19 @@ import fs from "node:fs"; import { createTarBlob, type TarEntry } from "../../utils/tar.ts"; import { execSync } from "node:child_process"; -import { NewScript, Script } from "../../../gen/types.gen.ts"; +import { NewScript, Script, ScriptModule } from "../../../gen/types.gen.ts"; import { isRawAppBackendPath as isRawAppBackendPathInternal, isAppInlineScriptPath as isAppInlineScriptPathInternal, isFlowInlineScriptPath as isFlowInlineScriptPathInternal, isFlowPath, isAppPath, + isScriptModulePath, + buildModuleFolderPath, + getModuleFolderSuffix, + isModuleEntryPoint, + getScriptBasePathFromModulePath, + isRawAppPath, } from "../../utils/resource_folders.ts"; export interface ScriptFile { @@ -188,11 +195,17 @@ export async function handleScriptMetadata( codebases: SyncCodebase[], opts: GlobalOptions ): Promise { - if ( - path.endsWith(".script.json") || + // Flat layout: my_script.script.yaml + const isFlatMeta = path.endsWith(".script.json") || path.endsWith(".script.yaml") || - path.endsWith(".script.lock") - ) { + path.endsWith(".script.lock"); + // Folder layout: my_script__mod/script.yaml + const isFolderMeta = !isFlatMeta && isScriptModulePath(path) && ( + path.endsWith("/script.yaml") || + path.endsWith("/script.json") || + path.endsWith("/script.lock") + ); + if (isFlatMeta || isFolderMeta) { const contentPath = await findContentFile(path); return handleFile( contentPath, @@ -225,10 +238,13 @@ export async function handleFile( rawWorkspaceDependencies: Record, codebases: SyncCodebase[] ): Promise { + // Detect module entry point: e.g., my_script__mod/script.ts + const moduleEntryPoint = isModuleEntryPoint(path); if ( !isAppInlineScriptPath(path) && !isFlowInlineScriptPath(path) && !isRawAppBackendPath(path) && + (!isScriptModulePath(path) || moduleEntryPoint) && exts.some((exts) => path.endsWith(exts)) ) { if (alreadySynced.includes(path)) { @@ -237,9 +253,9 @@ export async function handleFile( log.debug(`Processing local script ${path}`); alreadySynced.push(path); - const remotePath = path - .substring(0, path.indexOf(".")) - .replaceAll(SEP, "/"); + const remotePath = moduleEntryPoint + ? getScriptBasePathFromModulePath(path)!.replaceAll(SEP, "/") + : path.substring(0, path.indexOf(".")).replaceAll(SEP, "/"); const language = inferContentTypeFromFilePath(path, opts?.defaultTs); @@ -391,6 +407,13 @@ export async function handleFile( typed.codebase = await codebase.getDigest(forceTar); } + // Scan for modules: folder layout (entry point inside __mod/) or flat layout + const scriptBasePath = moduleEntryPoint + ? getScriptBasePathFromModulePath(path)! + : path.substring(0, path.indexOf(".")); + const moduleFolderPath = scriptBasePath + getModuleFolderSuffix(); + const modules = await readModulesFromDisk(moduleFolderPath, opts?.defaultTs, moduleEntryPoint); + const requestBodyCommon: NewScript = { content, description: typed?.description ?? "", @@ -409,7 +432,6 @@ export async function handleFile( deployment_message: message, restart_unless_cancelled: typed?.restart_unless_cancelled, visible_to_runner_only: typed?.visible_to_runner_only, - no_main_func: typed?.no_main_func, has_preprocessor: typed?.has_preprocessor, priority: typed?.priority, concurrency_key: typed?.concurrency_key, @@ -419,6 +441,7 @@ export async function handleFile( timeout: typed?.timeout, on_behalf_of_email: typed?.on_behalf_of_email, envs: typed?.envs, + modules: modules, }; // console.log(requestBodyCommon.codebase); @@ -449,7 +472,6 @@ export async function handleFile( Boolean(remote.restart_unless_cancelled) && Boolean(typed.visible_to_runner_only) == Boolean(remote.visible_to_runner_only) && - Boolean(typed.no_main_func) == Boolean(remote.no_main_func) && Boolean(typed.has_preprocessor) == Boolean(remote.has_preprocessor) && typed.priority == Boolean(remote.priority) && @@ -460,7 +482,8 @@ export async function handleFile( typed.debounce_delay_s == remote["debounce_delay_s"] && typed.codebase == remote.codebase && typed.on_behalf_of_email == remote.on_behalf_of_email && - deepEqual(typed.envs, remote.envs)) + deepEqual(typed.envs, remote.envs) && + deepEqual(modules ?? null, remote.modules ?? null)) ) { log.info(colors.green(`Script ${remotePath} is up to date`)); return true; @@ -506,6 +529,135 @@ export async function handleFile( return false; } +/** + * Read module files from a __mod/ directory on disk. + * Returns the modules record for the API, or undefined if no module folder exists. + */ +export async function readModulesFromDisk( + moduleFolderPath: string, + defaultTs: "bun" | "deno" | undefined, + folderLayout: boolean = false, +): Promise | undefined> { + if (!fs.existsSync(moduleFolderPath) || !fs.statSync(moduleFolderPath).isDirectory()) { + return undefined; + } + + const modules: Record = {}; + + // In folder layout mode, skip the entry point files (script.*, script.yaml, etc.) + const isEntryPointFile = (name: string, isTopLevel: boolean) => { + if (!folderLayout || !isTopLevel) return false; + return ( + name.startsWith("script.") || + name === "script.lock" || + name === "script.yaml" || + name === "script.json" + ); + }; + + function readDir(dirPath: string, relPrefix: string) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name; + const isTopLevel = relPrefix === ""; + + if (entry.isDirectory()) { + readDir(fullPath, relPath); + } else if (entry.isFile() && !entry.name.endsWith(".lock") && !isEntryPointFile(entry.name, isTopLevel)) { + // Skip lock files — they're handled as the `lock` field on ScriptModule + if (exts.some((ext) => entry.name.endsWith(ext))) { + const content = fs.readFileSync(fullPath, "utf-8"); + const language = inferContentTypeFromFilePath(entry.name, defaultTs); + + // Check for an accompanying lock file (helper.lock) + const baseName = entry.name.replace(/\.[^.]+$/, ''); + const lockPath = path.join(dirPath, baseName + ".lock"); + let lock: string | undefined; + if (fs.existsSync(lockPath)) { + lock = fs.readFileSync(lockPath, "utf-8"); + } + + modules[relPath] = { + content, + language: language as ScriptModule["language"], + lock: lock ?? undefined, + }; + } + } + } + } + + readDir(moduleFolderPath, ""); + + if (Object.keys(modules).length === 0) { + return undefined; + } + + log.debug(`Found ${Object.keys(modules).length} module(s) in ${moduleFolderPath}`); + return modules; +} + +/** + * Write module files to a __mod/ directory on disk during pull. + */ +export async function writeModulesToDisk( + moduleFolderPath: string, + modules: Record, + defaultTs: "bun" | "deno" | undefined +): Promise { + // Ensure the module folder exists + fs.mkdirSync(moduleFolderPath, { recursive: true }); + + // Clean up stale module files that are no longer in the modules map + const expectedFiles = new Set(); + for (const [relPath, mod] of Object.entries(modules)) { + expectedFiles.add(relPath); + if (mod.lock) { + expectedFiles.add(relPath.replace(/\.[^.]+$/, '') + ".lock"); + } + } + + function cleanDir(dirPath: string, relPrefix: string) { + if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) return; + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name; + if (entry.isDirectory()) { + cleanDir(path.join(dirPath, entry.name), relPath); + // Remove empty directories after cleaning + try { + const remaining = fs.readdirSync(path.join(dirPath, entry.name)); + if (remaining.length === 0) { + fs.rmdirSync(path.join(dirPath, entry.name)); + } + } catch {} + } else if (!expectedFiles.has(relPath)) { + fs.unlinkSync(path.join(dirPath, entry.name)); + } + } + } + cleanDir(moduleFolderPath, ""); + + for (const [relPath, mod] of Object.entries(modules)) { + const fullPath = path.join(moduleFolderPath, relPath); + const dir = path.dirname(fullPath); + fs.mkdirSync(dir, { recursive: true }); + + // Write the module content + fs.writeFileSync(fullPath, mod.content, "utf-8"); + + // Write the lock file if present + if (mod.lock) { + const baseName = relPath.replace(/\.[^.]+$/, ''); + const lockPath = path.join(moduleFolderPath, baseName + ".lock"); + const lockDir = path.dirname(lockPath); + fs.mkdirSync(lockDir, { recursive: true }); + fs.writeFileSync(lockPath, mod.lock, "utf-8"); + } + } +} + async function createScript( bundleContent: string | Blob | undefined, workspaceId: string, @@ -559,7 +711,12 @@ async function createScript( } export async function findContentFile(filePath: string) { - const candidates = filePath.endsWith("script.json") + // Folder layout: __mod/script.yaml -> __mod/script.ts + const isModuleFolderMeta = + filePath.endsWith("/script.yaml") || filePath.endsWith("/script.json") || filePath.endsWith("/script.lock"); + const candidates = isModuleFolderMeta + ? exts.map((x) => filePath.replace(/\/script\.(yaml|json|lock)$/, "/script" + x)) + : filePath.endsWith("script.json") ? exts.map((x) => filePath.replace(".script.json", x)) : filePath.endsWith("script.lock") ? exts.map((x) => filePath.replace(".script.lock", x)) @@ -978,7 +1135,7 @@ export type GlobalDeps = Map< Record >; -async function generateMetadata( +export async function generateMetadata( opts: GlobalOptions & { lockOnly?: boolean; schemaOnly?: boolean; @@ -986,6 +1143,9 @@ async function generateMetadata( } & SyncOptions, scriptPath: string | undefined ) { + log.warn( + colors.yellow('This command is deprecated. Use "wmill generate-metadata" instead.') + ); log.info( "This command only works for workspace scripts, for flows inline scripts use `wmill flow generate-locks`" ); @@ -1024,7 +1184,10 @@ async function generateMetadata( (!isD && !exts.some((ext) => p.endsWith(ext))) || ignore(p, isD) || isFlowPath(p) || - isAppPath(p) + isAppPath(p) || + isRawAppPath(p) || + // Skip module helper files; only entry points (script.{ext}) are processed + (isScriptModulePath(p) && !isModuleEntryPoint(p)) ); }, false, @@ -1113,6 +1276,13 @@ async function preview( const content = await readFile(filePath, "utf-8"); const input = opts.data ? await resolve(opts.data) : {}; + // Read modules from __mod/ folder if present + const isFolderLayout = isModuleEntryPoint(filePath); + const moduleFolderPath = isFolderLayout + ? path.dirname(filePath) + : filePath.substring(0, filePath.indexOf(".")) + getModuleFolderSuffix(); + const modules = await readModulesFromDisk(moduleFolderPath, opts?.defaultTs, isFolderLayout); + // Check if this is a codebase script const codebase = language == "bun" ? findCodebase(filePath, codebases) : undefined; @@ -1272,6 +1442,7 @@ async function preview( path: filePath.substring(0, filePath.indexOf(".")).replaceAll(SEP, "/"), args: input, language: language as any, + modules: modules ?? undefined, }, }); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 7d48a14848..5de149cad3 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -31,6 +31,7 @@ import { findResourceFile, handleScriptMetadata, removeExtensionToPath, + filePathExtensionFromContentType, } from "../script/script.ts"; import { handleFile } from "../script/script.ts"; @@ -68,7 +69,7 @@ import { readLockfile, workspaceDependenciesPathToLanguageAndFilename, } from "../../utils/metadata.ts"; -import { OpenFlow, NativeServiceName } from "../../../gen/types.gen.ts"; +import { OpenFlow, NativeServiceName, ScriptModule } from "../../../gen/types.gen.ts"; import { pushResource } from "../resource/resource.ts"; import { newPathAssigner, @@ -97,6 +98,10 @@ import { getFolderSuffix, getFolderSuffixWithSep, getNonDottedPaths, + isScriptModulePath, + getModuleFolderSuffix, + isModuleEntryPoint, + getScriptBasePathFromModulePath, } from "../../utils/resource_folders.ts"; // Merge CLI options with effective settings, preserving CLI flags as overrides @@ -534,6 +539,29 @@ function ZipFSElement( resourceTypeToIsFileset: Record, ignoreCodebaseChanges: boolean, ): DynFSElement { + // Pre-scan: find zip base paths of scripts that have modules. + // These scripts use the folder layout: {basePath}__mod/script.{ext} + let _moduleScriptPaths: Set | null = null; + async function getModuleScriptPaths(): Promise> { + if (_moduleScriptPaths === null) { + _moduleScriptPaths = new Set(); + for (const filename in zip.files) { + if (filename.endsWith(".script.json") && !zip.files[filename].dir) { + try { + const content = await zip.files[filename].async("text"); + const parsed = JSON.parse(content); + if (parsed.modules && Object.keys(parsed.modules).length > 0) { + _moduleScriptPaths.add( + filename.slice(0, -".script.json".length) + ); + } + } catch {} + } + } + } + return _moduleScriptPaths; + } + async function _internal_file( p: string, f: JSZip.JSZipObject, @@ -575,7 +603,22 @@ function ZipFSElement( } } - const finalPath = transformPath(); + let finalPath = transformPath(); + + // Redirect content files for scripts with modules into __mod/ folder + if (kind == "other" && exts.some((ext) => p.endsWith(ext))) { + const normalizedP = p.replace(/^\.[\\/]/, ""); + const moduleScripts = await getModuleScriptPaths(); + for (const basePath of moduleScripts) { + if (normalizedP.startsWith(basePath + ".")) { + const ext = normalizedP.slice(basePath.length); // e.g., ".ts", ".py" + const dir = path.dirname(finalPath); + const base = path.basename(basePath); + finalPath = path.join(dir, base + getModuleFolderSuffix(), "script" + ext); + break; + } + } + } const r = [ { @@ -592,14 +635,35 @@ function ZipFSElement( } let inlineScripts; try { + const assigner = newPathAssigner(defaultTs, { skipInlineScriptSuffix: getNonDottedPaths() }); inlineScripts = extractInlineScriptsForFlows( flow.value.modules as any, {}, SEP, defaultTs, - undefined, // pathAssigner - let it create one + assigner, { skipInlineScriptSuffix: getNonDottedPaths() }, ); + if (flow.value.failure_module) { + inlineScripts.push(...extractInlineScriptsForFlows( + [flow.value.failure_module], + {}, + SEP, + defaultTs, + assigner, + { skipInlineScriptSuffix: getNonDottedPaths() }, + )); + } + if (flow.value.preprocessor_module) { + inlineScripts.push(...extractInlineScriptsForFlows( + [flow.value.preprocessor_module], + {}, + SEP, + defaultTs, + assigner, + { skipInlineScriptSuffix: getNonDottedPaths() }, + )); + } } catch (error) { log.error( `Failed to extract inline scripts for flow at path: ${p}`, @@ -872,15 +936,23 @@ function ZipFSElement( log.error(`Failed to parse script.yaml at path: ${p}`); throw error; } + const hasModules = parsed["modules"] && Object.keys(parsed["modules"]).length > 0; if ( parsed["lock"] && parsed["lock"] != "" && parsed["codebase"] == undefined ) { - parsed["lock"] = - "!inline " + - removeSuffix(p.replaceAll(SEP, "/"), ".json") + - ".lock"; + if (hasModules) { + // Lock lives inside __mod/ folder as script.lock + const scriptBase = removeSuffix(removeSuffix(p.replaceAll(SEP, "/"), ".json"), ".script"); + parsed["lock"] = + "!inline " + scriptBase + getModuleFolderSuffix() + "/script.lock"; + } else { + parsed["lock"] = + "!inline " + + removeSuffix(p.replaceAll(SEP, "/"), ".json") + + ".lock"; + } } else if (parsed["lock"] == "") { parsed["lock"] = ""; } else { @@ -889,6 +961,8 @@ function ZipFSElement( if (ignoreCodebaseChanges && parsed["codebase"]) { parsed["codebase"] = undefined; } + // Modules are stored as files in __mod/ folder, not in metadata + delete parsed["modules"]; return useYaml ? yamlStringify(parsed, yamlOptions) : JSON.stringify(parsed, null, 2); @@ -948,16 +1022,71 @@ function ZipFSElement( throw error; } const lock = parsed["lock"]; + const scriptModules: Record | undefined = parsed["modules"]; + const hasModules = scriptModules && Object.keys(scriptModules).length > 0; + + // Compute base path and module folder + const metaExt = useYaml ? ".yaml" : ".json"; + const scriptBasePath = removeSuffix( + removeSuffix(finalPath, metaExt), + ".script" + ); + const moduleFolderPath = scriptBasePath + getModuleFolderSuffix(); + + if (hasModules) { + // Redirect metadata into __mod/script.yaml + r[0].path = path.join(moduleFolderPath, "script" + metaExt); + } + if (lock && lock != "") { r.push({ isDirectory: false, - path: removeSuffix(finalPath, ".json") + ".lock", + path: hasModules + ? path.join(moduleFolderPath, "script.lock") + : removeSuffix(finalPath, metaExt) + ".lock", async *getChildren() {}, async getContentText() { return lock; }, }); } + + // Extract script modules into __mod/ folder + if (hasModules) { + r.push({ + isDirectory: true, + path: moduleFolderPath, + async *getChildren() { + for (const [relPath, mod] of Object.entries(scriptModules!)) { + // Yield the module content file + yield { + isDirectory: false, + path: path.join(moduleFolderPath, relPath), + async *getChildren() {}, + async getContentText() { + return mod.content; + }, + }; + + // Yield the module lock file if present + if (mod.lock) { + const baseName = relPath.replace(/\.[^.]+$/, ''); + yield { + isDirectory: false, + path: path.join(moduleFolderPath, baseName + ".lock"), + async *getChildren() {}, + async getContentText() { + return mod.lock!; + }, + }; + } + } + }, + async getContentText() { + throw new Error("Cannot get content of directory"); + }, + }); + } } if (kind == "resource") { const content = await f.async("text"); @@ -1133,6 +1262,12 @@ export async function elementsToMap( continue; } const path = entry.path; + // Include module files in the map so they're compared for changes, + // but they're pushed as part of their parent script via handleFile + if (isScriptModulePath(path)) { + map[path] = await entry.getContentText(); + continue; + } if ( !isFileResource(path) && !isFilesetResource(path) && @@ -1579,6 +1714,11 @@ const isNotWmillFile = (p: string, isDirectory: boolean) => { ); } + // Files inside __mod/ folders are script module files — always valid wmill files + if (isScriptModulePath(p)) { + return false; + } + try { const typ = getTypeStrFromPath(p); if ( @@ -1723,6 +1863,37 @@ async function addToChangedIfNotExists(p: string, tracker: ChangeTracker) { if (!tracker.rawApps.includes(folder)) { tracker.rawApps.push(folder); } + } else if (isScriptModulePath(p)) { + if (isModuleEntryPoint(p)) { + // Entry point (e.g. __mod/script.ts) IS the parent script content file + if (!tracker.scripts.includes(p)) { + tracker.scripts.push(p); + } + } else { + // Module file changed — find the parent script content file + const moduleSuffix = getModuleFolderSuffix() + "/"; + const idx = p.indexOf(moduleSuffix); + if (idx !== -1) { + const scriptBasePath = p.substring(0, idx); + // Try folder layout first: __mod/script.{ext} + try { + const contentPath = await findContentFile(scriptBasePath + getModuleFolderSuffix() + "/script.yaml"); + if (contentPath && !tracker.scripts.includes(contentPath)) { + tracker.scripts.push(contentPath); + } + } catch { + // Fall back to flat layout: scriptBasePath.script.yaml + try { + const contentPath = await findContentFile(scriptBasePath + ".script.yaml"); + if (contentPath && !tracker.scripts.includes(contentPath)) { + tracker.scripts.push(contentPath); + } + } catch { + // ignore — content file not found + } + } + } + } } else { if (!tracker.scripts.includes(p)) { tracker.scripts.push(p); @@ -1755,6 +1926,61 @@ async function buildTracker(changes: Change[]) { return tracker; } +/** + * When a module file changes, find and push the parent script. + * The parent script's handleFile will read the __mod/ folder and include all modules. + */ +async function pushParentScriptForModule( + modulePath: string, + workspace: Workspace, + alreadySynced: string[], + message: string | undefined, + opts: (GlobalOptions & { defaultTs?: "bun" | "deno" } & Skips) | undefined, + rawWorkspaceDependencies: Record, + codebases: SyncCodebase[], +): Promise { + const moduleSuffix = getModuleFolderSuffix() + "/"; + const idx = modulePath.indexOf(moduleSuffix); + if (idx === -1) return; + const scriptBasePath = modulePath.substring(0, idx); + const moduleFolderPath = scriptBasePath + getModuleFolderSuffix(); + + // Try folder layout first: look for script.{ext} inside __mod/ + try { + const entryPoint = await findContentFile(moduleFolderPath + "/script.yaml"); + if (entryPoint) { + await handleFile( + entryPoint, + workspace, + alreadySynced, + message, + opts, + rawWorkspaceDependencies, + codebases, + ); + return; + } + } catch {} + + // Fall back to flat layout: look for content file alongside __mod/ + try { + const contentPath = await findContentFile(scriptBasePath + ".script.yaml"); + if (contentPath) { + await handleFile( + contentPath, + workspace, + alreadySynced, + message, + opts, + rawWorkspaceDependencies, + codebases, + ); + } + } catch { + log.debug(`Could not find parent script for module: ${modulePath}`); + } +} + export async function pull( opts: GlobalOptions & SyncOptions & { repository?: string; promotion?: string; branch?: string }, @@ -2683,6 +2909,21 @@ export async function push( await writeFile(stateTarget, change.after, "utf-8"); } continue; + } else if (isScriptModulePath(change.path)) { + // Module file changed — push the parent script + await pushParentScriptForModule( + change.path, + workspace, + alreadySynced, + opts.message, + opts, + rawWorkspaceDependencies, + codebases, + ); + if (stateTarget) { + await writeFile(stateTarget, change.after, "utf-8"); + } + continue; } if (stateTarget) { await mkdir(path.dirname(stateTarget), { recursive: true }); @@ -2807,6 +3048,17 @@ export async function push( ) ) { continue; + } else if (isScriptModulePath(change.path)) { + await pushParentScriptForModule( + change.path, + workspace, + alreadySynced, + opts.message, + opts, + rawWorkspaceDependencies, + codebases, + ); + continue; } if (stateTarget) { await mkdir(path.dirname(stateTarget), { recursive: true }); @@ -2848,6 +3100,19 @@ export async function push( if (change.path.endsWith(".lock")) { continue; } + if (isScriptModulePath(change.path)) { + // Module file deleted — push the parent script (which will now have fewer modules) + await pushParentScriptForModule( + change.path, + workspace, + alreadySynced, + opts.message, + opts, + rawWorkspaceDependencies, + codebases, + ); + continue; + } const typ = getTypeStrFromPath(change.path); if (typ == "script") { diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 0f7acce4a9..21ba5463ad 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -53,6 +53,7 @@ export interface SimplifiedSettings { mute_critical_alerts?: boolean; color?: string; operator_settings?: any; + datatable?: any; slack_team_id?: string; slack_name?: string; slack_command_script?: string; @@ -100,6 +101,7 @@ export function migrateToGroupedFormat(settings: any): SimplifiedSettings { if (settings.mute_critical_alerts !== undefined) result.mute_critical_alerts = settings.mute_critical_alerts; if (settings.color !== undefined) result.color = settings.color; if (settings.operator_settings !== undefined) result.operator_settings = settings.operator_settings; + if (settings.datatable !== undefined) result.datatable = settings.datatable; if (settings.slack_team_id !== undefined) result.slack_team_id = settings.slack_team_id; if (settings.slack_name !== undefined) result.slack_name = settings.slack_name; if (settings.slack_command_script !== undefined) result.slack_command_script = settings.slack_command_script; @@ -192,6 +194,7 @@ export async function pushWorkspaceSettings( mute_critical_alerts: remoteSettings.mute_critical_alerts, color: remoteSettings.color, operator_settings: remoteSettings.operator_settings, + datatable: remoteSettings.datatable, slack_team_id: remoteSettings.slack_team_id, slack_name: remoteSettings.slack_name, slack_command_script: remoteSettings.slack_command_script, @@ -382,6 +385,14 @@ export async function pushWorkspaceSettings( }); } + if (!deepEqual(localSettings.datatable, settings.datatable)) { + log.debug(`Updating datatable config...`); + await wmill.editDataTableConfig({ + workspace, + requestBody: { settings: localSettings.datatable ?? { datatables: {} } }, + }); + } + if (localSettings.slack_command_script != settings.slack_command_script) { log.debug(`Updating slack command script...`); await wmill.editSlackCommand({ diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index 34e288c7d7..e97d9babe2 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -7,25 +7,25 @@ export interface SkillMetadata { } export const SKILLS: SkillMetadata[] = [ - { name: "write-script-go", description: "MUST use when writing Go scripts.", languageKey: "go" }, - { name: "write-script-java", description: "MUST use when writing Java scripts.", languageKey: "java" }, - { name: "write-script-graphql", description: "MUST use when writing GraphQL queries.", languageKey: "graphql" }, - { name: "write-script-rust", description: "MUST use when writing Rust scripts.", languageKey: "rust" }, - { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts.", languageKey: "bunnative" }, - { name: "write-script-postgresql", description: "MUST use when writing PostgreSQL queries.", languageKey: "postgresql" }, - { name: "write-script-php", description: "MUST use when writing PHP scripts.", languageKey: "php" }, + { name: "write-script-bash", description: "MUST use when writing Bash scripts.", languageKey: "bash" }, { name: "write-script-bigquery", description: "MUST use when writing BigQuery queries.", languageKey: "bigquery" }, { name: "write-script-bun", description: "MUST use when writing Bun/TypeScript scripts.", languageKey: "bun" }, + { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts.", languageKey: "bunnative" }, { name: "write-script-csharp", description: "MUST use when writing C# scripts.", languageKey: "csharp" }, - { name: "write-script-mssql", description: "MUST use when writing MS SQL Server queries.", languageKey: "mssql" }, { name: "write-script-deno", description: "MUST use when writing Deno/TypeScript scripts.", languageKey: "deno" }, - { name: "write-script-mysql", description: "MUST use when writing MySQL queries.", languageKey: "mysql" }, - { name: "write-script-powershell", description: "MUST use when writing PowerShell scripts.", languageKey: "powershell" }, - { name: "write-script-snowflake", description: "MUST use when writing Snowflake queries.", languageKey: "snowflake" }, - { name: "write-script-python3", description: "MUST use when writing Python scripts.", languageKey: "python3" }, { name: "write-script-duckdb", description: "MUST use when writing DuckDB queries.", languageKey: "duckdb" }, - { name: "write-script-bash", description: "MUST use when writing Bash scripts.", languageKey: "bash" }, + { name: "write-script-go", description: "MUST use when writing Go scripts.", languageKey: "go" }, + { name: "write-script-graphql", description: "MUST use when writing GraphQL queries.", languageKey: "graphql" }, + { name: "write-script-java", description: "MUST use when writing Java scripts.", languageKey: "java" }, + { name: "write-script-mssql", description: "MUST use when writing MS SQL Server queries.", languageKey: "mssql" }, + { name: "write-script-mysql", description: "MUST use when writing MySQL queries.", languageKey: "mysql" }, { name: "write-script-nativets", description: "MUST use when writing Native TypeScript scripts.", languageKey: "nativets" }, + { name: "write-script-php", description: "MUST use when writing PHP scripts.", languageKey: "php" }, + { name: "write-script-postgresql", description: "MUST use when writing PostgreSQL queries.", languageKey: "postgresql" }, + { name: "write-script-powershell", description: "MUST use when writing PowerShell scripts.", languageKey: "powershell" }, + { name: "write-script-python3", description: "MUST use when writing Python scripts.", languageKey: "python3" }, + { name: "write-script-rust", description: "MUST use when writing Rust scripts.", languageKey: "rust" }, + { name: "write-script-snowflake", description: "MUST use when writing Snowflake queries.", languageKey: "snowflake" }, { name: "write-flow", description: "MUST use when creating flows." }, { name: "raw-app", description: "MUST use when creating raw apps." }, { name: "triggers", description: "MUST use when configuring triggers." }, @@ -36,1025 +36,70 @@ export const SKILLS: SkillMetadata[] = [ // Skill content for each skill (loaded inline for bundling) export const SKILL_CONTENT: Record = { - "write-script-go": `--- -name: write-script-go -description: MUST use when writing Go scripts. + "write-script-bash": `--- +name: write-script-bash +description: MUST use when writing Bash scripts. --- ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. -# Go +# Bash ## Structure -The file package must be \`inner\` and export a function called \`main\`: +Do not include \`#!/bin/bash\`. Arguments are obtained as positional parameters: -\`\`\`go -package inner +\`\`\`bash +# Get arguments +var1="$1" +var2="$2" -func main(param1 string, param2 int) (map[string]interface{}, error) { - return map[string]interface{}{ - "result": param1, - "count": param2, - }, nil -} +echo "Processing $var1 and $var2" + +# Return JSON by echoing to stdout +echo "{\\"result\\": \\"$var1\\", \\"count\\": $var2}" \`\`\` **Important:** -- Package must be \`inner\` -- Return type must be \`({return_type}, error)\` -- Function name is \`main\` (lowercase) +- Do not include shebang (\`#!/bin/bash\`) +- Arguments are always strings +- Access with \`$1\`, \`$2\`, etc. -## Return Types +## Output -The return type can be any Go type that can be serialized to JSON: +The script output is captured as the result. For structured data, output valid JSON: -\`\`\`go -package inner +\`\`\`bash +name="$1" +count="$2" -type Result struct { - Name string \`json:"name"\` - Count int \`json:"count"\` -} - -func main(name string, count int) (Result, error) { - return Result{ - Name: name, - Count: count, - }, nil +# Output JSON result +cat << EOF +{ + "name": "$name", + "count": $count, + "timestamp": "$(date -Iseconds)" } +EOF \`\`\` -## Error Handling +## Environment Variables -Return errors as the second return value: +Environment variables set in Windmill are available: -\`\`\`go -package inner - -import "errors" - -func main(value int) (string, error) { - if value < 0 { - return "", errors.New("value must be positive") - } - return "success", nil -} +\`\`\`bash +# Access environment variable +echo "Workspace: $WM_WORKSPACE" +echo "Job ID: $WM_JOB_ID" \`\`\` -`, - "write-script-java": `--- -name: write-script-java -description: MUST use when writing Java scripts. ---- - -## CLI Commands - -Place scripts in a folder. After writing, run: -- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files -- \`wmill sync push\` - Deploy to Windmill - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# Java - -The script must contain a Main public class with a \`public static main()\` method: - -\`\`\`java -public class Main { - public static Object main(String name, int count) { - java.util.Map result = new java.util.HashMap<>(); - result.put("name", name); - result.put("count", count); - return result; - } -} -\`\`\` - -**Important:** -- Class must be named \`Main\` -- Method must be \`public static Object main(...)\` -- Return type is \`Object\` or \`void\` - -## Maven Dependencies - -Add dependencies using comments at the top: - -\`\`\`java -//requirements: -//com.google.code.gson:gson:2.10.1 -//org.apache.httpcomponents:httpclient:4.5.14 - -import com.google.gson.Gson; - -public class Main { - public static Object main(String input) { - Gson gson = new Gson(); - return gson.fromJson(input, Object.class); - } -} -\`\`\` -`, - "write-script-graphql": `--- -name: write-script-graphql -description: MUST use when writing GraphQL queries. ---- - -## CLI Commands - -Place scripts in a folder. After writing, run: -- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files -- \`wmill sync push\` - Deploy to Windmill - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# GraphQL - -## Structure - -Write GraphQL queries or mutations. Arguments can be added as query parameters: - -\`\`\`graphql -query GetUser($id: ID!) { - user(id: $id) { - id - name - email - } -} -\`\`\` - -## Variables - -Variables are passed as script arguments and automatically bound to the query: - -\`\`\`graphql -query SearchProducts($query: String!, $limit: Int = 10) { - products(search: $query, first: $limit) { - edges { - node { - id - name - price - } - } - } -} -\`\`\` - -## Mutations - -\`\`\`graphql -mutation CreateUser($input: CreateUserInput!) { - createUser(input: $input) { - id - name - createdAt - } -} -\`\`\` -`, - "write-script-rust": `--- -name: write-script-rust -description: MUST use when writing Rust scripts. ---- - -## CLI Commands - -Place scripts in a folder. After writing, run: -- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files -- \`wmill sync push\` - Deploy to Windmill - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# Rust - -## Structure - -The script must contain a function called \`main\` with proper return type: - -\`\`\`rust -use anyhow::anyhow; -use serde::Serialize; - -#[derive(Serialize, Debug)] -struct ReturnType { - result: String, - count: i32, -} - -fn main(param1: String, param2: i32) -> anyhow::Result { - Ok(ReturnType { - result: param1, - count: param2, - }) -} -\`\`\` - -**Important:** -- Arguments should be owned types -- Return type must be serializable (\`#[derive(Serialize)]\`) -- Return type is \`anyhow::Result\` - -## Dependencies - -Packages must be specified with a partial cargo.toml at the beginning of the script: - -\`\`\`rust -//! \`\`\`cargo -//! [dependencies] -//! anyhow = "1.0.86" -//! reqwest = { version = "0.11", features = ["json"] } -//! tokio = { version = "1", features = ["full"] } -//! \`\`\` - -use anyhow::anyhow; -// ... rest of the code -\`\`\` - -**Note:** Serde is already included, no need to add it again. - -## Async Functions - -If you need to handle async functions (e.g., using tokio), keep the main function sync and create the runtime inside: - -\`\`\`rust -//! \`\`\`cargo -//! [dependencies] -//! anyhow = "1.0.86" -//! tokio = { version = "1", features = ["full"] } -//! reqwest = { version = "0.11", features = ["json"] } -//! \`\`\` - -use anyhow::anyhow; -use serde::Serialize; - -#[derive(Serialize, Debug)] -struct Response { - data: String, -} - -fn main(url: String) -> anyhow::Result { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { - let resp = reqwest::get(&url).await?.text().await?; - Ok(Response { data: resp }) - }) -} -\`\`\` -`, - "write-script-bunnative": `--- -name: write-script-bunnative -description: MUST use when writing Bun Native scripts. ---- - -## CLI Commands - -Place scripts in a folder. After writing, run: -- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files -- \`wmill sync push\` - Deploy to Windmill - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# TypeScript (Bun Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called \`main\`: - -\`\`\`typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -\`\`\` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the \`RT\` namespace for resource types: - -\`\`\`typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -\`\`\` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. - -## Imports - -**No imports allowed.** Use the globally available \`fetch\` function: - -\`\`\`typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -\`\`\` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: - -\`\`\`typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id, - }; -} -\`\`\` - -## S3 Object Operations - -Windmill provides built-in support for S3-compatible storage operations. - -### S3Object Type - -The S3Object type represents a file in S3 storage: - -\`\`\`typescript -type S3Object = { - s3: string; // Path within the bucket -}; -\`\`\` - -## TypeScript Operations - -\`\`\`typescript -import * as wmill from "windmill-client"; - -// Load file content from S3 -const content: Uint8Array = await wmill.loadS3File(s3object); - -// Load file as stream -const blob: Blob = await wmill.loadS3FileStream(s3object); - -// Write file to S3 -const result: S3Object = await wmill.writeS3File( - s3object, // Target path (or undefined to auto-generate) - fileContent, // string or Blob - s3ResourcePath // Optional: specific S3 resource to use -); -\`\`\` - - -# TypeScript SDK (windmill-client) - -Import: import * as wmill from 'windmill-client' - -/** - * Create a SQL template function for PostgreSQL/datatable queries - * @param name - Database/datatable name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.datatable() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age}::int - * \`.fetch() - */ -datatable(name: string = "main"): DatatableSqlTemplateFunction - -/** - * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.ducklake() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age} - * \`.fetch() - */ -ducklake(name: string = "main"): SqlTemplateFunction - -/** - * Initialize the Windmill client with authentication token and base URL - * @param token - Authentication token (defaults to WM_TOKEN env variable) - * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable) - */ -setClient(token?: string, baseUrl?: string): void - -/** - * Create a client configuration from env variables - * @returns client configuration - */ -getWorkspace(): string - -/** - * Get a resource value by path - * @param path path of the resource, default to internal state path - * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error - * @returns resource value - */ -async getResource(path?: string, undefinedIfEmpty?: boolean): Promise - -/** - * Get the true root job id - * @param jobId job id to get the root job id from (default to current job) - * @returns root job id - */ -async getRootJobId(jobId?: string): Promise - -/** - * @deprecated Use runScriptByPath or runScriptByHash instead - */ -async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its path and wait for the result - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its hash and wait for the result - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Append a text to the result stream - * @param text text to append to the result stream - */ -appendToResultStream(text: string): void - -/** - * Stream to the result stream - * @param stream stream to stream to the result stream - */ -async streamResult(stream: AsyncIterable): Promise - -/** - * Run a flow synchronously by its path and wait for the result - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param verbose - Enable verbose logging - * @returns Flow execution result - */ -async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Wait for a job to complete and return its result - * @param jobId - ID of the job to wait for - * @param verbose - Enable verbose logging - * @returns Job result when completed - */ -async waitJob(jobId: string, verbose: boolean = false): Promise - -/** - * Get the result of a completed job - * @param jobId - ID of the completed job - * @returns Job result - */ -async getResult(jobId: string): Promise - -/** - * Get the result of a job if completed, or its current status - * @param jobId - ID of the job - * @returns Object with started, completed, success, and result properties - */ -async getResultMaybe(jobId: string): Promise - -/** - * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead - */ -async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its path - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its hash - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a flow asynchronously by its path - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) - * @returns Job ID of the created job - */ -async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise - -/** - * Resolve a resource value in case the default value was picked because the input payload was undefined - * @param obj resource value or path of the resource under the format \`$res:path\` - * @returns resource value - */ -async resolveDefaultResource(obj: any): Promise - -/** - * Get the state file path from environment variables - * @returns State path string - */ -getStatePath(): string - -/** - * Set a resource value by path - * @param path path of the resource to set, default to state path - * @param value new value of the resource to set - * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type - */ -async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise - -/** - * Set the state - * @param state state to set - * @deprecated use setState instead - */ -async setInternalState(state: any): Promise - -/** - * Set the state - * @param state state to set - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async setState(state: any, path?: string): Promise - -/** - * Set the progress - * Progress cannot go back and limited to 0% to 99% range - * @param percent Progress to set in % - * @param jobId? Job to set progress for - */ -async setProgress(percent: number, jobId?: any): Promise - -/** - * Get the progress - * @param jobId? Job to get progress from - * @returns Optional clamped between 0 and 100 progress value - */ -async getProgress(jobId?: any): Promise - -/** - * Set a flow user state - * @param key key of the state - * @param value value of the state - */ -async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise - -/** - * Get a flow user state - * @param path path of the variable - */ -async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise - -/** - * Get the internal state - * @deprecated use getState instead - */ -async getInternalState(): Promise - -/** - * Get the state shared across executions - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async getState(path?: string): Promise - -/** - * Get a variable by path - * @param path path of the variable - * @returns variable value - */ -async getVariable(path: string): Promise - -/** - * Set a variable by path, create if not exist - * @param path path of the variable - * @param value value of the variable - * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) - * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") - */ -async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise - -/** - * Build a PostgreSQL connection URL from a database resource - * @param path - Path to the database resource - * @returns PostgreSQL connection URL string - */ -async databaseUrlFromResource(path: string): Promise - -async polarsConnectionSettings(s3_resource_path: string | undefined): Promise - -async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise - -/** - * Get S3 client settings from a resource or workspace default - * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) - * @returns S3 client configuration settings - */ -async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise - -/** - * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContent = await wmill.loadS3FileContent(inputFile) - * // if the file is a raw text file, it can be decoded and printed directly: - * const text = new TextDecoder().decode(fileContentStream) - * console.log(text); - * \`\`\` - */ -async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContentBlob = await wmill.loadS3FileStream(inputFile) - * // if the content is plain text, the blob can be read directly: - * console.log(await fileContentBlob.text()); - * \`\`\` - */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise - -/** - * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * const s3object = await writeS3File(s3Object, "Hello Windmill!") - * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') - * console.log(fileContentAsUtf8Str) - * \`\`\` - */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise - -/** - * Sign S3 objects to be used by anonymous users in public apps - * @param s3objects s3 objects to sign - * @returns signed s3 objects - */ -async signS3Objects(s3objects: S3Object[]): Promise - -/** - * Sign S3 object to be used by anonymous users in public apps - * @param s3object s3 object to sign - * @returns signed s3 object - */ -async signS3Object(s3object: S3Object): Promise - -/** - * Generate a presigned public URL for an array of S3 objects. - * If an S3 object is not signed yet, it will be signed first. - * @param s3Objects s3 objects to sign - * @returns list of signed public URLs - */ -async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. - * @param s3Object s3 object to sign - * @returns signed public URL - */ -async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Get URLs needed for resuming a flow after this step - * @param approver approver name - * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. - * This allows pre-approvals that can be consumed by any later suspend step in the same flow. - * @returns approval page UI URL, resume and cancel API URLs for resuming the flow - */ -async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * @deprecated use getResumeUrls instead - */ -getResumeEndpoints(approver?: string): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) - * @param audience audience of the token - * @param expiresIn Optional number of seconds until the token expires - * @returns jwt token - */ -async getIdToken(audience: string, expiresIn?: number): Promise - -/** - * Convert a base64-encoded string to Uint8Array - * @param data - Base64-encoded string - * @returns Decoded Uint8Array - */ -base64ToUint8Array(data: string): Uint8Array - -/** - * Convert a Uint8Array to base64-encoded string - * @param arrayBuffer - Uint8Array to encode - * @returns Base64-encoded string - */ -uint8ArrayToBase64(arrayBuffer: Uint8Array): string - -/** - * Get email from workspace username - * This method is particularly useful for apps that require the email address of the viewer. - * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. - * @param username - * @returns email address - */ -async usernameToEmail(username: string): Promise - -/** - * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Slack approval request. - * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. - * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Slack approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * @param {string} [options.resumeButtonText] - Optional text for the resume button. - * @param {string} [options.cancelButtonText] - Optional text for the cancel button. - * - * @returns {Promise} Resolves when the Slack approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getSlackApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveSlackApproval({ - * slackResourcePath: "/u/alex/my_slack_resource", - * channelId: "admins-slack-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * resumeButtonText: "Resume", - * cancelButtonText: "Cancel", - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise - -/** - * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Teams approval request. - * @param {string} options.teamName - The Teams team name where the approval request will be sent. - * @param {string} options.channelName - The Teams channel name where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Teams approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * - * @returns {Promise} Resolves when the Teams approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getTeamsApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveTeamsApproval({ - * teamName: "admins-teams", - * channelName: "admins-teams-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise - -/** - * Parse an S3 object from URI string or record format - * @param s3Object - S3 object as URI string (s3://storage/key) or record - * @returns S3 object record with storage and s3 key - */ -parseS3Object(s3Object: S3Object): S3ObjectRecord - -setWorkflowCtx(ctx: WorkflowCtx | null): void - -async sleep(seconds: number): Promise - -async step(name: string, fn: () => T | Promise): Promise - -/** - * Create a task that dispatches to a separate Windmill script. - * - * @example - * const extract = taskScript("f/data/extract"); - * // inside workflow: await extract({ url: "https://..." }) - */ -taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Create a task that dispatches to a separate Windmill flow. - * - * @example - * const pipeline = taskFlow("f/etl/pipeline"); - * // inside workflow: await pipeline({ input: data }) - */ -taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Mark an async function as a workflow-as-code entry point. - * - * The function must be **deterministic**: given the same inputs it must call - * tasks in the same order on every replay. Branching on task results is fine - * (results are replayed from checkpoint), but branching on external state - * (current time, random values, external API calls) must use \`step()\` to - * checkpoint the value so replays see the same result. - */ -workflow(fn: (...args: any[]) => Promise): void - -/** - * Suspend the workflow and wait for an external approval. - * - * Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage - * URLs before calling this function. - * - * @example - * const urls = await step("urls", () => getResumeUrls()); - * await step("notify", () => sendEmail(urls.approvalPage)); - * const { value, approver } = await waitForApproval({ timeout: 3600 }); - */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> - -/** - * Process items in parallel with optional concurrency control. - * - * Each item is processed by calling \`fn(item)\`, which should be a task(). - * Items are dispatched in batches of \`concurrency\` (default: all at once). - * - * @example - * const process = task(async (item: string) => { ... }); - * const results = await parallel(items, process, { concurrency: 5 }); - */ -async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise -`, - "write-script-postgresql": `--- -name: write-script-postgresql -description: MUST use when writing PostgreSQL queries. ---- - -## CLI Commands - -Place scripts in a folder. After writing, run: -- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files -- \`wmill sync push\` - Deploy to Windmill - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# PostgreSQL - -Arguments are obtained directly in the statement with \`$1::{type}\`, \`$2::{type}\`, etc. - -Name the parameters by adding comments at the beginning of the script (without specifying the type): - -\`\`\`sql --- $1 name1 --- $2 name2 = default_value -SELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT; -\`\`\` -`, - "write-script-php": `--- -name: write-script-php -description: MUST use when writing PHP scripts. ---- - -## CLI Commands - -Place scripts in a folder. After writing, run: -- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files -- \`wmill sync push\` - Deploy to Windmill - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# PHP - -## Structure - -The script must start with \` $param1, "count" => $param2]; -} -\`\`\` - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -You need to **redefine** the type of the resources that are needed before the main function. Always check if the class already exists using \`class_exists\`: - -\`\`\`php - + +/** + * Get the true root job id + * @param jobId job id to get the root job id from (default to current job) + * @returns root job id + */ +async getRootJobId(jobId?: string): Promise + +/** + * @deprecated Use runScriptByPath or runScriptByHash instead + */ +async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise + +/** + * Run a script synchronously by its path and wait for the result + * @param path - Script path in Windmill + * @param args - Arguments to pass to the script + * @param verbose - Enable verbose logging + * @returns Script execution result + */ +async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise + +/** + * Run a script synchronously by its hash and wait for the result + * @param hash_ - Script hash in Windmill + * @param args - Arguments to pass to the script + * @param verbose - Enable verbose logging + * @returns Script execution result + */ +async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise + +/** + * Append a text to the result stream + * @param text text to append to the result stream + */ +appendToResultStream(text: string): void + +/** + * Stream to the result stream + * @param stream stream to stream to the result stream + */ +async streamResult(stream: AsyncIterable): Promise + +/** + * Run a flow synchronously by its path and wait for the result + * @param path - Flow path in Windmill + * @param args - Arguments to pass to the flow + * @param verbose - Enable verbose logging + * @returns Flow execution result + */ +async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise + +/** + * Wait for a job to complete and return its result + * @param jobId - ID of the job to wait for + * @param verbose - Enable verbose logging + * @returns Job result when completed + */ +async waitJob(jobId: string, verbose: boolean = false): Promise + +/** + * Get the result of a completed job + * @param jobId - ID of the completed job + * @returns Job result + */ +async getResult(jobId: string): Promise + +/** + * Get the result of a job if completed, or its current status + * @param jobId - ID of the job + * @returns Object with started, completed, success, and result properties + */ +async getResultMaybe(jobId: string): Promise + +/** + * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead + */ +async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise + +/** + * Run a script asynchronously by its path + * @param path - Script path in Windmill + * @param args - Arguments to pass to the script + * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @returns Job ID of the created job + */ +async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise + +/** + * Run a script asynchronously by its hash + * @param hash_ - Script hash in Windmill + * @param args - Arguments to pass to the script + * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @returns Job ID of the created job + */ +async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise + +/** + * Run a flow asynchronously by its path + * @param path - Flow path in Windmill + * @param args - Arguments to pass to the flow + * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) + * @returns Job ID of the created job + */ +async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise + +/** + * Resolve a resource value in case the default value was picked because the input payload was undefined + * @param obj resource value or path of the resource under the format \`$res:path\` + * @returns resource value + */ +async resolveDefaultResource(obj: any): Promise + +/** + * Get the state file path from environment variables + * @returns State path string + */ +getStatePath(): string + +/** + * Set a resource value by path + * @param path path of the resource to set, default to state path + * @param value new value of the resource to set + * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type + */ +async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise + +/** + * Set the state + * @param state state to set + * @deprecated use setState instead + */ +async setInternalState(state: any): Promise + +/** + * Set the state + * @param state state to set + * @param path Optional state resource path override. Defaults to \`getStatePath()\`. + */ +async setState(state: any, path?: string): Promise + +/** + * Set the progress + * Progress cannot go back and limited to 0% to 99% range + * @param percent Progress to set in % + * @param jobId? Job to set progress for + */ +async setProgress(percent: number, jobId?: any): Promise + +/** + * Get the progress + * @param jobId? Job to get progress from + * @returns Optional clamped between 0 and 100 progress value + */ +async getProgress(jobId?: any): Promise + +/** + * Set a flow user state + * @param key key of the state + * @param value value of the state + */ +async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise + +/** + * Get a flow user state + * @param path path of the variable + */ +async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise + +/** + * Get the internal state + * @deprecated use getState instead + */ +async getInternalState(): Promise + +/** + * Get the state shared across executions + * @param path Optional state resource path override. Defaults to \`getStatePath()\`. + */ +async getState(path?: string): Promise + +/** + * Get a variable by path + * @param path path of the variable + * @returns variable value + */ +async getVariable(path: string): Promise + +/** + * Set a variable by path, create if not exist + * @param path path of the variable + * @param value value of the variable + * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) + * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") + */ +async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise + +/** + * Build a PostgreSQL connection URL from a database resource + * @param path - Path to the database resource + * @returns PostgreSQL connection URL string + */ +async databaseUrlFromResource(path: string): Promise + +async polarsConnectionSettings(s3_resource_path: string | undefined): Promise + +async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise + +/** + * Get S3 client settings from a resource or workspace default + * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) + * @returns S3 client configuration settings + */ +async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise + +/** + * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. + * + * \`\`\`typescript + * let fileContent = await wmill.loadS3FileContent(inputFile) + * // if the file is a raw text file, it can be decoded and printed directly: + * const text = new TextDecoder().decode(fileContentStream) + * console.log(text); + * \`\`\` + */ +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise + +/** + * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. + * + * \`\`\`typescript + * let fileContentBlob = await wmill.loadS3FileStream(inputFile) + * // if the content is plain text, the blob can be read directly: + * console.log(await fileContentBlob.text()); + * \`\`\` + */ +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise + +/** + * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. + * + * \`\`\`typescript + * const s3object = await writeS3File(s3Object, "Hello Windmill!") + * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') + * console.log(fileContentAsUtf8Str) + * \`\`\` + */ +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise + +/** + * Sign S3 objects to be used by anonymous users in public apps + * @param s3objects s3 objects to sign + * @returns signed s3 objects + */ +async signS3Objects(s3objects: S3Object[]): Promise + +/** + * Sign S3 object to be used by anonymous users in public apps + * @param s3object s3 object to sign + * @returns signed s3 object + */ +async signS3Object(s3object: S3Object): Promise + +/** + * Generate a presigned public URL for an array of S3 objects. + * If an S3 object is not signed yet, it will be signed first. + * @param s3Objects s3 objects to sign + * @returns list of signed public URLs + */ +async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise + +/** + * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. + * @param s3Object s3 object to sign + * @returns signed public URL + */ +async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise + +/** + * Get URLs needed for resuming a flow after this step + * @param approver approver name + * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. + * This allows pre-approvals that can be consumed by any later suspend step in the same flow. + * @returns approval page UI URL, resume and cancel API URLs for resuming the flow + */ +async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ + approvalPage: string; + resume: string; + cancel: string; +}> + +/** + * @deprecated use getResumeUrls instead + */ +getResumeEndpoints(approver?: string): Promise<{ + approvalPage: string; + resume: string; + cancel: string; +}> + +/** + * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) + * @param audience audience of the token + * @param expiresIn Optional number of seconds until the token expires + * @returns jwt token + */ +async getIdToken(audience: string, expiresIn?: number): Promise + +/** + * Convert a base64-encoded string to Uint8Array + * @param data - Base64-encoded string + * @returns Decoded Uint8Array + */ +base64ToUint8Array(data: string): Uint8Array + +/** + * Convert a Uint8Array to base64-encoded string + * @param arrayBuffer - Uint8Array to encode + * @returns Base64-encoded string + */ +uint8ArrayToBase64(arrayBuffer: Uint8Array): string + +/** + * Get email from workspace username + * This method is particularly useful for apps that require the email address of the viewer. + * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. + * @param username + * @returns email address + */ +async usernameToEmail(username: string): Promise + +/** + * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. + * + * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** + * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). + * + * @param {Object} options - The configuration options for the Slack approval request. + * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. + * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. + * @param {string} [options.message] - Optional custom message to include in the Slack approval request. + * @param {string} [options.approver] - Optional user ID or name of the approver for the request. + * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. + * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. + * @param {string} [options.resumeButtonText] - Optional text for the resume button. + * @param {string} [options.cancelButtonText] - Optional text for the cancel button. + * + * @returns {Promise} Resolves when the Slack approval request is successfully sent. + * + * @throws {Error} If the function is not called within a flow or flow preview. + * @throws {Error} If the \`JobService.getSlackApprovalPayload\` call fails. + * + * **Usage Example:** + * \`\`\`typescript + * await requestInteractiveSlackApproval({ + * slackResourcePath: "/u/alex/my_slack_resource", + * channelId: "admins-slack-channel", + * message: "Please approve this request", + * approver: "approver123", + * defaultArgsJson: { key1: "value1", key2: 42 }, + * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, + * resumeButtonText: "Resume", + * cancelButtonText: "Cancel", + * }); + * \`\`\` + * + * **Note:** This function requires execution within a Windmill flow or flow preview. + */ +async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise + +/** + * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. + * + * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** + * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). + * + * @param {Object} options - The configuration options for the Teams approval request. + * @param {string} options.teamName - The Teams team name where the approval request will be sent. + * @param {string} options.channelName - The Teams channel name where the approval request will be sent. + * @param {string} [options.message] - Optional custom message to include in the Teams approval request. + * @param {string} [options.approver] - Optional user ID or name of the approver for the request. + * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. + * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. + * + * @returns {Promise} Resolves when the Teams approval request is successfully sent. + * + * @throws {Error} If the function is not called within a flow or flow preview. + * @throws {Error} If the \`JobService.getTeamsApprovalPayload\` call fails. + * + * **Usage Example:** + * \`\`\`typescript + * await requestInteractiveTeamsApproval({ + * teamName: "admins-teams", + * channelName: "admins-teams-channel", + * message: "Please approve this request", + * approver: "approver123", + * defaultArgsJson: { key1: "value1", key2: 42 }, + * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, + * }); + * \`\`\` + * + * **Note:** This function requires execution within a Windmill flow or flow preview. + */ +async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise + +/** + * Parse an S3 object from URI string or record format + * @param s3Object - S3 object as URI string (s3://storage/key) or record + * @returns S3 object record with storage and s3 key + */ +parseS3Object(s3Object: S3Object): S3ObjectRecord + +setWorkflowCtx(ctx: WorkflowCtx | null): void + +async sleep(seconds: number): Promise + +async step(name: string, fn: () => T | Promise): Promise + +/** + * Create a task that dispatches to a separate Windmill script. + * + * @example + * const extract = taskScript("f/data/extract"); + * // inside workflow: await extract({ url: "https://..." }) + */ +taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike + +/** + * Create a task that dispatches to a separate Windmill flow. + * + * @example + * const pipeline = taskFlow("f/etl/pipeline"); + * // inside workflow: await pipeline({ input: data }) + */ +taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike + +/** + * Mark an async function as a workflow-as-code entry point. + * + * The function must be **deterministic**: given the same inputs it must call + * tasks in the same order on every replay. Branching on task results is fine + * (results are replayed from checkpoint), but branching on external state + * (current time, random values, external API calls) must use \`step()\` to + * checkpoint the value so replays see the same result. + */ +workflow(fn: (...args: any[]) => Promise): void + +/** + * Suspend the workflow and wait for an external approval. + * + * Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage + * URLs before calling this function. + * + * @example + * const urls = await step("urls", () => getResumeUrls()); + * await step("notify", () => sendEmail(urls.approvalPage)); + * const { value, approver } = await waitForApproval({ timeout: 3600 }); + */ +waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> + +/** + * Process items in parallel with optional concurrency control. + * + * Each item is processed by calling \`fn(item)\`, which should be a task(). + * Items are dispatched in batches of \`concurrency\` (default: all at once). + * + * @example + * const process = task(async (item: string) => { ... }); + * const results = await parallel(items, process, { concurrency: 5 }); + */ +async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise + +/** + * Commit Kafka offsets for a trigger with auto_commit disabled. + * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) + * @param topic - Kafka topic name (from event.topic) + * @param partition - Partition number (from event.partition) + * @param offset - Message offset to commit (from event.offset) + */ +async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise + /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") @@ -1241,6 +792,137 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction * \`.fetch() */ ducklake(name: string = "main"): SqlTemplateFunction +`, + "write-script-bunnative": `--- +name: write-script-bunnative +description: MUST use when writing Bun Native scripts. +--- + +## CLI Commands + +Place scripts in a folder. After writing, tell the user they can run: +- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files +- \`wmill sync push\` - Deploy to Windmill + +Do NOT run these commands yourself. Instead, inform the user that they should run them. + +Use \`wmill resource-type list --schema\` to discover available resource types. + +# TypeScript (Bun Native) + +Native TypeScript execution with fetch only - no external imports allowed. + +## Structure + +Export a single **async** function called \`main\`: + +\`\`\`typescript +export async function main(param1: string, param2: number) { + // Your code here + return { result: param1, count: param2 }; +} +\`\`\` + +Do not call the main function. + +## Resource Types + +On Windmill, credentials and configuration are stored in resources and passed as parameters to main. + +Use the \`RT\` namespace for resource types: + +\`\`\`typescript +export async function main(stripe: RT.Stripe) { + // stripe contains API key and config from the resource +} +\`\`\` + +Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. + +Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. + +## Imports + +**No imports allowed.** Use the globally available \`fetch\` function: + +\`\`\`typescript +export async function main(url: string) { + const response = await fetch(url); + return await response.json(); +} +\`\`\` + +## Windmill Client + +The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. + +## Preprocessor Scripts + +For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: + +\`\`\`typescript +type Event = { + kind: + | "webhook" + | "http" + | "websocket" + | "kafka" + | "email" + | "nats" + | "postgres" + | "sqs" + | "mqtt" + | "gcp"; + body: any; + headers: Record; + query: Record; +}; + +export async function preprocessor(event: Event) { + return { + param1: event.body.field1, + param2: event.query.id, + }; +} +\`\`\` + +## S3 Object Operations + +Windmill provides built-in support for S3-compatible storage operations. + +### S3Object Type + +The S3Object type represents a file in S3 storage: + +\`\`\`typescript +type S3Object = { + s3: string; // Path within the bucket +}; +\`\`\` + +## TypeScript Operations + +\`\`\`typescript +import * as wmill from "windmill-client"; + +// Load file content from S3 +const content: Uint8Array = await wmill.loadS3File(s3object); + +// Load file as stream +const blob: Blob = await wmill.loadS3FileStream(s3object); + +// Write file to S3 +const result: S3Object = await wmill.writeS3File( + s3object, // Target path (or undefined to auto-generate) + fileContent, // string or Blob + s3ResourcePath // Optional: specific S3 resource to use +); +\`\`\` + + +# TypeScript SDK (windmill-client) + +Import: import * as wmill from 'windmill-client' /** * Initialize the Windmill client with authentication token and base URL @@ -1734,6 +1416,45 @@ waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ v * const results = await parallel(items, process, { concurrency: 5 }); */ async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise + +/** + * Commit Kafka offsets for a trigger with auto_commit disabled. + * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) + * @param topic - Kafka topic name (from event.topic) + * @param partition - Partition number (from event.partition) + * @param offset - Message offset to commit (from event.offset) + */ +async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise + +/** + * Create a SQL template function for PostgreSQL/datatable queries + * @param name - Database/datatable name (default: "main") + * @returns SQL template function for building parameterized queries + * @example + * let sql = wmill.datatable() + * let name = 'Robin' + * let age = 21 + * await sql\` + * SELECT * FROM friends + * WHERE name = \${name} AND age = \${age}::int + * \`.fetch() + */ +datatable(name: string = "main"): DatatableSqlTemplateFunction + +/** + * Create a SQL template function for DuckDB/ducklake queries + * @param name - DuckDB database name (default: "main") + * @returns SQL template function for building parameterized queries + * @example + * let sql = wmill.ducklake() + * let name = 'Robin' + * let age = 21 + * await sql\` + * SELECT * FROM friends + * WHERE name = \${name} AND age = \${age} + * \`.fetch() + */ +ducklake(name: string = "main"): SqlTemplateFunction `, "write-script-csharp": `--- name: write-script-csharp @@ -1742,10 +1463,12 @@ description: MUST use when writing C# scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # C# @@ -1789,31 +1512,6 @@ public class Script } } \`\`\` -`, - "write-script-mssql": `--- -name: write-script-mssql -description: MUST use when writing MS SQL Server queries. ---- - -## CLI Commands - -Place scripts in a folder. After writing, run: -- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files -- \`wmill sync push\` - Deploy to Windmill - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# Microsoft SQL Server (MSSQL) - -Arguments use \`@P1\`, \`@P2\`, etc. - -Name the parameters by adding comments before the statement: - -\`\`\`sql --- @P1 name1 (varchar) --- @P2 name2 (int) = 0 -SELECT * FROM users WHERE name = @P1 AND age > @P2; -\`\`\` `, "write-script-deno": `--- name: write-script-deno @@ -1822,10 +1520,12 @@ description: MUST use when writing Deno/TypeScript scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Deno) @@ -1950,6 +1650,508 @@ const result: S3Object = await wmill.writeS3File( Import: import * as wmill from 'windmill-client' +/** + * Initialize the Windmill client with authentication token and base URL + * @param token - Authentication token (defaults to WM_TOKEN env variable) + * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable) + */ +setClient(token?: string, baseUrl?: string): void + +/** + * Create a client configuration from env variables + * @returns client configuration + */ +getWorkspace(): string + +/** + * Get a resource value by path + * @param path path of the resource, default to internal state path + * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error + * @returns resource value + */ +async getResource(path?: string, undefinedIfEmpty?: boolean): Promise + +/** + * Get the true root job id + * @param jobId job id to get the root job id from (default to current job) + * @returns root job id + */ +async getRootJobId(jobId?: string): Promise + +/** + * @deprecated Use runScriptByPath or runScriptByHash instead + */ +async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise + +/** + * Run a script synchronously by its path and wait for the result + * @param path - Script path in Windmill + * @param args - Arguments to pass to the script + * @param verbose - Enable verbose logging + * @returns Script execution result + */ +async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise + +/** + * Run a script synchronously by its hash and wait for the result + * @param hash_ - Script hash in Windmill + * @param args - Arguments to pass to the script + * @param verbose - Enable verbose logging + * @returns Script execution result + */ +async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise + +/** + * Append a text to the result stream + * @param text text to append to the result stream + */ +appendToResultStream(text: string): void + +/** + * Stream to the result stream + * @param stream stream to stream to the result stream + */ +async streamResult(stream: AsyncIterable): Promise + +/** + * Run a flow synchronously by its path and wait for the result + * @param path - Flow path in Windmill + * @param args - Arguments to pass to the flow + * @param verbose - Enable verbose logging + * @returns Flow execution result + */ +async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise + +/** + * Wait for a job to complete and return its result + * @param jobId - ID of the job to wait for + * @param verbose - Enable verbose logging + * @returns Job result when completed + */ +async waitJob(jobId: string, verbose: boolean = false): Promise + +/** + * Get the result of a completed job + * @param jobId - ID of the completed job + * @returns Job result + */ +async getResult(jobId: string): Promise + +/** + * Get the result of a job if completed, or its current status + * @param jobId - ID of the job + * @returns Object with started, completed, success, and result properties + */ +async getResultMaybe(jobId: string): Promise + +/** + * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead + */ +async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise + +/** + * Run a script asynchronously by its path + * @param path - Script path in Windmill + * @param args - Arguments to pass to the script + * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @returns Job ID of the created job + */ +async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise + +/** + * Run a script asynchronously by its hash + * @param hash_ - Script hash in Windmill + * @param args - Arguments to pass to the script + * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @returns Job ID of the created job + */ +async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise + +/** + * Run a flow asynchronously by its path + * @param path - Flow path in Windmill + * @param args - Arguments to pass to the flow + * @param scheduledInSeconds - Schedule execution for a future time (in seconds) + * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) + * @returns Job ID of the created job + */ +async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise + +/** + * Resolve a resource value in case the default value was picked because the input payload was undefined + * @param obj resource value or path of the resource under the format \`$res:path\` + * @returns resource value + */ +async resolveDefaultResource(obj: any): Promise + +/** + * Get the state file path from environment variables + * @returns State path string + */ +getStatePath(): string + +/** + * Set a resource value by path + * @param path path of the resource to set, default to state path + * @param value new value of the resource to set + * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type + */ +async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise + +/** + * Set the state + * @param state state to set + * @deprecated use setState instead + */ +async setInternalState(state: any): Promise + +/** + * Set the state + * @param state state to set + * @param path Optional state resource path override. Defaults to \`getStatePath()\`. + */ +async setState(state: any, path?: string): Promise + +/** + * Set the progress + * Progress cannot go back and limited to 0% to 99% range + * @param percent Progress to set in % + * @param jobId? Job to set progress for + */ +async setProgress(percent: number, jobId?: any): Promise + +/** + * Get the progress + * @param jobId? Job to get progress from + * @returns Optional clamped between 0 and 100 progress value + */ +async getProgress(jobId?: any): Promise + +/** + * Set a flow user state + * @param key key of the state + * @param value value of the state + */ +async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise + +/** + * Get a flow user state + * @param path path of the variable + */ +async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise + +/** + * Get the internal state + * @deprecated use getState instead + */ +async getInternalState(): Promise + +/** + * Get the state shared across executions + * @param path Optional state resource path override. Defaults to \`getStatePath()\`. + */ +async getState(path?: string): Promise + +/** + * Get a variable by path + * @param path path of the variable + * @returns variable value + */ +async getVariable(path: string): Promise + +/** + * Set a variable by path, create if not exist + * @param path path of the variable + * @param value value of the variable + * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) + * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") + */ +async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise + +/** + * Build a PostgreSQL connection URL from a database resource + * @param path - Path to the database resource + * @returns PostgreSQL connection URL string + */ +async databaseUrlFromResource(path: string): Promise + +async polarsConnectionSettings(s3_resource_path: string | undefined): Promise + +async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise + +/** + * Get S3 client settings from a resource or workspace default + * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) + * @returns S3 client configuration settings + */ +async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise + +/** + * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. + * + * \`\`\`typescript + * let fileContent = await wmill.loadS3FileContent(inputFile) + * // if the file is a raw text file, it can be decoded and printed directly: + * const text = new TextDecoder().decode(fileContentStream) + * console.log(text); + * \`\`\` + */ +async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise + +/** + * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. + * + * \`\`\`typescript + * let fileContentBlob = await wmill.loadS3FileStream(inputFile) + * // if the content is plain text, the blob can be read directly: + * console.log(await fileContentBlob.text()); + * \`\`\` + */ +async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise + +/** + * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. + * + * \`\`\`typescript + * const s3object = await writeS3File(s3Object, "Hello Windmill!") + * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') + * console.log(fileContentAsUtf8Str) + * \`\`\` + */ +async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise + +/** + * Sign S3 objects to be used by anonymous users in public apps + * @param s3objects s3 objects to sign + * @returns signed s3 objects + */ +async signS3Objects(s3objects: S3Object[]): Promise + +/** + * Sign S3 object to be used by anonymous users in public apps + * @param s3object s3 object to sign + * @returns signed s3 object + */ +async signS3Object(s3object: S3Object): Promise + +/** + * Generate a presigned public URL for an array of S3 objects. + * If an S3 object is not signed yet, it will be signed first. + * @param s3Objects s3 objects to sign + * @returns list of signed public URLs + */ +async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise + +/** + * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. + * @param s3Object s3 object to sign + * @returns signed public URL + */ +async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise + +/** + * Get URLs needed for resuming a flow after this step + * @param approver approver name + * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. + * This allows pre-approvals that can be consumed by any later suspend step in the same flow. + * @returns approval page UI URL, resume and cancel API URLs for resuming the flow + */ +async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ + approvalPage: string; + resume: string; + cancel: string; +}> + +/** + * @deprecated use getResumeUrls instead + */ +getResumeEndpoints(approver?: string): Promise<{ + approvalPage: string; + resume: string; + cancel: string; +}> + +/** + * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) + * @param audience audience of the token + * @param expiresIn Optional number of seconds until the token expires + * @returns jwt token + */ +async getIdToken(audience: string, expiresIn?: number): Promise + +/** + * Convert a base64-encoded string to Uint8Array + * @param data - Base64-encoded string + * @returns Decoded Uint8Array + */ +base64ToUint8Array(data: string): Uint8Array + +/** + * Convert a Uint8Array to base64-encoded string + * @param arrayBuffer - Uint8Array to encode + * @returns Base64-encoded string + */ +uint8ArrayToBase64(arrayBuffer: Uint8Array): string + +/** + * Get email from workspace username + * This method is particularly useful for apps that require the email address of the viewer. + * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. + * @param username + * @returns email address + */ +async usernameToEmail(username: string): Promise + +/** + * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. + * + * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** + * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). + * + * @param {Object} options - The configuration options for the Slack approval request. + * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. + * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. + * @param {string} [options.message] - Optional custom message to include in the Slack approval request. + * @param {string} [options.approver] - Optional user ID or name of the approver for the request. + * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. + * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. + * @param {string} [options.resumeButtonText] - Optional text for the resume button. + * @param {string} [options.cancelButtonText] - Optional text for the cancel button. + * + * @returns {Promise} Resolves when the Slack approval request is successfully sent. + * + * @throws {Error} If the function is not called within a flow or flow preview. + * @throws {Error} If the \`JobService.getSlackApprovalPayload\` call fails. + * + * **Usage Example:** + * \`\`\`typescript + * await requestInteractiveSlackApproval({ + * slackResourcePath: "/u/alex/my_slack_resource", + * channelId: "admins-slack-channel", + * message: "Please approve this request", + * approver: "approver123", + * defaultArgsJson: { key1: "value1", key2: 42 }, + * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, + * resumeButtonText: "Resume", + * cancelButtonText: "Cancel", + * }); + * \`\`\` + * + * **Note:** This function requires execution within a Windmill flow or flow preview. + */ +async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise + +/** + * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. + * + * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** + * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). + * + * @param {Object} options - The configuration options for the Teams approval request. + * @param {string} options.teamName - The Teams team name where the approval request will be sent. + * @param {string} options.channelName - The Teams channel name where the approval request will be sent. + * @param {string} [options.message] - Optional custom message to include in the Teams approval request. + * @param {string} [options.approver] - Optional user ID or name of the approver for the request. + * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. + * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. + * + * @returns {Promise} Resolves when the Teams approval request is successfully sent. + * + * @throws {Error} If the function is not called within a flow or flow preview. + * @throws {Error} If the \`JobService.getTeamsApprovalPayload\` call fails. + * + * **Usage Example:** + * \`\`\`typescript + * await requestInteractiveTeamsApproval({ + * teamName: "admins-teams", + * channelName: "admins-teams-channel", + * message: "Please approve this request", + * approver: "approver123", + * defaultArgsJson: { key1: "value1", key2: 42 }, + * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, + * }); + * \`\`\` + * + * **Note:** This function requires execution within a Windmill flow or flow preview. + */ +async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise + +/** + * Parse an S3 object from URI string or record format + * @param s3Object - S3 object as URI string (s3://storage/key) or record + * @returns S3 object record with storage and s3 key + */ +parseS3Object(s3Object: S3Object): S3ObjectRecord + +setWorkflowCtx(ctx: WorkflowCtx | null): void + +async sleep(seconds: number): Promise + +async step(name: string, fn: () => T | Promise): Promise + +/** + * Create a task that dispatches to a separate Windmill script. + * + * @example + * const extract = taskScript("f/data/extract"); + * // inside workflow: await extract({ url: "https://..." }) + */ +taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike + +/** + * Create a task that dispatches to a separate Windmill flow. + * + * @example + * const pipeline = taskFlow("f/etl/pipeline"); + * // inside workflow: await pipeline({ input: data }) + */ +taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike + +/** + * Mark an async function as a workflow-as-code entry point. + * + * The function must be **deterministic**: given the same inputs it must call + * tasks in the same order on every replay. Branching on task results is fine + * (results are replayed from checkpoint), but branching on external state + * (current time, random values, external API calls) must use \`step()\` to + * checkpoint the value so replays see the same result. + */ +workflow(fn: (...args: any[]) => Promise): void + +/** + * Suspend the workflow and wait for an external approval. + * + * Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage + * URLs before calling this function. + * + * @example + * const urls = await step("urls", () => getResumeUrls()); + * await step("notify", () => sendEmail(urls.approvalPage)); + * const { value, approver } = await waitForApproval({ timeout: 3600 }); + */ +waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> + +/** + * Process items in parallel with optional concurrency control. + * + * Each item is processed by calling \`fn(item)\`, which should be a task(). + * Items are dispatched in batches of \`concurrency\` (default: all at once). + * + * @example + * const process = task(async (item: string) => { ... }); + * const results = await parallel(items, process, { concurrency: 5 }); + */ +async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise + +/** + * Commit Kafka offsets for a trigger with auto_commit disabled. + * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) + * @param topic - Kafka topic name (from event.topic) + * @param partition - Partition number (from event.partition) + * @param offset - Message offset to commit (from event.offset) + */ +async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise + /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") @@ -1979,6 +2181,414 @@ datatable(name: string = "main"): DatatableSqlTemplateFunction * \`.fetch() */ ducklake(name: string = "main"): SqlTemplateFunction +`, + "write-script-duckdb": `--- +name: write-script-duckdb +description: MUST use when writing DuckDB queries. +--- + +## CLI Commands + +Place scripts in a folder. After writing, tell the user they can run: +- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files +- \`wmill sync push\` - Deploy to Windmill + +Do NOT run these commands yourself. Instead, inform the user that they should run them. + +Use \`wmill resource-type list --schema\` to discover available resource types. + +# DuckDB + +Arguments are defined with comments and used with \`$name\` syntax: + +\`\`\`sql +-- $name (text) = default +-- $age (integer) +SELECT * FROM users WHERE name = $name AND age > $age; +\`\`\` + +## Ducklake Integration + +Attach Ducklake for data lake operations: + +\`\`\`sql +-- Main ducklake +ATTACH 'ducklake' AS dl; + +-- Named ducklake +ATTACH 'ducklake://my_lake' AS dl; + +-- Then query +SELECT * FROM dl.schema.table; +\`\`\` + +## External Database Connections + +Connect to external databases using resources: + +\`\`\`sql +ATTACH '$res:path/to/resource' AS db (TYPE postgres); +SELECT * FROM db.schema.table; +\`\`\` + +## S3 File Operations + +Read files from S3 storage: + +\`\`\`sql +-- Default storage +SELECT * FROM read_csv('s3:///path/to/file.csv'); + +-- Named storage +SELECT * FROM read_csv('s3://storage_name/path/to/file.csv'); + +-- Parquet files +SELECT * FROM read_parquet('s3:///path/to/file.parquet'); + +-- JSON files +SELECT * FROM read_json('s3:///path/to/file.json'); +\`\`\` +`, + "write-script-go": `--- +name: write-script-go +description: MUST use when writing Go scripts. +--- + +## CLI Commands + +Place scripts in a folder. After writing, tell the user they can run: +- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files +- \`wmill sync push\` - Deploy to Windmill + +Do NOT run these commands yourself. Instead, inform the user that they should run them. + +Use \`wmill resource-type list --schema\` to discover available resource types. + +# Go + +## Structure + +The file package must be \`inner\` and export a function called \`main\`: + +\`\`\`go +package inner + +func main(param1 string, param2 int) (map[string]interface{}, error) { + return map[string]interface{}{ + "result": param1, + "count": param2, + }, nil +} +\`\`\` + +**Important:** +- Package must be \`inner\` +- Return type must be \`({return_type}, error)\` +- Function name is \`main\` (lowercase) + +## Return Types + +The return type can be any Go type that can be serialized to JSON: + +\`\`\`go +package inner + +type Result struct { + Name string \`json:"name"\` + Count int \`json:"count"\` +} + +func main(name string, count int) (Result, error) { + return Result{ + Name: name, + Count: count, + }, nil +} +\`\`\` + +## Error Handling + +Return errors as the second return value: + +\`\`\`go +package inner + +import "errors" + +func main(value int) (string, error) { + if value < 0 { + return "", errors.New("value must be positive") + } + return "success", nil +} +\`\`\` +`, + "write-script-graphql": `--- +name: write-script-graphql +description: MUST use when writing GraphQL queries. +--- + +## CLI Commands + +Place scripts in a folder. After writing, tell the user they can run: +- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files +- \`wmill sync push\` - Deploy to Windmill + +Do NOT run these commands yourself. Instead, inform the user that they should run them. + +Use \`wmill resource-type list --schema\` to discover available resource types. + +# GraphQL + +## Structure + +Write GraphQL queries or mutations. Arguments can be added as query parameters: + +\`\`\`graphql +query GetUser($id: ID!) { + user(id: $id) { + id + name + email + } +} +\`\`\` + +## Variables + +Variables are passed as script arguments and automatically bound to the query: + +\`\`\`graphql +query SearchProducts($query: String!, $limit: Int = 10) { + products(search: $query, first: $limit) { + edges { + node { + id + name + price + } + } + } +} +\`\`\` + +## Mutations + +\`\`\`graphql +mutation CreateUser($input: CreateUserInput!) { + createUser(input: $input) { + id + name + createdAt + } +} +\`\`\` +`, + "write-script-java": `--- +name: write-script-java +description: MUST use when writing Java scripts. +--- + +## CLI Commands + +Place scripts in a folder. After writing, tell the user they can run: +- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files +- \`wmill sync push\` - Deploy to Windmill + +Do NOT run these commands yourself. Instead, inform the user that they should run them. + +Use \`wmill resource-type list --schema\` to discover available resource types. + +# Java + +The script must contain a Main public class with a \`public static main()\` method: + +\`\`\`java +public class Main { + public static Object main(String name, int count) { + java.util.Map result = new java.util.HashMap<>(); + result.put("name", name); + result.put("count", count); + return result; + } +} +\`\`\` + +**Important:** +- Class must be named \`Main\` +- Method must be \`public static Object main(...)\` +- Return type is \`Object\` or \`void\` + +## Maven Dependencies + +Add dependencies using comments at the top: + +\`\`\`java +//requirements: +//com.google.code.gson:gson:2.10.1 +//org.apache.httpcomponents:httpclient:4.5.14 + +import com.google.gson.Gson; + +public class Main { + public static Object main(String input) { + Gson gson = new Gson(); + return gson.fromJson(input, Object.class); + } +} +\`\`\` +`, + "write-script-mssql": `--- +name: write-script-mssql +description: MUST use when writing MS SQL Server queries. +--- + +## CLI Commands + +Place scripts in a folder. After writing, tell the user they can run: +- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files +- \`wmill sync push\` - Deploy to Windmill + +Do NOT run these commands yourself. Instead, inform the user that they should run them. + +Use \`wmill resource-type list --schema\` to discover available resource types. + +# Microsoft SQL Server (MSSQL) + +Arguments use \`@P1\`, \`@P2\`, etc. + +Name the parameters by adding comments before the statement: + +\`\`\`sql +-- @P1 name1 (varchar) +-- @P2 name2 (int) = 0 +SELECT * FROM users WHERE name = @P1 AND age > @P2; +\`\`\` +`, + "write-script-mysql": `--- +name: write-script-mysql +description: MUST use when writing MySQL queries. +--- + +## CLI Commands + +Place scripts in a folder. After writing, tell the user they can run: +- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files +- \`wmill sync push\` - Deploy to Windmill + +Do NOT run these commands yourself. Instead, inform the user that they should run them. + +Use \`wmill resource-type list --schema\` to discover available resource types. + +# MySQL + +Arguments use \`?\` placeholders. + +Name the parameters by adding comments before the statement: + +\`\`\`sql +-- ? name1 (text) +-- ? name2 (int) = 0 +SELECT * FROM users WHERE name = ? AND age > ?; +\`\`\` +`, + "write-script-nativets": `--- +name: write-script-nativets +description: MUST use when writing Native TypeScript scripts. +--- + +## CLI Commands + +Place scripts in a folder. After writing, tell the user they can run: +- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files +- \`wmill sync push\` - Deploy to Windmill + +Do NOT run these commands yourself. Instead, inform the user that they should run them. + +Use \`wmill resource-type list --schema\` to discover available resource types. + +# TypeScript (Native) + +Native TypeScript execution with fetch only - no external imports allowed. + +## Structure + +Export a single **async** function called \`main\`: + +\`\`\`typescript +export async function main(param1: string, param2: number) { + // Your code here + return { result: param1, count: param2 }; +} +\`\`\` + +Do not call the main function. + +## Resource Types + +On Windmill, credentials and configuration are stored in resources and passed as parameters to main. + +Use the \`RT\` namespace for resource types: + +\`\`\`typescript +export async function main(stripe: RT.Stripe) { + // stripe contains API key and config from the resource +} +\`\`\` + +Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. + +Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. + +## Imports + +**No imports allowed.** Use the globally available \`fetch\` function: + +\`\`\`typescript +export async function main(url: string) { + const response = await fetch(url); + return await response.json(); +} +\`\`\` + +## Windmill Client + +The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. + +## Preprocessor Scripts + +For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: + +\`\`\`typescript +type Event = { + kind: + | "webhook" + | "http" + | "websocket" + | "kafka" + | "email" + | "nats" + | "postgres" + | "sqs" + | "mqtt" + | "gcp"; + body: any; + headers: Record; + query: Record; +}; + +export async function preprocessor(event: Event) { + return { + param1: event.body.field1, + param2: event.query.id + }; +} +\`\`\` + + +# TypeScript SDK (windmill-client) + +Import: import * as wmill from 'windmill-client' /** * Initialize the Windmill client with authentication token and base URL @@ -2472,30 +3082,144 @@ waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ v * const results = await parallel(items, process, { concurrency: 5 }); */ async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise + +/** + * Commit Kafka offsets for a trigger with auto_commit disabled. + * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) + * @param topic - Kafka topic name (from event.topic) + * @param partition - Partition number (from event.partition) + * @param offset - Message offset to commit (from event.offset) + */ +async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise + +/** + * Create a SQL template function for PostgreSQL/datatable queries + * @param name - Database/datatable name (default: "main") + * @returns SQL template function for building parameterized queries + * @example + * let sql = wmill.datatable() + * let name = 'Robin' + * let age = 21 + * await sql\` + * SELECT * FROM friends + * WHERE name = \${name} AND age = \${age}::int + * \`.fetch() + */ +datatable(name: string = "main"): DatatableSqlTemplateFunction + +/** + * Create a SQL template function for DuckDB/ducklake queries + * @param name - DuckDB database name (default: "main") + * @returns SQL template function for building parameterized queries + * @example + * let sql = wmill.ducklake() + * let name = 'Robin' + * let age = 21 + * await sql\` + * SELECT * FROM friends + * WHERE name = \${name} AND age = \${age} + * \`.fetch() + */ +ducklake(name: string = "main"): SqlTemplateFunction `, - "write-script-mysql": `--- -name: write-script-mysql -description: MUST use when writing MySQL queries. + "write-script-php": `--- +name: write-script-php +description: MUST use when writing PHP scripts. --- ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. -# MySQL +# PHP -Arguments use \`?\` placeholders. +## Structure -Name the parameters by adding comments before the statement: +The script must start with \` $param1, "count" => $param2]; +} +\`\`\` + +## Resource Types + +On Windmill, credentials and configuration are stored in resources and passed as parameters to main. + +You need to **redefine** the type of the resources that are needed before the main function. Always check if the class already exists using \`class_exists\`: + +\`\`\`php + ?; +-- $1 name1 +-- $2 name2 = default_value +SELECT * FROM users WHERE name = $1::TEXT AND age > $2::INT; \`\`\` `, "write-script-powershell": `--- @@ -2505,10 +3229,12 @@ description: MUST use when writing PowerShell scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # PowerShell @@ -2566,31 +3292,6 @@ $result = @{ $result \`\`\` -`, - "write-script-snowflake": `--- -name: write-script-snowflake -description: MUST use when writing Snowflake queries. ---- - -## CLI Commands - -Place scripts in a folder. After writing, run: -- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files -- \`wmill sync push\` - Deploy to Windmill - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# Snowflake - -Arguments use \`?\` placeholders. - -Name the parameters by adding comments before the statement: - -\`\`\`sql --- ? name1 (text) --- ? name2 (number) = 0 -SELECT * FROM users WHERE name = ? AND age > ?; -\`\`\` `, "write-script-python3": `--- name: write-script-python3 @@ -2599,10 +3300,12 @@ description: MUST use when writing Python scripts. ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. # Python @@ -3396,753 +4099,133 @@ async def wait_for_approval(timeout: int = 1800, form: dict | None = None) -> di # results = await parallel(items, process, concurrency=5) async def parallel(items, fn, concurrency: Optional[int] = None) +# Commit Kafka offsets for a trigger with auto_commit disabled. +# +# Args: +# trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path']) +# topic: Kafka topic name (from event['topic']) +# partition: Partition number (from event['partition']) +# offset: Message offset to commit (from event['offset']) +def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None + `, - "write-script-duckdb": `--- -name: write-script-duckdb -description: MUST use when writing DuckDB queries. + "write-script-rust": `--- +name: write-script-rust +description: MUST use when writing Rust scripts. --- ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill -Use \`wmill resource-type list --schema\` to discover available resource types. - -# DuckDB - -Arguments are defined with comments and used with \`$name\` syntax: - -\`\`\`sql --- $name (text) = default --- $age (integer) -SELECT * FROM users WHERE name = $name AND age > $age; -\`\`\` - -## Ducklake Integration - -Attach Ducklake for data lake operations: - -\`\`\`sql --- Main ducklake -ATTACH 'ducklake' AS dl; - --- Named ducklake -ATTACH 'ducklake://my_lake' AS dl; - --- Then query -SELECT * FROM dl.schema.table; -\`\`\` - -## External Database Connections - -Connect to external databases using resources: - -\`\`\`sql -ATTACH '$res:path/to/resource' AS db (TYPE postgres); -SELECT * FROM db.schema.table; -\`\`\` - -## S3 File Operations - -Read files from S3 storage: - -\`\`\`sql --- Default storage -SELECT * FROM read_csv('s3:///path/to/file.csv'); - --- Named storage -SELECT * FROM read_csv('s3://storage_name/path/to/file.csv'); - --- Parquet files -SELECT * FROM read_parquet('s3:///path/to/file.parquet'); - --- JSON files -SELECT * FROM read_json('s3:///path/to/file.json'); -\`\`\` -`, - "write-script-bash": `--- -name: write-script-bash -description: MUST use when writing Bash scripts. ---- - -## CLI Commands - -Place scripts in a folder. After writing, run: -- \`wmill script generate-metadata\` - Generate .script.yaml and .lock files -- \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. Use \`wmill resource-type list --schema\` to discover available resource types. -# Bash +# Rust ## Structure -Do not include \`#!/bin/bash\`. Arguments are obtained as positional parameters: +The script must contain a function called \`main\` with proper return type: -\`\`\`bash -# Get arguments -var1="$1" -var2="$2" +\`\`\`rust +use anyhow::anyhow; +use serde::Serialize; -echo "Processing $var1 and $var2" +#[derive(Serialize, Debug)] +struct ReturnType { + result: String, + count: i32, +} -# Return JSON by echoing to stdout -echo "{\\"result\\": \\"$var1\\", \\"count\\": $var2}" +fn main(param1: String, param2: i32) -> anyhow::Result { + Ok(ReturnType { + result: param1, + count: param2, + }) +} \`\`\` **Important:** -- Do not include shebang (\`#!/bin/bash\`) -- Arguments are always strings -- Access with \`$1\`, \`$2\`, etc. +- Arguments should be owned types +- Return type must be serializable (\`#[derive(Serialize)]\`) +- Return type is \`anyhow::Result\` -## Output +## Dependencies -The script output is captured as the result. For structured data, output valid JSON: +Packages must be specified with a partial cargo.toml at the beginning of the script: -\`\`\`bash -name="$1" -count="$2" +\`\`\`rust +//! \`\`\`cargo +//! [dependencies] +//! anyhow = "1.0.86" +//! reqwest = { version = "0.11", features = ["json"] } +//! tokio = { version = "1", features = ["full"] } +//! \`\`\` -# Output JSON result -cat << EOF -{ - "name": "$name", - "count": $count, - "timestamp": "$(date -Iseconds)" -} -EOF +use anyhow::anyhow; +// ... rest of the code \`\`\` -## Environment Variables +**Note:** Serde is already included, no need to add it again. -Environment variables set in Windmill are available: +## Async Functions -\`\`\`bash -# Access environment variable -echo "Workspace: $WM_WORKSPACE" -echo "Job ID: $WM_JOB_ID" +If you need to handle async functions (e.g., using tokio), keep the main function sync and create the runtime inside: + +\`\`\`rust +//! \`\`\`cargo +//! [dependencies] +//! anyhow = "1.0.86" +//! tokio = { version = "1", features = ["full"] } +//! reqwest = { version = "0.11", features = ["json"] } +//! \`\`\` + +use anyhow::anyhow; +use serde::Serialize; + +#[derive(Serialize, Debug)] +struct Response { + data: String, +} + +fn main(url: String) -> anyhow::Result { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { + let resp = reqwest::get(&url).await?.text().await?; + Ok(Response { data: resp }) + }) +} \`\`\` `, - "write-script-nativets": `--- -name: write-script-nativets -description: MUST use when writing Native TypeScript scripts. + "write-script-snowflake": `--- +name: write-script-snowflake +description: MUST use when writing Snowflake queries. --- ## CLI Commands -Place scripts in a folder. After writing, run: +Place scripts in a folder. After writing, tell the user they can run: - \`wmill script generate-metadata\` - Generate .script.yaml and .lock files - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + Use \`wmill resource-type list --schema\` to discover available resource types. -# TypeScript (Native) +# Snowflake -Native TypeScript execution with fetch only - no external imports allowed. +Arguments use \`?\` placeholders. -## Structure +Name the parameters by adding comments before the statement: -Export a single **async** function called \`main\`: - -\`\`\`typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} +\`\`\`sql +-- ? name1 (text) +-- ? name2 (number) = 0 +SELECT * FROM users WHERE name = ? AND age > ?; \`\`\` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the \`RT\` namespace for resource types: - -\`\`\`typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -\`\`\` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. - -## Imports - -**No imports allowed.** Use the globally available \`fetch\` function: - -\`\`\`typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -\`\`\` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: - -\`\`\`typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -\`\`\` - - -# TypeScript SDK (windmill-client) - -Import: import * as wmill from 'windmill-client' - -/** - * Create a SQL template function for PostgreSQL/datatable queries - * @param name - Database/datatable name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.datatable() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age}::int - * \`.fetch() - */ -datatable(name: string = "main"): DatatableSqlTemplateFunction - -/** - * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.ducklake() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age} - * \`.fetch() - */ -ducklake(name: string = "main"): SqlTemplateFunction - -/** - * Initialize the Windmill client with authentication token and base URL - * @param token - Authentication token (defaults to WM_TOKEN env variable) - * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable) - */ -setClient(token?: string, baseUrl?: string): void - -/** - * Create a client configuration from env variables - * @returns client configuration - */ -getWorkspace(): string - -/** - * Get a resource value by path - * @param path path of the resource, default to internal state path - * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error - * @returns resource value - */ -async getResource(path?: string, undefinedIfEmpty?: boolean): Promise - -/** - * Get the true root job id - * @param jobId job id to get the root job id from (default to current job) - * @returns root job id - */ -async getRootJobId(jobId?: string): Promise - -/** - * @deprecated Use runScriptByPath or runScriptByHash instead - */ -async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its path and wait for the result - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its hash and wait for the result - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Append a text to the result stream - * @param text text to append to the result stream - */ -appendToResultStream(text: string): void - -/** - * Stream to the result stream - * @param stream stream to stream to the result stream - */ -async streamResult(stream: AsyncIterable): Promise - -/** - * Run a flow synchronously by its path and wait for the result - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param verbose - Enable verbose logging - * @returns Flow execution result - */ -async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Wait for a job to complete and return its result - * @param jobId - ID of the job to wait for - * @param verbose - Enable verbose logging - * @returns Job result when completed - */ -async waitJob(jobId: string, verbose: boolean = false): Promise - -/** - * Get the result of a completed job - * @param jobId - ID of the completed job - * @returns Job result - */ -async getResult(jobId: string): Promise - -/** - * Get the result of a job if completed, or its current status - * @param jobId - ID of the job - * @returns Object with started, completed, success, and result properties - */ -async getResultMaybe(jobId: string): Promise - -/** - * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead - */ -async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its path - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its hash - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a flow asynchronously by its path - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) - * @returns Job ID of the created job - */ -async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise - -/** - * Resolve a resource value in case the default value was picked because the input payload was undefined - * @param obj resource value or path of the resource under the format \`$res:path\` - * @returns resource value - */ -async resolveDefaultResource(obj: any): Promise - -/** - * Get the state file path from environment variables - * @returns State path string - */ -getStatePath(): string - -/** - * Set a resource value by path - * @param path path of the resource to set, default to state path - * @param value new value of the resource to set - * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type - */ -async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise - -/** - * Set the state - * @param state state to set - * @deprecated use setState instead - */ -async setInternalState(state: any): Promise - -/** - * Set the state - * @param state state to set - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async setState(state: any, path?: string): Promise - -/** - * Set the progress - * Progress cannot go back and limited to 0% to 99% range - * @param percent Progress to set in % - * @param jobId? Job to set progress for - */ -async setProgress(percent: number, jobId?: any): Promise - -/** - * Get the progress - * @param jobId? Job to get progress from - * @returns Optional clamped between 0 and 100 progress value - */ -async getProgress(jobId?: any): Promise - -/** - * Set a flow user state - * @param key key of the state - * @param value value of the state - */ -async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise - -/** - * Get a flow user state - * @param path path of the variable - */ -async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise - -/** - * Get the internal state - * @deprecated use getState instead - */ -async getInternalState(): Promise - -/** - * Get the state shared across executions - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async getState(path?: string): Promise - -/** - * Get a variable by path - * @param path path of the variable - * @returns variable value - */ -async getVariable(path: string): Promise - -/** - * Set a variable by path, create if not exist - * @param path path of the variable - * @param value value of the variable - * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) - * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") - */ -async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise - -/** - * Build a PostgreSQL connection URL from a database resource - * @param path - Path to the database resource - * @returns PostgreSQL connection URL string - */ -async databaseUrlFromResource(path: string): Promise - -async polarsConnectionSettings(s3_resource_path: string | undefined): Promise - -async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise - -/** - * Get S3 client settings from a resource or workspace default - * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) - * @returns S3 client configuration settings - */ -async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise - -/** - * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContent = await wmill.loadS3FileContent(inputFile) - * // if the file is a raw text file, it can be decoded and printed directly: - * const text = new TextDecoder().decode(fileContentStream) - * console.log(text); - * \`\`\` - */ -async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContentBlob = await wmill.loadS3FileStream(inputFile) - * // if the content is plain text, the blob can be read directly: - * console.log(await fileContentBlob.text()); - * \`\`\` - */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise - -/** - * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * const s3object = await writeS3File(s3Object, "Hello Windmill!") - * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') - * console.log(fileContentAsUtf8Str) - * \`\`\` - */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise - -/** - * Sign S3 objects to be used by anonymous users in public apps - * @param s3objects s3 objects to sign - * @returns signed s3 objects - */ -async signS3Objects(s3objects: S3Object[]): Promise - -/** - * Sign S3 object to be used by anonymous users in public apps - * @param s3object s3 object to sign - * @returns signed s3 object - */ -async signS3Object(s3object: S3Object): Promise - -/** - * Generate a presigned public URL for an array of S3 objects. - * If an S3 object is not signed yet, it will be signed first. - * @param s3Objects s3 objects to sign - * @returns list of signed public URLs - */ -async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. - * @param s3Object s3 object to sign - * @returns signed public URL - */ -async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Get URLs needed for resuming a flow after this step - * @param approver approver name - * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. - * This allows pre-approvals that can be consumed by any later suspend step in the same flow. - * @returns approval page UI URL, resume and cancel API URLs for resuming the flow - */ -async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * @deprecated use getResumeUrls instead - */ -getResumeEndpoints(approver?: string): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) - * @param audience audience of the token - * @param expiresIn Optional number of seconds until the token expires - * @returns jwt token - */ -async getIdToken(audience: string, expiresIn?: number): Promise - -/** - * Convert a base64-encoded string to Uint8Array - * @param data - Base64-encoded string - * @returns Decoded Uint8Array - */ -base64ToUint8Array(data: string): Uint8Array - -/** - * Convert a Uint8Array to base64-encoded string - * @param arrayBuffer - Uint8Array to encode - * @returns Base64-encoded string - */ -uint8ArrayToBase64(arrayBuffer: Uint8Array): string - -/** - * Get email from workspace username - * This method is particularly useful for apps that require the email address of the viewer. - * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. - * @param username - * @returns email address - */ -async usernameToEmail(username: string): Promise - -/** - * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Slack approval request. - * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. - * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Slack approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * @param {string} [options.resumeButtonText] - Optional text for the resume button. - * @param {string} [options.cancelButtonText] - Optional text for the cancel button. - * - * @returns {Promise} Resolves when the Slack approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getSlackApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveSlackApproval({ - * slackResourcePath: "/u/alex/my_slack_resource", - * channelId: "admins-slack-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * resumeButtonText: "Resume", - * cancelButtonText: "Cancel", - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise - -/** - * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Teams approval request. - * @param {string} options.teamName - The Teams team name where the approval request will be sent. - * @param {string} options.channelName - The Teams channel name where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Teams approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * - * @returns {Promise} Resolves when the Teams approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getTeamsApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveTeamsApproval({ - * teamName: "admins-teams", - * channelName: "admins-teams-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise - -/** - * Parse an S3 object from URI string or record format - * @param s3Object - S3 object as URI string (s3://storage/key) or record - * @returns S3 object record with storage and s3 key - */ -parseS3Object(s3Object: S3Object): S3ObjectRecord - -setWorkflowCtx(ctx: WorkflowCtx | null): void - -async sleep(seconds: number): Promise - -async step(name: string, fn: () => T | Promise): Promise - -/** - * Create a task that dispatches to a separate Windmill script. - * - * @example - * const extract = taskScript("f/data/extract"); - * // inside workflow: await extract({ url: "https://..." }) - */ -taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Create a task that dispatches to a separate Windmill flow. - * - * @example - * const pipeline = taskFlow("f/etl/pipeline"); - * // inside workflow: await pipeline({ input: data }) - */ -taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Mark an async function as a workflow-as-code entry point. - * - * The function must be **deterministic**: given the same inputs it must call - * tasks in the same order on every replay. Branching on task results is fine - * (results are replayed from checkpoint), but branching on external state - * (current time, random values, external API calls) must use \`step()\` to - * checkpoint the value so replays see the same result. - */ -workflow(fn: (...args: any[]) => Promise): void - -/** - * Suspend the workflow and wait for an external approval. - * - * Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage - * URLs before calling this function. - * - * @example - * const urls = await step("urls", () => getResumeUrls()); - * await step("notify", () => sendEmail(urls.approvalPage)); - * const { value, approver } = await waitForApproval({ timeout: 3600 }); - */ -waitForApproval(options?: { timeout?: number; form?: object; }): PromiseLike<{ value: any; approver: string; approved: boolean }> - -/** - * Process items in parallel with optional concurrency control. - * - * Each item is processed by calling \`fn(item)\`, which should be a task(). - * Items are dispatched in batches of \`concurrency\` (default: all at once). - * - * @example - * const process = task(async (item: string) => { ... }); - * const results = await parallel(items, process, { concurrency: 5 }); - */ -async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise `, "write-flow": `--- name: write-flow @@ -4153,12 +4236,14 @@ description: MUST use when creating flows. ## CLI Commands -Create a folder ending with \`.flow\` and add a YAML file with the flow definition. -For rawscript modules, use \`!inline path/to/script.ts\` for the content key. -After writing: -- \`wmill flow generate-locks --yes\` - Generate lock files for the specific flow you modified (e.g. \`wmill flow generate-locks f/my_folder/my_flow.flow --yes\`) +Create a folder ending with \`{{FLOW_SUFFIX}}\` and add a \`flow.yaml\` file with the flow definition. +For rawscript modules, use \`!inline path/to/script.ts\` for the content key. {{INLINE_SCRIPT_NAMING}} +After writing, tell the user they can run: +- \`wmill flow generate-locks --yes\` - Generate lock files for the specific flow you modified (e.g. \`wmill flow generate-locks f/my_folder/my_flow{{FLOW_SUFFIX}} --yes\`) - \`wmill sync push\` - Deploy to Windmill +Do NOT run these commands yourself. Instead, inform the user that they should run them. + ## OpenFlow Schema The OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions. @@ -4290,7 +4375,7 @@ This interactive command creates a complete app structure with your choice of fr ## App Structure \`\`\` -my_app.raw_app/ +my_app{{RAW_APP_SUFFIX}}/ ├── AGENTS.md # AI agent instructions (auto-generated) ├── DATATABLES.md # Database schemas (run 'wmill app generate-agents' to refresh) ├── raw_app.yaml # App configuration (summary, path, data settings) @@ -4356,7 +4441,7 @@ export async function main(user_id: string) { } \`\`\` -After creating, generate lock files: +After creating, tell the user they can generate lock files by running: \`\`\`bash wmill app generate-locks \`\`\` @@ -4509,6 +4594,8 @@ data: ## CLI Commands +Tell the user they can run these commands (do NOT run them yourself): + | Command | Description | |---------|-------------| | \`wmill app new\` | Create a new raw app interactively | @@ -4525,7 +4612,7 @@ data: 3. **Keep runnables focused** - one function per file 4. **Use descriptive IDs** - \`get_user.ts\` not \`a.ts\` 5. **Always whitelist tables** - add to \`data.tables\` before querying -6. **Generate locks** - run \`wmill app generate-locks\` after adding/modifying backend runnables +6. **Generate locks** - tell the user to run \`wmill app generate-locks\` after adding/modifying backend runnables `, "triggers": `--- name: triggers @@ -4547,6 +4634,8 @@ Examples: ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + \`\`\`bash # Push trigger configuration wmill sync push @@ -4596,6 +4685,8 @@ Windmill uses 6-field cron expressions (includes seconds): ## CLI Commands +After writing, tell the user they can run these commands (do NOT run them yourself): + \`\`\`bash # Push schedules to Windmill wmill sync push @@ -4851,7 +4942,7 @@ wmill resource-type list --schema # Get specific resource type schema wmill resource-type get postgresql -# Push resources +# Push resources (tell the user to run this, do NOT run it yourself) wmill sync push \`\`\` `, @@ -4864,8 +4955,6 @@ description: MUST use when using the CLI. The Windmill CLI (\`wmill\`) provides commands for managing scripts, flows, apps, and other resources. -Current version: 1.651.1 - ## Global Options - \`--workspace \` - Specify the target workspace. This overrides the default workspace. @@ -4983,6 +5072,23 @@ folder related commands - \`folder add-missing\` - create default folder.meta.yaml for all subdirectories of f/ that are missing one - \`-y, --yes\` - skip confirmation prompt +### generate-metadata + +Generate metadata (locks, schemas) for all scripts, flows, and apps + +**Arguments:** \`[folder:string]\` + +**Options:** +- \`--yes\` - Skip confirmation prompt +- \`--dry-run\` - Show what would be updated without making changes +- \`--lock-only\` - Re-generate only the lock files +- \`--schema-only\` - Re-generate only script schemas (skips flows and apps) +- \`--skip-scripts\` - Skip processing scripts +- \`--skip-flows\` - Skip processing flows +- \`--skip-apps\` - Skip processing apps +- \`-i --includes \` - Comma separated patterns to specify which files to include +- \`-e --excludes \` - Comma separated patterns to specify which files to exclude + ### gitsync-settings Manage git-sync settings between local wmill.yaml and Windmill backend @@ -5613,6 +5719,18 @@ properties: key: type: string value: {} + auto_offset_reset: + type: string + enum: + - latest + - earliest + description: Initial offset behavior when consumer group has no committed offset. + 'latest' starts from new messages only, 'earliest' starts from the beginning. + auto_commit: + type: boolean + description: When true (default), offsets are committed automatically after receiving + each message. When false, you must manually commit offsets using the commit_offsets + endpoint. error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails diff --git a/cli/src/main.ts b/cli/src/main.ts index 73e1aeb8cb..01d4040f4f 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -39,6 +39,7 @@ import queues from "./commands/queues/queues.ts"; import dependencies from "./commands/dependencies/dependencies.ts"; import init from "./commands/init/init.ts"; import jobs from "./commands/jobs/jobs.ts"; +import generateMetadata from "./commands/generate-metadata/generate-metadata.ts"; import docs from "./commands/docs/docs.ts"; import { fetchVersion } from "./core/context.ts"; @@ -67,7 +68,7 @@ export { workspaceAdd, }; -export const VERSION = "1.654.0"; +export const VERSION = "1.658.0"; // Re-exported from constants.ts to maintain backwards compatibility export { WM_FORK_PREFIX } from "./core/constants.ts"; @@ -129,6 +130,7 @@ const command = new Command() .command("queues", queues) .command("dependencies", dependencies) .command("jobs", jobs) + .command("generate-metadata", generateMetadata) .command("docs", docs) .command("version --version", "Show version information") .action(async (opts: any) => { diff --git a/cli/src/types.ts b/cli/src/types.ts index 69592664f8..8ba36a0ea0 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -28,6 +28,7 @@ import { isRawAppPath, extractResourceName, buildFolderPath, + isScriptModulePath, } from "./utils/resource_folders.ts"; export interface DifferenceCreate { @@ -259,6 +260,9 @@ export function getTypeStrFromPath( | "settings" | "encryption_key" | "workspace_dependencies" { + if (isScriptModulePath(p)) { + return "script"; + } if (isFlowPath(p)) { return "flow"; } diff --git a/cli/src/utils/metadata.ts b/cli/src/utils/metadata.ts index 590c3d7d8e..f211e3dc71 100644 --- a/cli/src/utils/metadata.ts +++ b/cli/src/utils/metadata.ts @@ -5,7 +5,8 @@ import * as log from "../core/log.ts"; import { stringify as yamlStringify } from "yaml"; import { yamlParseFile } from "./yaml.ts"; import { readFile, writeFile, stat, rm, readdir } from "node:fs/promises"; -import { readFileSync } from "node:fs"; +import { readFileSync, existsSync, readdirSync, statSync, mkdirSync, writeFileSync } from "node:fs"; +import * as path from "node:path"; import { createRequire } from "node:module"; import { ScriptMetadata, @@ -15,8 +16,10 @@ import { Workspace } from "../commands/workspace/workspace.ts"; import { ScriptLanguage, workspaceDependenciesLanguages, + languageNeedsLock, } from "./script_common.ts"; import { inferContentTypeFromFilePath } from "./script_common.ts"; +import { getModuleFolderSuffix, isModuleEntryPoint, getScriptBasePathFromModulePath } from "./resource_folders.ts"; import { findCodebase, yamlOptions } from "../commands/sync/sync.ts"; import { generateHash, readInlinePathSync, getHeaders } from "./utils.ts"; @@ -35,7 +38,7 @@ function loadParser(pkgName: string): Promise { const wasmPath = _require.resolve( `${pkgName}/windmill_parser_wasm_bg.wasm` ); - await mod.default(readFileSync(wasmPath)); + await mod.default({ module_or_path: readFileSync(wasmPath) }); return mod; })(); _parserCache.set(pkgName, p); @@ -50,26 +53,25 @@ export class LockfileGenerationError extends Error { } } -export async function generateAllMetadata() {} export async function getRawWorkspaceDependencies(): Promise> { const rawWorkspaceDeps: Record = {}; - + try { const entries = await readdir("dependencies", { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) continue; - + const filePath = `dependencies/${entry.name}`; const content = await readFile(filePath, "utf-8"); - + // Find matching language for (const lang of workspaceDependenciesLanguages) { if (entry.name.endsWith(lang.filename)) { // Check if out of sync const contentHash = await generateHash(content + filePath); const isUpToDate = await checkifMetadataUptodate(filePath, contentHash, undefined); - + if (!isUpToDate) { rawWorkspaceDeps[filePath] = content; } @@ -186,13 +188,18 @@ export async function generateScriptMetadataInternal( codebases: SyncCodebase[], justUpdateMetadataLock?: boolean ): Promise { - const remotePath = scriptPath - .substring(0, scriptPath.indexOf(".")) - .replaceAll(SEP, "/"); + // Detect folder layout: my_script__mod/script.ts + const isFolderLayout = isModuleEntryPoint(scriptPath); + + // remotePath is the Windmill API path (e.g., "u/admin/my_script") + const remotePath = isFolderLayout + ? getScriptBasePathFromModulePath(scriptPath)!.replaceAll(SEP, "/") + : scriptPath.substring(0, scriptPath.indexOf(".")).replaceAll(SEP, "/"); const language = inferContentTypeFromFilePath(scriptPath, opts.defaultTs); - + // For folder layout, parseMetadataFile is called with remotePath which + // will find __mod/script.yaml via the folder layout fallback const metadataWithType = await parseMetadataFile( remotePath, undefined, @@ -208,11 +215,35 @@ export async function generateScriptMetadataInternal( language ); + // Compute the module folder path early so we can include module hashes in stale check + const moduleFolderPath = isFolderLayout + ? path.dirname(scriptPath) + : scriptPath.substring(0, scriptPath.indexOf(".")) + getModuleFolderSuffix(); + + const hasModules = existsSync(moduleFolderPath) && statSync(moduleFolderPath).isDirectory(); - // Note: rawWorkspaceDependencies are now passed in as parameter instead of being searched hierarchically let hash = await generateScriptHash(filteredRawWorkspaceDependencies, scriptContent, metadataContent); - if (await checkifMetadataUptodate(remotePath, hash, undefined)) { + // Compute per-module hashes for stale detection (like flow inline scripts) + let moduleHashes: Record = {}; + if (hasModules) { + moduleHashes = await computeModuleHashes( + moduleFolderPath, opts.defaultTs, rawWorkspaceDependencies, isFolderLayout + ); + } + const hasModuleHashes = Object.keys(moduleHashes).length > 0; + + // If modules exist, combine main script hash + module hashes into a meta-hash + let checkHash = hash; + let checkSubpath: string | undefined; + if (hasModuleHashes) { + const sortedEntries = Object.entries(moduleHashes).sort(([a], [b]) => a.localeCompare(b)); + checkHash = await generateHash(hash + JSON.stringify(sortedEntries)); + checkSubpath = SCRIPT_TOP_HASH; + } + + const conf = await readLockfile(); + if (await checkifMetadataUptodate(remotePath, checkHash, conf, checkSubpath)) { if (!noStaleMessage) { log.info( colors.green(`Script ${remotePath} metadata is up-to-date, skipping`) @@ -220,10 +251,22 @@ export async function generateScriptMetadataInternal( } return; } else if (dryRun) { - return `${remotePath} (${language})`; + let detail = `${remotePath} (${language})`; + if (hasModuleHashes) { + const changed: string[] = []; + for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) { + if (!(await checkifMetadataUptodate(remotePath, moduleHash, conf, modulePath))) { + changed.push(modulePath); + } + } + if (changed.length > 0) { + detail += ` [changed modules: ${changed.join(", ")}]`; + } + } + return detail; } - if (!justUpdateMetadataLock) { + if (!justUpdateMetadataLock && !noStaleMessage) { log.info(colors.gray(`Generating metadata for ${scriptPath}`)); } @@ -245,26 +288,71 @@ export async function generateScriptMetadataInternal( const hasCodebase = findCodebase(scriptPath, codebases) != undefined; if (!hasCodebase) { + const lockPathOverride = isFolderLayout + ? path.dirname(scriptPath) + "/script.lock" + : undefined; await updateScriptLock( workspace, scriptContent, language, remotePath, metadataParsedContent, - filteredRawWorkspaceDependencies + filteredRawWorkspaceDependencies, + lockPathOverride, ); } else { metadataParsedContent.lock = ""; } + + // Generate locks for modules in __mod/ folder + if (hasModules) { + // Identify which modules changed by comparing per-module hashes + let changedModules: string[] | undefined; + if (hasModuleHashes) { + changedModules = []; + for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) { + if (!(await checkifMetadataUptodate(remotePath, moduleHash, conf, modulePath))) { + changedModules.push(modulePath); + } + } + if (changedModules.length === 0) { + changedModules = undefined; // no modules changed, skip lock regeneration + } + } + await updateModuleLocks( + workspace, moduleFolderPath, "", remotePath, + rawWorkspaceDependencies, opts.defaultTs, changedModules, + ); + } } else { - metadataParsedContent.lock = - "!inline " + remotePath.replaceAll(SEP, "/") + ".script.lock"; + if (isFolderLayout) { + metadataParsedContent.lock = + "!inline " + remotePath.replaceAll(SEP, "/") + getModuleFolderSuffix() + "/script.lock"; + } else { + metadataParsedContent.lock = + "!inline " + remotePath.replaceAll(SEP, "/") + ".script.lock"; + } } - let metaPath = remotePath + ".script.yaml"; - let newMetadataContent = yamlStringify(metadataParsedContent, yamlOptions); - if (metadataWithType.isJson) { - metaPath = remotePath + ".script.json"; - newMetadataContent = JSON.stringify(metadataParsedContent); + + // Write metadata back to the correct path + let metaPath: string; + let newMetadataContent: string; + if (isFolderLayout) { + if (metadataWithType.isJson) { + metaPath = path.dirname(scriptPath) + "/script.json"; + newMetadataContent = JSON.stringify(metadataParsedContent); + } else { + metaPath = path.dirname(scriptPath) + "/script.yaml"; + newMetadataContent = yamlStringify(metadataParsedContent, yamlOptions); + } + } else { + if (metadataWithType.isJson) { + metaPath = remotePath + ".script.json"; + newMetadataContent = JSON.stringify(metadataParsedContent); + } else { + metaPath = remotePath + ".script.yaml"; + newMetadataContent = yamlStringify(metadataParsedContent, yamlOptions); + } } const metadataContentUsedForHash = newMetadataContent; @@ -274,7 +362,21 @@ export async function generateScriptMetadataInternal( scriptContent, metadataContentUsedForHash ); - await updateMetadataGlobalLock(remotePath, hash); + + // Store hashes in wmill-lock.yaml + if (hasModuleHashes) { + // Use per-module hash tracking (like flow inline scripts) + const sortedEntries = Object.entries(moduleHashes).sort(([a], [b]) => a.localeCompare(b)); + const metaHash = await generateHash(hash + JSON.stringify(sortedEntries)); + await clearGlobalLock(remotePath); + await updateMetadataGlobalLock(remotePath, metaHash, SCRIPT_TOP_HASH); + for (const [modulePath, moduleHash] of Object.entries(moduleHashes)) { + await updateMetadataGlobalLock(remotePath, moduleHash, modulePath); + } + } else { + await updateMetadataGlobalLock(remotePath, hash); + } + if (!justUpdateMetadataLock) { await writeFile(metaPath, newMetadataContent, "utf-8"); } @@ -300,11 +402,9 @@ export async function updateScriptSchema( } else { delete metadataContent.has_preprocessor; } - if (result.no_main_func) { - metadataContent.no_main_func = result.no_main_func; - } else { - delete metadataContent.no_main_func; - } + // auto_kind is intentionally not written to metadata — it is auto-detected + // by the parser at deploy time from script content. + delete metadataContent.auto_kind; } // --------------------------------------------------------------------------- @@ -329,6 +429,7 @@ const LANG_ANNOTATION_CONFIG: Partial< nativets: { comment: "//", keyword: "package_json" }, go: { comment: "//", keyword: "go_mod" }, php: { comment: "//", keyword: "composer_json" }, + powershell: { comment: "#", keyword: "modules_json" }, }; export function extractWorkspaceDepsAnnotation( @@ -502,11 +603,13 @@ async function updateScriptLock( language: ScriptLanguage, remotePath: string, metadataContent: Record, - rawWorkspaceDependencies: Record + rawWorkspaceDependencies: Record, + lockPathOverride?: string, ): Promise { if ( !( - workspaceDependenciesLanguages.some((l) => l.language == language) || + (workspaceDependenciesLanguages.some((l) => l.language == language) && + language !== "powershell") || language == "deno" || language == "rust" || language == "ansible" @@ -529,7 +632,7 @@ async function updateScriptLock( rawWorkspaceDependencies, ); - const lockPath = remotePath + ".script.lock"; + const lockPath = lockPathOverride ?? remotePath + ".script.lock"; if (lock != "") { await writeFile(lockPath, lock, "utf-8"); metadataContent.lock = "!inline " + lockPath.replaceAll(SEP, "/"); @@ -545,6 +648,82 @@ async function updateScriptLock( } } +/** + * Generate locks for all module files in a __mod/ directory. + * Recursively walks the directory and generates a lock for each module + * whose language requires one. + */ +async function updateModuleLocks( + workspace: Workspace, + dirPath: string, + relPrefix: string, + scriptRemotePath: string, + rawWorkspaceDependencies: Record, + defaultTs: "bun" | "deno" | undefined, + changedModules?: string[], +): Promise { + const entries = readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name; + + if (entry.isDirectory()) { + await updateModuleLocks(workspace, fullPath, relPath, scriptRemotePath, rawWorkspaceDependencies, defaultTs, changedModules); + } else if (entry.isFile() + && !entry.name.endsWith(".lock") + // In folder layout, skip entry point files (script.{ext}, script.yaml, script.json, script.lock) + && !(relPrefix === "" && entry.name.startsWith("script.")) + ) { + let modLanguage: ScriptLanguage; + try { + modLanguage = inferContentTypeFromFilePath(entry.name, defaultTs); + } catch { + continue; // skip files with unrecognized extensions + } + + if (!languageNeedsLock(modLanguage)) continue; + + // Skip unchanged modules when per-module hash tracking is active + if (changedModules) { + const normalizedRelPath = normalizeLockPath(relPath); + if (!changedModules.includes(normalizedRelPath)) continue; + } + + const moduleContent = readFileSync(fullPath, "utf-8"); + const moduleRemotePath = scriptRemotePath + "/" + relPath; + + log.info(colors.gray(`Generating lock for module ${relPath}`)); + + try { + const lock = await fetchScriptLock( + workspace, + moduleContent, + modLanguage, + moduleRemotePath, + rawWorkspaceDependencies, + ); + + const baseName = entry.name.replace(/\.[^.]+$/, ''); + const lockPath = path.join(dirPath, baseName + ".lock"); + if (lock != "") { + writeFileSync(lockPath, lock, "utf-8"); + } else { + try { + if (existsSync(lockPath)) { + const { rm: rmAsync } = await import("node:fs/promises"); + await rmAsync(lockPath); + } + } catch { + // ignore + } + } + } catch (e) { + log.info(colors.yellow(`Failed to generate lock for module ${relPath}: ${e}`)); + } + } + } +} + //////////////////////////////////////////////////////////////////////////////////////////// // below functions copied from Windmill's FE inferArgs function. TODO: refactor // //////////////////////////////////////////////////////////////////////////////////////////// @@ -556,7 +735,7 @@ export async function inferSchema( ): Promise<{ schema: any; has_preprocessor: boolean | undefined; - no_main_func: boolean | undefined; + auto_kind: string | undefined; }> { let inferedSchema: any; if (language === "python3") { @@ -666,7 +845,7 @@ export async function inferSchema( return { schema: defaultScriptMetadata().schema, has_preprocessor: false, - no_main_func: false, + auto_kind: undefined, }; } @@ -691,6 +870,11 @@ export async function inferSchema( argSigToJsonSchemaType(arg.typ, currentSchema.properties[arg.name]); + // For T | T[] detection for debouncing arg accumulation + if ((arg as any).otyp && (arg as any).otyp.includes('[') && (arg as any).otyp.includes('|')) { + currentSchema.properties[arg.name].originalType = (arg as any).otyp + } + currentSchema.properties[arg.name].default = arg.default; if (!arg.has_default && !currentSchema.required.includes(arg.name)) { @@ -701,7 +885,7 @@ export async function inferSchema( return { schema: currentSchema, has_preprocessor: inferedSchema.has_preprocessor, - no_main_func: inferedSchema.no_main_func, + auto_kind: inferedSchema.auto_kind, }; } @@ -770,62 +954,87 @@ export async function parseMetadataFile( isJson: false, }; } catch { - // no metadata file at all. Create it - log.info( - (await blueColor())( - `Creating script metadata file for ${metadataFilePath}` - ) - ); - metadataFilePath = scriptPath + ".script.yaml"; - let scriptInitialMetadata = defaultScriptMetadata(); - const lockPath = scriptPath + ".script.lock"; - scriptInitialMetadata.lock = "!inline " + lockPath; - const scriptInitialMetadataYaml = yamlStringify( - scriptInitialMetadata as Record, - yamlOptions - ); - - await writeFile(metadataFilePath, scriptInitialMetadataYaml, { flag: "wx", encoding: "utf-8" }); - await writeFile(lockPath, "", { flag: "wx", encoding: "utf-8" }); - - if (generateMetadataIfMissing) { - log.info( - (await blueColor())( - `Generating lockfile and schema for ${metadataFilePath}` - ) - ); + // Try folder layout: {scriptPath}__mod/script.yaml or .json + const moduleFolderMeta = scriptPath + getModuleFolderSuffix(); + try { + metadataFilePath = moduleFolderMeta + "/script.json"; + await stat(metadataFilePath); + return { + path: metadataFilePath, + payload: JSON.parse(await readFile(metadataFilePath, "utf-8")), + isJson: true, + }; + } catch { try { - await generateScriptMetadataInternal( - generateMetadataIfMissing.path, - generateMetadataIfMissing.workspaceRemote, - generateMetadataIfMissing, - false, - false, - generateMetadataIfMissing.rawWorkspaceDependencies, - generateMetadataIfMissing.codebases, - false - ); - scriptInitialMetadata = (await yamlParseFile( - metadataFilePath - )) as ScriptMetadata; - if (!generateMetadataIfMissing.schemaOnly) { - replaceLock(scriptInitialMetadata); - } - } catch (e) { - log.info( - colors.yellow( - `Failed to generate lockfile and schema for ${metadataFilePath}: ${e}` - ) - ); + metadataFilePath = moduleFolderMeta + "/script.yaml"; + await stat(metadataFilePath); + const payload: any = await yamlParseFile(metadataFilePath); + replaceLock(payload); + return { + path: metadataFilePath, + payload, + isJson: false, + }; + } catch { + // fall through to create metadata } } - return { - path: metadataFilePath, - payload: scriptInitialMetadata, - isJson: false, - }; } } + // no metadata file at all. Create it + log.info( + (await blueColor())( + `Creating script metadata file for ${metadataFilePath}` + ) + ); + metadataFilePath = scriptPath + ".script.yaml"; + let scriptInitialMetadata = defaultScriptMetadata(); + const lockPath = scriptPath + ".script.lock"; + scriptInitialMetadata.lock = "!inline " + lockPath; + const scriptInitialMetadataYaml = yamlStringify( + scriptInitialMetadata as Record, + yamlOptions + ); + + await writeFile(metadataFilePath, scriptInitialMetadataYaml, { flag: "wx", encoding: "utf-8" }); + await writeFile(lockPath, "", { flag: "wx", encoding: "utf-8" }); + + if (generateMetadataIfMissing) { + log.info( + (await blueColor())( + `Generating lockfile and schema for ${metadataFilePath}` + ) + ); + try { + await generateScriptMetadataInternal( + generateMetadataIfMissing.path, + generateMetadataIfMissing.workspaceRemote, + generateMetadataIfMissing, + false, + false, + generateMetadataIfMissing.rawWorkspaceDependencies, + generateMetadataIfMissing.codebases, + false + ); + scriptInitialMetadata = (await yamlParseFile( + metadataFilePath + )) as ScriptMetadata; + if (!generateMetadataIfMissing.schemaOnly) { + replaceLock(scriptInitialMetadata); + } + } catch (e) { + log.info( + colors.yellow( + `Failed to generate lockfile and schema for ${metadataFilePath}: ${e}` + ) + ); + } + } + return { + path: metadataFilePath, + payload: scriptInitialMetadata, + isJson: false, + }; } interface Lock { @@ -834,6 +1043,7 @@ interface Lock { } const WMILL_LOCKFILE = "wmill-lock.yaml"; +const SCRIPT_TOP_HASH = "__script_hash"; /** * Normalizes a path to use Linux separators (forward slashes). @@ -902,6 +1112,46 @@ export async function generateScriptHash( ); } +async function computeModuleHashes( + moduleFolderPath: string, + defaultTs: "bun" | "deno" | undefined, + rawWorkspaceDependencies: Record, + isFolderLayout: boolean, +): Promise> { + const hashes: Record = {}; + + async function readDir(dirPath: string, relPrefix: string) { + const entries = readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + const relPath = relPrefix ? relPrefix + "/" + entry.name : entry.name; + const isTopLevel = relPrefix === ""; + + if (entry.isDirectory()) { + await readDir(fullPath, relPath); + } else if ( + entry.isFile() && + !entry.name.endsWith(".lock") && + !(isFolderLayout && isTopLevel && entry.name.startsWith("script.")) + ) { + try { + inferContentTypeFromFilePath(entry.name, defaultTs); + } catch { + continue; + } + const content = readFileSync(fullPath, "utf-8"); + const normalizedPath = normalizeLockPath(relPath); + hashes[normalizedPath] = await generateHash( + content + JSON.stringify(rawWorkspaceDependencies) + ); + } + } + } + + await readDir(moduleFolderPath, ""); + return hashes; +} + export async function clearGlobalLock(path: string): Promise { const conf = await readLockfile(); if (!conf?.locks) { diff --git a/cli/src/utils/resource_folders.ts b/cli/src/utils/resource_folders.ts index e24835ab35..713a47ecb1 100644 --- a/cli/src/utils/resource_folders.ts +++ b/cli/src/utils/resource_folders.ts @@ -433,6 +433,66 @@ export function isRawAppFolderMetadataFile(p: string): boolean { ); } +// ============================================================================ +// Script Module Path Functions +// ============================================================================ + +/** + * The suffix used for script module folders. + * Unlike flows/apps, modules always use `__mod` (never dotted `.mod`) + * to avoid confusion with file extensions. + */ +const MODULE_SUFFIX = "__mod"; + +/** + * Get the module folder suffix (always "__mod") + */ +export function getModuleFolderSuffix(): string { + return MODULE_SUFFIX; +} + +/** + * Check if a path is inside a script module folder. + * Matches patterns like: .../my_script__mod/... + */ +export function isScriptModulePath(p: string): boolean { + return normalizeSep(p).includes(MODULE_SUFFIX + "/"); +} + +/** + * Build the module folder path from a script's base path (without extension). + * e.g., "f/my_script" -> "f/my_script__mod" + */ +export function buildModuleFolderPath(scriptBasePath: string): string { + return scriptBasePath + MODULE_SUFFIX; +} + +/** + * Check if a file inside a __mod/ folder is the main entry point (script.{ext}). + * Entry points are files named "script.*" directly under __mod/ (not in subdirs). + */ +export function isModuleEntryPoint(p: string): boolean { + const norm = normalizeSep(p); + const suffix = MODULE_SUFFIX + "/"; + const idx = norm.indexOf(suffix); + if (idx === -1) return false; + const rest = norm.slice(idx + suffix.length); + return rest.startsWith("script.") && !rest.includes("/"); +} + +/** + * Extract the script base path from a module folder entry. + * e.g., "u/admin/my_script__mod/script.ts" -> "u/admin/my_script" + * e.g., "u/admin/my_script__mod/helper.ts" -> "u/admin/my_script" + */ +export function getScriptBasePathFromModulePath(p: string): string | undefined { + const norm = normalizeSep(p); + const suffix = MODULE_SUFFIX + "/"; + const idx = norm.indexOf(suffix); + if (idx === -1) return undefined; + return norm.slice(0, idx); +} + // ============================================================================ // Sync-related Path Functions // ============================================================================ diff --git a/cli/src/utils/script_common.ts b/cli/src/utils/script_common.ts index 558a83bda6..7f126b6b85 100644 --- a/cli/src/utils/script_common.ts +++ b/cli/src/utils/script_common.ts @@ -30,13 +30,15 @@ export type WorkspaceDependenciesLanguage = | { language: "bun", filename /** (raw requirements filename) */: "package.json" } | { language: "python3", filename: "requirements.in" } | { language: "php", filename: "composer.json" } - | { language: "go", filename: "go.mod" }; + | { language: "go", filename: "go.mod" } + | { language: "powershell", filename: "modules.json" }; export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [ { language: "bun", filename: "package.json" }, { language: "python3", filename: "requirements.in" }, { language: "php", filename: "composer.json" }, { language: "go", filename: "go.mod" }, + { language: "powershell", filename: "modules.json" }, ] as const; /** @@ -45,7 +47,8 @@ export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [ */ export function languageNeedsLock(language: ScriptLanguage | string): boolean { return ( - workspaceDependenciesLanguages.some((l) => l.language === language) || + (workspaceDependenciesLanguages.some((l) => l.language === language) && + language !== "powershell") || language === "deno" || language === "rust" || language === "ansible" diff --git a/cli/test/datatable_settings_sync.test.ts b/cli/test/datatable_settings_sync.test.ts new file mode 100644 index 0000000000..387dd3eaf2 --- /dev/null +++ b/cli/test/datatable_settings_sync.test.ts @@ -0,0 +1,226 @@ +/** + * Datatable settings sync tests + * + * Tests that datatable config is correctly synced via settings.yaml during pull/push operations. + */ + +import { expect, test } from "bun:test"; +import { writeFile, readFile } from "node:fs/promises"; +import { parse, stringify } from "yaml"; +import { withTestBackend } from "./test_backend.ts"; +import { shouldSkipOnCI } from "./cargo_backend.ts"; +import { addWorkspace } from "../workspace.ts"; + +test.skipIf(shouldSkipOnCI())("Datatable config: included in sync pull settings.yaml", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "datatable_pull_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Configure datatable on backend + const datatableConfig = { + datatables: { + main: { + database: { + resource_path: "u/test/test_db", + resource_type: "postgresql" + } + } + } + }; + + const configResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/edit_datatable_config`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: datatableConfig }) + } + ); + expect(configResp.ok).toBe(true); + + // Create wmill.yaml with includeSettings + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: true`, "utf-8"); + + // Pull settings + const result = await backend.runCLICommand(['sync', 'pull', '--yes'], tempDir); + expect(result.code).toEqual(0); + + // Verify settings.yaml was created and contains datatable + const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); + expect(settingsContent).toContain("datatable:"); + expect(settingsContent).toContain("u/test/test_db"); + expect(settingsContent).toContain("postgresql"); + }); +}); + +test.skipIf(shouldSkipOnCI())("Datatable config: pushed correctly from settings.yaml", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "datatable_push_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Create wmill.yaml with includeSettings + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: true`, "utf-8"); + + // First pull to get baseline settings + const pullResult = await backend.runCLICommand(['sync', 'pull', '--yes'], tempDir); + expect(pullResult.code).toEqual(0); + + // Read existing settings and add datatable config + const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); + const existingSettings = parse(settingsContent) as Record; + + existingSettings.datatable = { + datatables: { + analytics: { + database: { + resource_path: "u/admin/analytics_db", + resource_type: "postgresql" + } + } + } + }; + + await writeFile(`${tempDir}/settings.yaml`, stringify(existingSettings), "utf-8"); + + // Push the modified settings + const pushResult = await backend.runCLICommand(['sync', 'push', '--yes'], tempDir); + expect(pushResult.code).toEqual(0); + + // Verify the backend has the updated datatable config + const settingsResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/get_settings` + ); + expect(settingsResp.ok).toBe(true); + const settings = await settingsResp.json(); + + expect(settings.datatable).toBeDefined(); + expect(settings.datatable.datatables).toBeDefined(); + expect(settings.datatable.datatables.analytics).toBeDefined(); + expect(settings.datatable.datatables.analytics.database.resource_path).toEqual("u/admin/analytics_db"); + expect(settings.datatable.datatables.analytics.database.resource_type).toEqual("postgresql"); + }); +}); + +test.skipIf(shouldSkipOnCI())("Datatable config: empty/undefined doesn't cause errors", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "datatable_empty_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Ensure datatable config is empty/cleared on backend + const clearResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/edit_datatable_config`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: { datatables: {} } }) + } + ); + expect(clearResp.ok).toBe(true); + + // Create wmill.yaml with includeSettings + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: true`, "utf-8"); + + // Pull should succeed even with empty datatable config + const pullResult = await backend.runCLICommand(['sync', 'pull', '--yes'], tempDir); + expect(pullResult.code).toEqual(0); + + // Settings.yaml should exist + const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); + expect(settingsContent.length).toBeGreaterThan(0); + + // Push should also succeed + const pushResult = await backend.runCLICommand(['sync', 'push', '--yes'], tempDir); + expect(pushResult.code).toEqual(0); + }); +}); + +test.skipIf(shouldSkipOnCI())("Datatable config: round-trip preserves structure", async () => { + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "datatable_roundtrip_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + // Set up a complex datatable config on backend with multiple datatables + const originalConfig = { + datatables: { + users: { + database: { + resource_path: "f/shared/users_db", + resource_type: "postgresql" + } + }, + logs: { + database: { + resource_path: "f/shared/logs_db", + resource_type: "instance" + } + } + } + }; + + const configResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/edit_datatable_config`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ settings: originalConfig }) + } + ); + expect(configResp.ok).toBe(true); + + // Create wmill.yaml with includeSettings + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +includeSettings: true`, "utf-8"); + + // Pull + const pullResult = await backend.runCLICommand(['sync', 'pull', '--yes'], tempDir); + expect(pullResult.code).toEqual(0); + + // Push back without modification + const pushResult = await backend.runCLICommand(['sync', 'push', '--yes'], tempDir); + expect(pushResult.code).toEqual(0); + + // Verify the config is preserved + const settingsResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/workspaces/get_settings` + ); + expect(settingsResp.ok).toBe(true); + const settings = await settingsResp.json(); + + expect(settings.datatable).toBeDefined(); + expect(settings.datatable.datatables.users).toBeDefined(); + expect(settings.datatable.datatables.logs).toBeDefined(); + expect(settings.datatable.datatables.users.database.resource_path).toEqual("f/shared/users_db"); + expect(settings.datatable.datatables.logs.database.resource_type).toEqual("instance"); + }); +}); diff --git a/cli/test/inline_scripts_failure_preprocessor.test.ts b/cli/test/inline_scripts_failure_preprocessor.test.ts new file mode 100644 index 0000000000..1a9150a257 --- /dev/null +++ b/cli/test/inline_scripts_failure_preprocessor.test.ts @@ -0,0 +1,498 @@ +/** + * Unit tests for failure_module and preprocessor_module inline script + * extraction (pull) and replacement (push). + * + * These tests verify that rawscript content in failure_module and + * preprocessor_module is correctly extracted to !inline references + * and resolved back, matching the existing behavior for regular modules. + */ + +import { expect, test, describe } from "bun:test"; +import { extractInlineScripts, extractCurrentMapping } from "../windmill-utils-internal/src/inline-scripts/extractor.ts"; +import { replaceInlineScripts } from "../windmill-utils-internal/src/inline-scripts/replacer.ts"; +import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-assigner.ts"; +import type { FlowModule } from "../windmill-utils-internal/src/gen/types.gen.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeRawscriptModule( + id: string, + content: string, + language: "bun" | "python3" | "deno" = "bun", + lock?: string, +): FlowModule { + return { + id, + value: { + type: "rawscript" as const, + content, + language, + lock: lock, + input_transforms: {}, + }, + }; +} + +const noopLogger = { + info: () => {}, + error: () => {}, +}; + +// --------------------------------------------------------------------------- +// extractInlineScripts — PULL direction +// --------------------------------------------------------------------------- + +describe("extractInlineScripts for failure_module / preprocessor_module", () => { + test("extracts rawscript from failure_module wrapped in array", () => { + const failureModule = makeRawscriptModule( + "failure", + 'export function main() { throw new Error("handler"); }', + "bun", + ); + + const scripts = extractInlineScripts([failureModule], {}, "/", "bun"); + + expect(scripts.length).toBeGreaterThanOrEqual(1); + const script = scripts.find((s) => !s.is_lock); + expect(script).toBeDefined(); + expect(script!.content).toBe( + 'export function main() { throw new Error("handler"); }', + ); + // The module content should have been replaced with an !inline reference + expect(failureModule.value.content).toStartWith("!inline "); + }); + + test("extracts rawscript from preprocessor_module wrapped in array", () => { + const preprocessorModule = makeRawscriptModule( + "preprocessor", + "export function main() { return {}; }", + "python3", + ); + + const scripts = extractInlineScripts( + [preprocessorModule], + {}, + "/", + "bun", + ); + + expect(scripts.length).toBeGreaterThanOrEqual(1); + const script = scripts.find((s) => !s.is_lock); + expect(script).toBeDefined(); + expect(script!.content).toBe("export function main() { return {}; }"); + expect(script!.language).toBe("python3"); + expect(preprocessorModule.value.content).toStartWith("!inline "); + }); + + test("extracts lock alongside content", () => { + const mod = makeRawscriptModule( + "failure", + "console.log('hi')", + "bun", + "some-lock-content", + ); + + const scripts = extractInlineScripts([mod], {}, "/", "bun"); + + const contentScript = scripts.find((s) => !s.is_lock); + const lockScript = scripts.find((s) => s.is_lock); + expect(contentScript).toBeDefined(); + expect(lockScript).toBeDefined(); + expect(lockScript!.content).toBe("some-lock-content"); + expect((mod.value as any).lock).toStartWith("!inline "); + }); + + test("shared pathAssigner prevents collisions when summaries match", () => { + // If a regular module and failure_module share the same summary, + // a shared PathAssigner deduplicates via its internal counter. + const regular = makeRawscriptModule("a", "code_a", "bun"); + regular.summary = "my step"; + const failure = makeRawscriptModule("failure", "code_failure", "bun"); + failure.summary = "my step"; // same summary — would collide without shared assigner + + const assigner = newPathAssigner("bun"); + const scripts1 = extractInlineScripts([regular], {}, "/", "bun", assigner); + const scripts2 = extractInlineScripts([failure], {}, "/", "bun", assigner); + + const allPaths = [...scripts1, ...scripts2] + .filter((s) => !s.is_lock) + .map((s) => s.path); + + // All paths should be unique despite identical summaries + expect(allPaths.length).toBe(2); + expect(new Set(allPaths).size).toBe(2); + }); + + test("without shared pathAssigner, identical summaries produce duplicate paths", () => { + // Demonstrates the problem that sharing a PathAssigner solves. + const regular = makeRawscriptModule("a", "code_a", "bun"); + regular.summary = "my step"; + const failure = makeRawscriptModule("failure", "code_failure", "bun"); + failure.summary = "my step"; + + // Separate assigners — each starts with a fresh counter + const scripts1 = extractInlineScripts([regular], {}, "/", "bun"); + const scripts2 = extractInlineScripts([failure], {}, "/", "bun"); + + const allPaths = [...scripts1, ...scripts2] + .filter((s) => !s.is_lock) + .map((s) => s.path); + + // Without a shared assigner, the paths collide + expect(allPaths.length).toBe(2); + expect(new Set(allPaths).size).toBe(1); // both got the same path + }); + + test("skips non-rawscript failure_module (identity type)", () => { + const identityModule: FlowModule = { + id: "failure", + value: { type: "identity" as any }, + }; + const scripts = extractInlineScripts([identityModule], {}, "/", "bun"); + expect(scripts).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// replaceInlineScripts — PUSH direction +// --------------------------------------------------------------------------- + +describe("replaceInlineScripts for failure_module / preprocessor_module", () => { + test("resolves !inline reference back to file content", async () => { + const failureModule = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + ); + + const files: Record = { + "failure.inline_script.ts": 'export function main() { return "error handled"; }', + }; + + await replaceInlineScripts( + [failureModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(failureModule.value.content).toBe( + 'export function main() { return "error handled"; }', + ); + }); + + test("resolves !inline reference for preprocessor_module", async () => { + const preprocessorModule = makeRawscriptModule( + "preprocessor", + "!inline preprocessor.inline_script.py", + "python3", + ); + + const files: Record = { + "preprocessor.inline_script.py": "def main(): return {}", + }; + + await replaceInlineScripts( + [preprocessorModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(preprocessorModule.value.content).toBe("def main(): return {}"); + }); + + test("resolves !inline lock reference", async () => { + const mod = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + "!inline failure.inline_script.lock", + ); + + const files: Record = { + "failure.inline_script.ts": "code here", + "failure.inline_script.lock": "lock-data-here", + }; + + await replaceInlineScripts( + [mod], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(mod.value.content).toBe("code here"); + expect((mod.value as any).lock).toBe("lock-data-here"); + }); + + test("leaves non-inline content untouched", async () => { + const mod = makeRawscriptModule( + "failure", + "export function main() { return 1; }", + "bun", + ); + + await replaceInlineScripts( + [mod], + async () => { + throw new Error("should not be called"); + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(mod.value.content).toBe("export function main() { return 1; }"); + }); +}); + +// --------------------------------------------------------------------------- +// Round-trip: extract then replace +// --------------------------------------------------------------------------- + +describe("round-trip extract → replace for failure_module / preprocessor_module", () => { + test("failure_module content survives extract + replace", async () => { + const originalContent = 'export function main(error: any) {\n console.error(error);\n return { handled: true };\n}'; + const failureModule = makeRawscriptModule( + "failure", + originalContent, + "bun", + ); + + // PULL: extract inline scripts (mutates module in place) + const extracted = extractInlineScripts([failureModule], {}, "/", "bun"); + expect(failureModule.value.content).toStartWith("!inline "); + + // Build a virtual filesystem from extracted scripts + const files: Record = {}; + for (const s of extracted) { + files[s.path] = s.content; + } + + // PUSH: replace inline references back + await replaceInlineScripts( + [failureModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(failureModule.value.content).toBe(originalContent); + }); + + test("preprocessor_module content survives extract + replace", async () => { + const originalContent = "def main():\n return {\"preprocessed\": True}"; + const preprocessorModule = makeRawscriptModule( + "preprocessor", + originalContent, + "python3", + ); + + const extracted = extractInlineScripts( + [preprocessorModule], + {}, + "/", + "bun", + ); + expect(preprocessorModule.value.content).toStartWith("!inline "); + + const files: Record = {}; + for (const s of extracted) { + files[s.path] = s.content; + } + + await replaceInlineScripts( + [preprocessorModule], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(preprocessorModule.value.content).toBe(originalContent); + }); + + test("failure_module with lock survives extract + replace", async () => { + const originalContent = "export function main() { return 42; }"; + const originalLock = "package-lock-contents-here"; + const mod = makeRawscriptModule( + "failure", + originalContent, + "bun", + originalLock, + ); + + const extracted = extractInlineScripts([mod], {}, "/", "bun"); + + const files: Record = {}; + for (const s of extracted) { + files[s.path] = s.content; + } + + await replaceInlineScripts( + [mod], + async (path) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }, + noopLogger, + "/tmp/test/", + "/", + ); + + expect(mod.value.content).toBe(originalContent); + expect((mod.value as any).lock).toBe(originalLock); + }); + + test("full flow with modules + failure_module + preprocessor_module round-trips", async () => { + const regularContent = "export function main() { return 'step1'; }"; + const failureContent = "export function main(e: any) { return e; }"; + const preprocessorContent = "def main():\n pass"; + + const modules = [makeRawscriptModule("a", regularContent, "bun")]; + const failureModule = makeRawscriptModule("failure", failureContent, "bun"); + const preprocessorModule = makeRawscriptModule("preprocessor", preprocessorContent, "python3"); + + // Extract all (mimicking sync.ts pull logic) + const allExtracted = [ + ...extractInlineScripts(modules, {}, "/", "bun"), + ...extractInlineScripts([failureModule], {}, "/", "bun"), + ...extractInlineScripts([preprocessorModule], {}, "/", "bun"), + ]; + + // All modules should now have !inline references + expect(modules[0].value.content).toStartWith("!inline "); + expect(failureModule.value.content).toStartWith("!inline "); + expect(preprocessorModule.value.content).toStartWith("!inline "); + + // All paths should be unique + const paths = allExtracted.filter((s) => !s.is_lock).map((s) => s.path); + expect(new Set(paths).size).toBe(paths.length); + + // Build filesystem + const files: Record = {}; + for (const s of allExtracted) { + files[s.path] = s.content; + } + + const fileReader = async (path: string) => { + if (!(path in files)) throw new Error(`File not found: ${path}`); + return files[path]; + }; + + // Replace all (mimicking flow.ts push logic) + await replaceInlineScripts(modules, fileReader, noopLogger, "/tmp/", "/"); + await replaceInlineScripts([failureModule], fileReader, noopLogger, "/tmp/", "/"); + await replaceInlineScripts([preprocessorModule], fileReader, noopLogger, "/tmp/", "/"); + + expect(modules[0].value.content).toBe(regularContent); + expect(failureModule.value.content).toBe(failureContent); + expect(preprocessorModule.value.content).toBe(preprocessorContent); + }); +}); + +// --------------------------------------------------------------------------- +// extractCurrentMapping +// --------------------------------------------------------------------------- + +describe("extractCurrentMapping for failure_module / preprocessor_module", () => { + test("extracts mapping from failure_module via optional param", () => { + const failureModule: FlowModule = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + ); + + const mapping = extractCurrentMapping( + undefined, + {}, + failureModule, + undefined, + ); + + expect(mapping["failure"]).toBe("failure.inline_script.ts"); + }); + + test("extracts mapping from preprocessor_module via optional param", () => { + const preprocessorModule: FlowModule = makeRawscriptModule( + "preprocessor", + "!inline preprocessor.inline_script.py", + "python3", + ); + + const mapping = extractCurrentMapping( + undefined, + {}, + undefined, + preprocessorModule, + ); + + expect(mapping["preprocessor"]).toBe("preprocessor.inline_script.py"); + }); + + test("extracts mapping from modules + failure + preprocessor combined", () => { + const modules: FlowModule[] = [ + makeRawscriptModule("a", "!inline a.inline_script.ts", "bun"), + ]; + const failureModule = makeRawscriptModule( + "failure", + "!inline failure.inline_script.ts", + "bun", + ); + const preprocessorModule = makeRawscriptModule( + "preprocessor", + "!inline preprocessor.inline_script.py", + "python3", + ); + + const mapping = extractCurrentMapping( + modules, + {}, + failureModule, + preprocessorModule, + ); + + expect(mapping["a"]).toBe("a.inline_script.ts"); + expect(mapping["failure"]).toBe("failure.inline_script.ts"); + expect(mapping["preprocessor"]).toBe("preprocessor.inline_script.py"); + }); + + test("ignores non-inline content in failure_module", () => { + const failureModule = makeRawscriptModule( + "failure", + "export function main() {}", + "bun", + ); + + const mapping = extractCurrentMapping( + undefined, + {}, + failureModule, + undefined, + ); + + expect(mapping["failure"]).toBeUndefined(); + }); +}); diff --git a/cli/test/preview.test.ts b/cli/test/preview.test.ts index 93dc7def48..4b54184d1b 100644 --- a/cli/test/preview.test.ts +++ b/cli/test/preview.test.ts @@ -359,6 +359,116 @@ schema: }); }); +// ============================================================================= +// SCRIPT WITH MODULES PREVIEW TESTS +// ============================================================================= + +test("script preview: script with modules (taskScript pattern)", async () => { + await withTestBackend(async (backend, tempDir) => { + await createWmillConfig(tempDir, { defaultTs: "bun" }); + + // Create the main script that uses taskScript to call a module + await createScript( + tempDir, + "f/test/wac_script.ts", + `import { task, taskScript, workflow } from "windmill-client"; + +const helper = taskScript("./helper.ts"); + +const process = task(async (x: string): Promise => { + return \`processed: \${x}\`; +}); + +export const main = workflow(async (x: string = "test") => { + const a = await process(x); + const b = await helper({ a }); + return { processed: a, helper_result: b }; +});` + ); + + // Create the module file in __mod/ folder + const modDir = `${tempDir}/f/test/wac_script__mod`; + await mkdir(modDir, { recursive: true }); + await writeFile( + `${modDir}/helper.ts`, + `export function main(a: string): string { + return \`helper got: \${a}\`; +}`, + "utf-8" + ); + + const result = await backend.runCLICommand( + ["script", "preview", "f/test/wac_script.ts"], + tempDir + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("processed: test"); + expect(output).toContain("helper got:"); + }); +}); + +test("script preview: script with modules (folder layout)", async () => { + await withTestBackend(async (backend, tempDir) => { + await createWmillConfig(tempDir, { defaultTs: "bun" }); + + // Create folder layout: my_script__mod/script.ts + my_script__mod/helper.ts + const modDir = `${tempDir}/f/test/folder_wac__mod`; + await mkdir(modDir, { recursive: true }); + + // Entry point script + await writeFile( + `${modDir}/script.ts`, + `import { task, taskScript, workflow } from "windmill-client"; + +const helper = taskScript("./helper.ts"); + +export const main = workflow(async (name: string = "World") => { + const result = await helper({ name }); + return { greeting: result }; +});`, + "utf-8" + ); + + // Module file + await writeFile( + `${modDir}/helper.ts`, + `export function main(name: string): string { + return \`Hello from module, \${name}!\`; +}`, + "utf-8" + ); + + // Script metadata + await writeFile( + `${modDir}/script.yaml`, + `summary: "Folder layout WAC script" +description: "Test" +lock: "" +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: + name: + type: string + default: "World" + required: [] +`, + "utf-8" + ); + + const result = await backend.runCLICommand( + ["script", "preview", `f/test/folder_wac__mod/script.ts`], + tempDir + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("Hello from module, World!"); + }); +}); + // ============================================================================= // FLOW PREVIEW TESTS // ============================================================================= diff --git a/cli/test/raw_app_sync.test.ts b/cli/test/raw_app_sync.test.ts index f77b4b045b..95d812511f 100644 --- a/cli/test/raw_app_sync.test.ts +++ b/cli/test/raw_app_sync.test.ts @@ -107,7 +107,7 @@ async function readFileContent(filePath: string): Promise { * Create a raw app directory structure on disk * Uses .raw_app folder suffix with raw_app.yaml metadata */ -async function createRawAppOnDisk(appDir: string): Promise { +async function createRawAppOnDisk(appDir: string, includeBackend: boolean = false): Promise { await mkdir(appDir, { recursive: true }); await mkdir(path.join(appDir, "inline_scripts"), { recursive: true }); @@ -131,6 +131,16 @@ async function createRawAppOnDisk(appDir: string): Promise { INLINE_SCRIPT_A_LOCK, "utf-8" ); + + // Optionally create backend runnable (type: inline) + if (includeBackend) { + await mkdir(path.join(appDir, "backend"), { recursive: true }); + await writeFile(path.join(appDir, "backend", "query.yaml"), "type: inline\n", "utf-8"); + await writeFile(path.join(appDir, "backend", "query.ts"), `export async function main(x: number): Promise { + return \`Result: \${x}\`; +} +`, "utf-8"); + } } test("Raw App: full sync workflow - push, pull, modify, push, clear, pull", async () => { @@ -153,7 +163,7 @@ excludes: []`, "utf-8"); // Create folder structure const appDir = path.join(tempDir, "f", "test", "my_raw_app.raw_app"); await mkdir(path.join(tempDir, "f", "test"), { recursive: true }); - await createRawAppOnDisk(appDir); + await createRawAppOnDisk(appDir, true); // Include backend for metadata test // ========================================================================= // STEP 1: Initial push - create raw app on backend @@ -266,6 +276,47 @@ excludes: []`, "utf-8"); const pulledInlineScript = await readFileContent(inlineScriptPath); expect(pulledInlineScript).toContain("modified:"); + + // ========================================================================= + // STEP 7: Test that script generate-metadata does NOT process backend runnables + // ========================================================================= + + // Create a standalone script (should be processed by script generate-metadata) + await writeFile(path.join(tempDir, "f", "test", "standalone.ts"), `export async function main(): Promise { + return "hello"; +} +`, "utf-8"); + + // Run script generate-metadata + const metaResult1 = await backend.runCLICommand( + ['script', 'generate-metadata', '--yes'], + tempDir, "raw_app_test" + ); + expect(metaResult1.code).toEqual(0); + + // Run generate-metadata --skip-flows --skip-apps + const metaResult2 = await backend.runCLICommand( + ['generate-metadata', '--skip-flows', '--skip-apps', '--yes'], + tempDir, "raw_app_test" + ); + expect(metaResult2.code).toEqual(0); + + // Backend runnables should NOT have .script.yaml files + const backendDir = path.join(appDir, "backend"); + expect(await fileExists(path.join(backendDir, "query.yaml"))).toBeTruthy(); + expect(await fileExists(path.join(backendDir, "query.ts"))).toBeTruthy(); + expect(await fileExists(path.join(backendDir, "query.script.yaml"))).toBeFalsy(); + expect(await fileExists(path.join(backendDir, "query.script.lock"))).toBeFalsy(); + + // Bug: raw app backend files get misprocessed and create script files at wrong location + // The path f/test/my_raw_app.raw_app/backend/query.ts gets truncated at first "." + // becoming f/test/my_raw_app.script.yaml (stripping .raw_app/backend/query.ts) + expect(await fileExists(path.join(tempDir, "f", "test", "my_raw_app.script.yaml"))).toBeFalsy(); + expect(await fileExists(path.join(tempDir, "f", "test", "my_raw_app.script.lock"))).toBeFalsy(); + + // Standalone script SHOULD have metadata + expect(await fileExists(path.join(tempDir, "f", "test", "standalone.script.yaml"))).toBeTruthy(); + expect(await fileExists(path.join(tempDir, "f", "test", "standalone.script.lock"))).toBeTruthy(); }); }); diff --git a/cli/test/resource_folders_unit.test.ts b/cli/test/resource_folders_unit.test.ts index cbcf6d72ea..da8ccd7ece 100644 --- a/cli/test/resource_folders_unit.test.ts +++ b/cli/test/resource_folders_unit.test.ts @@ -32,6 +32,8 @@ import { isRawAppFolderMetadataFile, getDeleteSuffix, transformJsonPathToDir, + isModuleEntryPoint, + getScriptBasePathFromModulePath, } from "../src/utils/resource_folders.ts"; import { removeWorkerPrefix } from "../src/commands/worker-groups/worker-groups.ts"; @@ -504,6 +506,70 @@ describe("transformJsonPathToDir", () => { // removeWorkerPrefix (from worker-groups.ts) // ============================================================================= +// ============================================================================= +// Module Path Functions +// ============================================================================= + +describe("isModuleEntryPoint", () => { + test("detects entry point files in __mod folders", () => { + expect(isModuleEntryPoint("f/my_script__mod/script.ts")).toBe(true); + expect(isModuleEntryPoint("u/admin/tool__mod/script.py")).toBe(true); + expect(isModuleEntryPoint("f/nested/path/script__mod/script.go")).toBe(true); + }); + + test("rejects non-entry-point files in __mod folders", () => { + expect(isModuleEntryPoint("f/my_script__mod/helper.ts")).toBe(false); + expect(isModuleEntryPoint("f/my_script__mod/utils/math.py")).toBe(false); + expect(isModuleEntryPoint("f/my_script__mod/helper.lock")).toBe(false); + }); + + test("rejects entry point files in subdirectories of __mod", () => { + expect(isModuleEntryPoint("f/my_script__mod/sub/script.ts")).toBe(false); + }); + + test("rejects paths without __mod", () => { + expect(isModuleEntryPoint("f/my_script.ts")).toBe(false); + expect(isModuleEntryPoint("f/script.ts")).toBe(false); + }); + + test("handles windows-style separators", () => { + expect(isModuleEntryPoint("f\\my_script__mod\\script.ts")).toBe(true); + expect(isModuleEntryPoint("f\\my_script__mod\\helper.ts")).toBe(false); + }); + + test("matches any extension for script.*", () => { + expect(isModuleEntryPoint("f/x__mod/script.yaml")).toBe(true); + expect(isModuleEntryPoint("f/x__mod/script.json")).toBe(true); + expect(isModuleEntryPoint("f/x__mod/script.lock")).toBe(true); + expect(isModuleEntryPoint("f/x__mod/script.sh")).toBe(true); + }); +}); + +describe("getScriptBasePathFromModulePath", () => { + test("extracts base path from module entry point", () => { + expect(getScriptBasePathFromModulePath("f/my_script__mod/script.ts")).toBe("f/my_script"); + expect(getScriptBasePathFromModulePath("u/admin/tool__mod/script.py")).toBe("u/admin/tool"); + }); + + test("extracts base path from module helper files", () => { + expect(getScriptBasePathFromModulePath("f/my_script__mod/helper.ts")).toBe("f/my_script"); + expect(getScriptBasePathFromModulePath("f/my_script__mod/utils/math.py")).toBe("f/my_script"); + }); + + test("extracts base path from nested script paths", () => { + expect(getScriptBasePathFromModulePath("f/deeply/nested/script__mod/helper.ts")).toBe("f/deeply/nested/script"); + }); + + test("returns undefined for non-module paths", () => { + expect(getScriptBasePathFromModulePath("f/my_script.ts")).toBeUndefined(); + expect(getScriptBasePathFromModulePath("f/my_flow.flow/flow.yaml")).toBeUndefined(); + }); + + test("handles windows-style separators", () => { + expect(getScriptBasePathFromModulePath("f\\my_script__mod\\helper.ts")).toBe("f/my_script"); + }); +}); + describe("removeWorkerPrefix", () => { test("removes worker__ prefix", () => { expect(removeWorkerPrefix("worker__default")).toBe("default"); diff --git a/cli/test/script_modules.test.ts b/cli/test/script_modules.test.ts new file mode 100644 index 0000000000..c797111e00 --- /dev/null +++ b/cli/test/script_modules.test.ts @@ -0,0 +1,323 @@ +/** + * Unit tests for script module utilities. + * These tests require no backend — they test standalone logic. + */ + +import { expect, test, describe, beforeEach, afterEach } from "bun:test"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + getModuleFolderSuffix, + isScriptModulePath, + buildModuleFolderPath, + isModuleEntryPoint, + getScriptBasePathFromModulePath, +} from "../src/utils/resource_folders.ts"; +import { + writeModulesToDisk, + readModulesFromDisk, +} from "../src/commands/script/script.ts"; +import { getTypeStrFromPath } from "../src/types.ts"; + +// ============================================================================= +// Module Path Utilities +// ============================================================================= + +describe("getModuleFolderSuffix", () => { + test("returns __mod", () => { + expect(getModuleFolderSuffix()).toBe("__mod"); + }); +}); + +describe("isScriptModulePath", () => { + test("detects module paths", () => { + expect(isScriptModulePath("f/my_script__mod/helper.ts")).toBe(true); + expect(isScriptModulePath("u/admin/script__mod/utils/math.py")).toBe(true); + }); + + test("rejects non-module paths", () => { + expect(isScriptModulePath("f/my_script.ts")).toBe(false); + expect(isScriptModulePath("f/my_script__mod")).toBe(false); // no trailing / + expect(isScriptModulePath("f/my_flow__flow/flow.yaml")).toBe(false); + }); + + test("handles windows-style separators", () => { + expect(isScriptModulePath("f\\my_script__mod\\helper.ts")).toBe(true); + }); +}); + +describe("buildModuleFolderPath", () => { + test("appends __mod suffix", () => { + expect(buildModuleFolderPath("f/my_script")).toBe("f/my_script__mod"); + expect(buildModuleFolderPath("u/admin/tool")).toBe("u/admin/tool__mod"); + }); +}); + +// ============================================================================= +// writeModulesToDisk / readModulesFromDisk round-trip +// ============================================================================= + +describe("module read/write round-trip", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-module-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("writes and reads back a simple module", async () => { + const moduleFolderPath = path.join(tempDir, "my_script__mod"); + const modules = { + "helper.ts": { + content: 'export function greet() { return "hi"; }\n', + language: "bun" as const, + }, + }; + + await writeModulesToDisk(moduleFolderPath, modules, "bun"); + + // Verify file exists on disk + expect(fs.existsSync(path.join(moduleFolderPath, "helper.ts"))).toBe(true); + + // Read back + const result = await readModulesFromDisk(moduleFolderPath, "bun", false); + expect(result).toBeDefined(); + expect(Object.keys(result!)).toEqual(["helper.ts"]); + expect(result!["helper.ts"].content).toBe('export function greet() { return "hi"; }\n'); + expect(result!["helper.ts"].language).toBe("bun"); + }); + + test("writes and reads back module with lock file", async () => { + const moduleFolderPath = path.join(tempDir, "my_script__mod"); + const modules = { + "helper.py": { + content: "def greet():\n return 'hi'\n", + language: "python3" as const, + lock: "requests==2.31.0\n", + }, + }; + + await writeModulesToDisk(moduleFolderPath, modules, undefined); + + // Verify lock file exists + expect(fs.existsSync(path.join(moduleFolderPath, "helper.lock"))).toBe(true); + + // Read back + const result = await readModulesFromDisk(moduleFolderPath, undefined, false); + expect(result).toBeDefined(); + expect(result!["helper.py"].lock).toBe("requests==2.31.0\n"); + }); + + test("writes and reads back nested module paths", async () => { + const moduleFolderPath = path.join(tempDir, "my_script__mod"); + const modules = { + "utils/math.py": { + content: "def add(a, b):\n return a + b\n", + language: "python3" as const, + }, + "utils/__init__.py": { + content: "", + language: "python3" as const, + }, + }; + + await writeModulesToDisk(moduleFolderPath, modules, undefined); + + // Verify nested structure + expect(fs.existsSync(path.join(moduleFolderPath, "utils", "math.py"))).toBe(true); + expect(fs.existsSync(path.join(moduleFolderPath, "utils", "__init__.py"))).toBe(true); + + // Read back + const result = await readModulesFromDisk(moduleFolderPath, undefined, false); + expect(result).toBeDefined(); + expect(Object.keys(result!).sort()).toEqual(["utils/__init__.py", "utils/math.py"]); + expect(result!["utils/math.py"].content).toBe("def add(a, b):\n return a + b\n"); + }); + + test("returns undefined for non-existent folder", async () => { + const result = await readModulesFromDisk(path.join(tempDir, "nonexistent__mod"), undefined, false); + expect(result).toBeUndefined(); + }); + + test("returns undefined for empty folder", async () => { + const emptyFolder = path.join(tempDir, "empty__mod"); + fs.mkdirSync(emptyFolder); + const result = await readModulesFromDisk(emptyFolder, undefined, false); + expect(result).toBeUndefined(); + }); + + test("multiple modules of different languages", async () => { + const moduleFolderPath = path.join(tempDir, "my_script__mod"); + const modules = { + "helper.ts": { + content: "export const x = 1;\n", + language: "bun" as const, + }, + "other.ts": { + content: "export const y = 2;\n", + language: "bun" as const, + lock: "some-lock-content\n", + }, + }; + + await writeModulesToDisk(moduleFolderPath, modules, "bun"); + const result = await readModulesFromDisk(moduleFolderPath, "bun", false); + + expect(result).toBeDefined(); + expect(Object.keys(result!).sort()).toEqual(["helper.ts", "other.ts"]); + expect(result!["other.ts"].lock).toBe("some-lock-content\n"); + expect(result!["helper.ts"].lock).toBeUndefined(); + }); + + test("folder layout skips entry point files", async () => { + const moduleFolderPath = path.join(tempDir, "my_script__mod"); + fs.mkdirSync(moduleFolderPath, { recursive: true }); + + // Simulate folder layout: script.ts is the entry point, helper.ts is a module + fs.writeFileSync(path.join(moduleFolderPath, "script.ts"), "export function main() {}\n"); + fs.writeFileSync(path.join(moduleFolderPath, "script.yaml"), "description: test\n"); + fs.writeFileSync(path.join(moduleFolderPath, "script.lock"), ""); + fs.writeFileSync(path.join(moduleFolderPath, "helper.ts"), "export const x = 1;\n"); + + // With folderLayout=true, entry point files should be skipped + const result = await readModulesFromDisk(moduleFolderPath, "bun", true); + expect(result).toBeDefined(); + expect(Object.keys(result!)).toEqual(["helper.ts"]); + + // With folderLayout=false, all files are included + const resultFlat = await readModulesFromDisk(moduleFolderPath, "bun", false); + expect(resultFlat).toBeDefined(); + expect(Object.keys(resultFlat!).sort()).toContain("script.ts"); + expect(Object.keys(resultFlat!).sort()).toContain("helper.ts"); + }); +}); + +// ============================================================================= +// getTypeStrFromPath with module paths +// ============================================================================= + +describe("getTypeStrFromPath with module paths", () => { + test("recognizes module content files as scripts", () => { + expect(getTypeStrFromPath("f/my_script__mod/helper.ts")).toBe("script"); + expect(getTypeStrFromPath("u/admin/tool__mod/utils/math.py")).toBe("script"); + expect(getTypeStrFromPath("f/x__mod/helper.go")).toBe("script"); + }); + + test("recognizes module lock files as scripts", () => { + // Lock files inside __mod/ should NOT throw — they are valid module files + expect(getTypeStrFromPath("f/my_script__mod/helper.lock")).toBe("script"); + expect(getTypeStrFromPath("u/admin/tool__mod/utils/math.lock")).toBe("script"); + }); + + test("recognizes module entry points as scripts", () => { + expect(getTypeStrFromPath("f/my_script__mod/script.ts")).toBe("script"); + expect(getTypeStrFromPath("f/my_script__mod/script.py")).toBe("script"); + }); + + test("recognizes module metadata files as scripts", () => { + // .yaml/.json files inside __mod/ should be recognized + expect(getTypeStrFromPath("f/my_script__mod/script.yaml")).toBe("script"); + expect(getTypeStrFromPath("f/my_script__mod/script.json")).toBe("script"); + }); +}); + +// ============================================================================= +// Module read/write with multiple lock files +// ============================================================================= + +describe("module lock file handling", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "wm-module-lock-test-")); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("writes and reads multiple modules each with their own lock", async () => { + const moduleFolderPath = path.join(tempDir, "my_script__mod"); + const modules = { + "api_client.py": { + content: "import requests\ndef fetch(): pass\n", + language: "python3" as const, + lock: "requests==2.31.0\nurllib3==2.0.4\n", + }, + "data_processor.py": { + content: "import pandas\ndef process(): pass\n", + language: "python3" as const, + lock: "pandas==2.1.0\nnumpy==1.25.0\n", + }, + "utils.py": { + content: "def helper(): pass\n", + language: "python3" as const, + // No lock — no external deps + }, + }; + + await writeModulesToDisk(moduleFolderPath, modules, undefined); + + // Verify lock files exist only for modules with locks + expect(fs.existsSync(path.join(moduleFolderPath, "api_client.lock"))).toBe(true); + expect(fs.existsSync(path.join(moduleFolderPath, "data_processor.lock"))).toBe(true); + expect(fs.existsSync(path.join(moduleFolderPath, "utils.lock"))).toBe(false); + + // Read back and verify + const result = await readModulesFromDisk(moduleFolderPath, undefined, false); + expect(result).toBeDefined(); + expect(Object.keys(result!).sort()).toEqual(["api_client.py", "data_processor.py", "utils.py"]); + expect(result!["api_client.py"].lock).toBe("requests==2.31.0\nurllib3==2.0.4\n"); + expect(result!["data_processor.py"].lock).toBe("pandas==2.1.0\nnumpy==1.25.0\n"); + expect(result!["utils.py"].lock).toBeUndefined(); + }); + + test("nested modules with lock files", async () => { + const moduleFolderPath = path.join(tempDir, "my_script__mod"); + const modules = { + "services/api.ts": { + content: "export function callApi() {}\n", + language: "bun" as const, + lock: "axios@1.5.0\n", + }, + "services/db.ts": { + content: "export function query() {}\n", + language: "bun" as const, + lock: "pg@8.11.0\n", + }, + }; + + await writeModulesToDisk(moduleFolderPath, modules, "bun"); + + // Verify nested lock files + expect(fs.existsSync(path.join(moduleFolderPath, "services", "api.lock"))).toBe(true); + expect(fs.existsSync(path.join(moduleFolderPath, "services", "db.lock"))).toBe(true); + + const result = await readModulesFromDisk(moduleFolderPath, "bun", false); + expect(result).toBeDefined(); + expect(result!["services/api.ts"].lock).toBe("axios@1.5.0\n"); + expect(result!["services/db.ts"].lock).toBe("pg@8.11.0\n"); + }); + + test("overwrites existing module folder on re-write", async () => { + const moduleFolderPath = path.join(tempDir, "my_script__mod"); + + // First write + await writeModulesToDisk(moduleFolderPath, { + "old.ts": { content: "old content\n", language: "bun" as const }, + }, "bun"); + expect(fs.existsSync(path.join(moduleFolderPath, "old.ts"))).toBe(true); + + // Second write with different module + await writeModulesToDisk(moduleFolderPath, { + "new.ts": { content: "new content\n", language: "bun" as const }, + }, "bun"); + expect(fs.existsSync(path.join(moduleFolderPath, "new.ts"))).toBe(true); + // old.ts should still exist (writeModulesToDisk doesn't clean up) + // This is intentional — cleanup happens at the sync level + }); +}); diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index 2b062eb157..40e8ac4fdf 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -3,6 +3,13 @@ * * Tests the sync pull and push functionality with a simulated filesystem * containing every kind of Windmill resource type. + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_fixtures.ts - Shared local fixtures (prefer using this module for new tests) + * @see test_backend.ts - API-based creation helpers (createTestApp, createTestResource, etc.) + * + * This file contains: Local fixtures (should migrate to test_fixtures.ts) + createRemoteScript + * If you add new helpers, update cross-links in the files above. */ import { expect, test, describe } from "bun:test"; @@ -32,15 +39,19 @@ import { isAppInlineScriptPath, isFlowInlineScriptPath, isRawAppBackendPath, + getModuleFolderSuffix, } from "../src/utils/resource_folders.ts"; import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-assigner.ts"; // ============================================================================= // Test Fixtures - Every Type of Windmill Resource +// See file header for cross-links to related helpers. +// Consider migrating these to test_fixtures.ts for reuse across tests. // ============================================================================= /** - * Creates a mock script file structure + * Creates a mock script file structure. + * See file header for cross-links to related helpers. */ function createScriptFixture( name: string, @@ -89,7 +100,8 @@ kind: script } /** - * Creates a mock flow file structure + * Creates a mock flow file structure. + * See file header for cross-links to related helpers. */ function createFlowFixture(name: string): Record { const flowSuffix = getFolderSuffix("flow"); @@ -123,7 +135,8 @@ schema: } /** - * Creates a mock app file structure + * Creates a mock app file structure. + * See file header for cross-links to related helpers. */ function createAppFixture(name: string): Record { const appSuffix = getFolderSuffix("app"); @@ -151,7 +164,8 @@ policy: } /** - * Creates a mock raw_app file structure + * Creates a mock raw_app file structure. + * See file header for cross-links to related helpers. */ function createRawAppFixture(name: string): Record { const rawAppSuffix = getFolderSuffix("raw_app"); @@ -1755,6 +1769,66 @@ excludes: [] }); }); +test("Integration: Sync pull with nonDottedPaths uses non-dotted inline script filenames", async () => { + await withTestBackend(async (backend, tempDir) => { + // Push a flow using default dotted paths + await writeFile( + `${tempDir}/wmill.yaml`, + `defaultTs: bun +includes: + - "**" +excludes: [] +`, + "utf-8", + ); + + const uniqueId = Date.now(); + const flowName = `f/test/nondot_pull_inline_${uniqueId}`; + const flowFixture = createFlowFixture(flowName); + await mkdir(`${tempDir}/f/test/nondot_pull_inline_${uniqueId}${getFolderSuffix("flow")}`, { recursive: true }); + for (const file of Object.values(flowFixture)) { + await writeFile(`${tempDir}/${file.path}`, file.content, "utf-8"); + } + + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `f/test/nondot_pull_inline_${uniqueId}*/**`], + tempDir, + ); + expect(pushResult.code).toEqual(0); + + // Pull into a fresh directory with nonDottedPaths enabled + const tempDir2 = await mkdtemp(join(tmpdir(), "wmill_nondot_inline_")); + try { + await writeFile( + `${tempDir2}/wmill.yaml`, + `defaultTs: bun +nonDottedPaths: true +includes: + - "**" +excludes: [] +`, + "utf-8", + ); + + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir2); + expect(pullResult.code).toEqual(0); + + // Verify pulled files use non-dotted inline script naming + const files = await listFilesRecursive(tempDir2); + const flowFiles = files.filter((f) => f.includes(`nondot_pull_inline_${uniqueId}`)); + + expect(flowFiles.length > 0).toBeTruthy(); + // Should use __flow folder, not .flow + expect(flowFiles.some((f) => f.includes("__flow/"))).toBeTruthy(); + // No files should have .inline_script. in their name + const dottedInlineFiles = flowFiles.filter((f) => f.includes(".inline_script.")); + expect(dottedInlineFiles.length).toEqual(0); + } finally { + await cleanupTempDir(tempDir2); + } + }); + }); + // ============================================================================= // ws_error_handler_muted Persistence Tests // ============================================================================= @@ -1920,7 +1994,7 @@ excludes: [] import type { TestBackend } from "./test_backend.ts"; -/** Create a script on the remote via API */ +/** Create a script on the remote via API. See file header for cross-links. */ async function createRemoteScript( backend: TestBackend, scriptPath: string, @@ -2328,3 +2402,315 @@ describe("http trigger sync", () => { }); }); }); + +// ============================================================================= +// Script Module Sync Tests +// ============================================================================= + +describe("script module sync", () => { + test.skipIf(process.platform === "win32")("push script with modules via API and pull back", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/module_script_${uniqueId}`; + const modSuffix = getModuleFolderSuffix(); + + // Create a script with modules via API + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content: 'import { greet } from "./helper";\nexport async function main() { return greet(); }', + language: "bun", + summary: "Script with modules", + description: "Test script with module files", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + modules: { + "helper.ts": { + content: 'export function greet() { return "hello from module"; }\n', + language: "bun", + }, + }, + }), + } + ); + expect(resp.status).toBeLessThan(300); + await resp.text(); + + await writeWmillYaml(tempDir); + + // Pull the script + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + // Verify module files exist on disk + const files = await listFilesRecursive(tempDir); + const moduleFile = files.find( + (f) => f.includes(`module_script_${uniqueId}${modSuffix}`) && f.endsWith("helper.ts") + ); + expect(moduleFile).toBeDefined(); + + // Scripts with modules use folder layout: main content is at __mod/script.ts + const mainFile = files.find( + (f) => f.includes(`module_script_${uniqueId}${modSuffix}`) && f.endsWith("script.ts") + ); + expect(mainFile).toBeDefined(); + + // Verify module content + if (moduleFile) { + const content = await readFile(`${tempDir}/${moduleFile}`, "utf-8"); + expect(content).toContain("greet"); + expect(content).toContain("hello from module"); + } + }); + }); + + test("push script with module lock files and pull back", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/locked_module_${uniqueId}`; + const modSuffix = getModuleFolderSuffix(); + + // Create a script with a module that has a lock + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content: 'export async function main() { return "main"; }', + language: "bun", + summary: "Script with locked module", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + modules: { + "dep.ts": { + content: 'import lodash from "lodash";\nexport const x = lodash.identity(1);\n', + language: "bun", + lock: "lodash@4.17.21\n", + }, + }, + }), + } + ); + expect(resp.status).toBeLessThan(300); + await resp.text(); + + await writeWmillYaml(tempDir); + + // Pull + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + // Verify lock file exists on disk + const files = await listFilesRecursive(tempDir); + const lockFile = files.find( + (f) => f.includes(`locked_module_${uniqueId}${modSuffix}`) && f.endsWith("dep.lock") + ); + expect(lockFile).toBeDefined(); + + // Verify lock content + if (lockFile) { + const lockContent = await readFile(`${tempDir}/${lockFile}`, "utf-8"); + expect(lockContent).toContain("lodash"); + } + }); + }); + + test("local script with modules pushes and round-trips", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const modSuffix = getModuleFolderSuffix(); + + await writeWmillYaml(tempDir); + + // Create local script with modules using folder layout: + // f/test/modular___mod/script.ts (entry point) + // f/test/modular___mod/script.yaml (metadata) + // f/test/modular___mod/helper.ts (module file) + const scriptDir = `${tempDir}/f/test`; + const scriptName = `modular_${uniqueId}`; + const modDir = `${scriptDir}/${scriptName}${modSuffix}`; + await mkdir(modDir, { recursive: true }); + + // Entry point (main script content) + await writeFile( + `${modDir}/script.ts`, + 'import { helper } from "./helper";\nexport async function main() { return helper(); }', + "utf-8" + ); + // Script metadata + await writeFile( + `${modDir}/script.yaml`, + `summary: "Script with modules" +description: "Test" +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +is_template: false +lock: "" +kind: script +`, + "utf-8" + ); + + // Module file + await writeFile( + `${modDir}/helper.ts`, + 'export function helper() { return "from module"; }\n', + "utf-8" + ); + + // Push + const pushResult = await backend.runCLICommand(["sync", "push", "--yes"], tempDir); + expect(pushResult.code).toEqual(0); + + // Pull into a fresh directory to verify round-trip + const pullDir = await mkdtemp(join(tmpdir(), "windmill_module_pull_")); + try { + await writeWmillYaml(pullDir); + + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], pullDir); + expect(pullResult.code).toEqual(0); + + // Verify module file came back + const files = await listFilesRecursive(pullDir); + const pulledModule = files.find( + (f) => f.includes(`${scriptName}${modSuffix}`) && f.endsWith("helper.ts") + ); + expect(pulledModule).toBeDefined(); + + if (pulledModule) { + const content = await readFile(`${pullDir}/${pulledModule}`, "utf-8"); + expect(content).toContain("from module"); + } + } finally { + await rm(pullDir, { recursive: true }); + } + }); + }); + + test("script with nested module paths pushes and pulls correctly", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/nested_mod_${uniqueId}`; + const modSuffix = getModuleFolderSuffix(); + + // Create script with nested modules via API + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content: 'export async function main() { return "main"; }', + language: "bun", + summary: "Script with nested modules", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + modules: { + "utils/format.ts": { + content: 'export function format(s: string) { return s.trim(); }\n', + language: "bun", + }, + "utils/validate.ts": { + content: 'export function validate(s: string) { return s.length > 0; }\n', + language: "bun", + }, + }, + }), + } + ); + expect(resp.status).toBeLessThan(300); + await resp.text(); + + await writeWmillYaml(tempDir); + + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + // Verify nested module files exist + const files = await listFilesRecursive(tempDir); + const formatFile = files.find( + (f) => f.includes(`nested_mod_${uniqueId}${modSuffix}`) && f.includes("utils/format.ts") + ); + const validateFile = files.find( + (f) => f.includes(`nested_mod_${uniqueId}${modSuffix}`) && f.includes("utils/validate.ts") + ); + expect(formatFile).toBeDefined(); + expect(validateFile).toBeDefined(); + }); + }); + + test.skipIf(process.platform === "win32")("pull script with modules does not create stale metadata", async () => { + await withTestBackend(async (backend, tempDir) => { + const uniqueId = Date.now(); + const scriptPath = `f/test/fresh_mod_${uniqueId}`; + + // Create a script with modules on remote + const resp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: scriptPath, + content: 'export async function main() { return "main"; }', + language: "bun", + summary: "Script with modules for freshness test", + schema: { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: {}, + required: [], + }, + modules: { + "helper.ts": { + content: 'export function help() { return true; }\n', + language: "bun", + }, + }, + }), + } + ); + expect(resp.status).toBeLessThan(300); + await resp.text(); + + await writeWmillYaml(tempDir); + + // Pull + const pullResult = await backend.runCLICommand(["sync", "pull", "--yes"], tempDir); + expect(pullResult.code).toEqual(0); + + // Run generate-metadata — should show up-to-date for pulled scripts + const metaResult = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir + ); + expect(metaResult.code).toEqual(0); + // The script we just pulled should NOT be stale + // (it may still show other scripts as stale from seedTestData) + const output = metaResult.stdout + metaResult.stderr; + expect(output).not.toContain(`fresh_mod_${uniqueId}`); + }); + }); +}); diff --git a/cli/test/test_backend.ts b/cli/test/test_backend.ts index 63c7f3078d..16fb79528d 100644 --- a/cli/test/test_backend.ts +++ b/cli/test/test_backend.ts @@ -19,6 +19,13 @@ * // ... * }); * }); + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_fixtures.ts - Local file fixtures (createLocalScript, createLocalFlow, etc.) + * @see sync_pull_push.test.ts - Local fixtures + createRemoteScript (API-based) + * + * This file contains: API-based creation helpers (createTestApp, createTestResource, etc.) + * If you add new helpers, update cross-links in the files above. */ import { CargoBackend, CargoBackendConfig } from "./cargo_backend.ts"; @@ -109,6 +116,7 @@ class CargoBackendAdapter implements TestBackend { return this.backend.apiRequest(path, options); } + /** Seeds test data via API calls. See file header for cross-links to related helpers. */ async seedTestData(): Promise { // Create test folder first await this.createTestFolder("test"); @@ -124,6 +132,7 @@ class CargoBackendAdapter implements TestBackend { await this.createTestApp("f/test/test_dashboard"); } + /** See file header for cross-links to related helpers. */ private async createTestApp(path: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/apps/create`, { method: "POST", @@ -156,6 +165,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestFolder(name: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/folders/create`, { method: "POST", @@ -172,6 +182,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestGroup(name: string): Promise { const response = await this.backend.apiRequest(`/api/w/${this.workspace}/groups/create`, { method: "POST", @@ -189,6 +200,7 @@ class CargoBackendAdapter implements TestBackend { } + /** See file header for cross-links to related helpers. */ private async createTestResource(path: string, description: string): Promise { // First ensure the folder exists const folderPath = path.split("/").slice(0, 2).join("/"); // e.g., "f/test" @@ -228,6 +240,7 @@ class CargoBackendAdapter implements TestBackend { } } + /** See file header for cross-links to related helpers. */ private async createTestVariable(path: string, value: string): Promise { const response = await this.backend.apiRequest( `/api/w/${this.workspace}/variables/create`, diff --git a/cli/test/test_fixtures.ts b/cli/test/test_fixtures.ts new file mode 100644 index 0000000000..cde57e1a95 --- /dev/null +++ b/cli/test/test_fixtures.ts @@ -0,0 +1,591 @@ +/** + * Test Fixtures + * + * Shared helpers for creating test data (scripts, flows, apps, raw apps) in tests. + * + * Two types of helpers: + * - Fixture functions: Return data structures with paths and contents (no disk I/O) + * - Local creation functions: Create fixtures AND write them to disk + * + * CROSS-LINKS - Related test helper locations (keep in sync when adding new helpers): + * @see test_backend.ts - API-based creation helpers (createTestApp, createTestResource, etc.) + * @see sync_pull_push.test.ts - Local fixtures + createRemoteScript (API-based) + * + * This file contains: Shared local fixtures (createLocalScript, createLocalFlow, etc.) + * If you add new helpers, update cross-links in the files above. + * + * @example + * // Using fixtures (data only) + * const fixture = createScriptFixture("my_script", "bun"); + * + * // Using local creation (writes to disk) + * await createLocalScript(tempDir, "f/test", "my_script", "bun"); + * + * @keywords createLocal, local script, local flow, local app, raw app, fixture, test data + */ + +import { writeFile, mkdir } from "node:fs/promises"; +import { + getFolderSuffix, + getMetadataFileName, + getModuleFolderSuffix, +} from "../src/utils/resource_folders.ts"; + +// ============================================================================= +// Fixture Types +// ============================================================================= + +export interface FileFixture { + path: string; + content: string; +} + +export interface ScriptFixture { + contentFile: FileFixture; + metadataFile: FileFixture; +} + +export interface FlowFixture { + metadata: FileFixture; + inlineScript: FileFixture; +} + +export interface AppFixture { + metadata: FileFixture; +} + +export interface RawAppFixture { + metadata: FileFixture; + indexHtml: FileFixture; + indexJs: FileFixture; + [key: string]: FileFixture; +} + +// ============================================================================= +// Script Fixtures +// ============================================================================= + +/** + * Creates a script fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * Use this when you need fine-grained control over the script structure. + * For simple cases, use {@link createLocalScript} instead. + * + * @param name - Script name (without extension) + * @param language - Script language + * @param content - Optional custom script content + * @returns Script fixture with content and metadata files + * + * @example + * const fixture = createScriptFixture("my_script", "bun"); + * const fixture = createScriptFixture("custom", "python3", "def main(): return 42"); + * + * @keywords script fixture, create script, local script + */ +export function createScriptFixture( + name: string, + language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql" = "bun", + content?: string +): ScriptFixture { + const extensions: Record = { + python3: ".py", + deno: ".ts", + bun: ".ts", + bash: ".sh", + go: ".go", + postgresql: ".sql", + }; + + const ext = extensions[language]; + const defaultContent: Record = { + python3: `def main():\n return "Hello from ${name}"`, + deno: `export async function main() {\n return "Hello from ${name}";\n}`, + bun: `export async function main() {\n return "Hello from ${name}";\n}`, + bash: `#!/bin/bash\necho "Hello from ${name}"`, + go: `package inner\n\nfunc main() string {\n return "Hello from ${name}"\n}`, + postgresql: `-- ${name}\nSELECT 'Hello from ${name}';`, + }; + + return { + contentFile: { + path: `${name}${ext}`, + content: content ?? defaultContent[language], + }, + metadataFile: { + path: `${name}.script.yaml`, + content: `summary: "${name} script" +description: "A ${language} script for testing" +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +is_template: false +lock: "" +kind: script +`, + }, + }; +} + +// ============================================================================= +// Flow Fixtures +// ============================================================================= + +/** + * Creates a flow fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: language, summary, description + * + * Use this when you need fine-grained control over the flow structure. + * For simple cases, use {@link createLocalFlow} instead. + * + * @param name - Flow name + * @param inlineScriptContent - Optional custom inline script content + * @returns Flow fixture with metadata and inline script + * + * @example + * const fixture = createFlowFixture("my_flow"); + * + * @keywords flow fixture, create flow, local flow + */ +export function createFlowFixture( + name: string, + inlineScriptContent?: string +): FlowFixture { + const flowSuffix = getFolderSuffix("flow"); + const metadataFile = getMetadataFileName("flow", "yaml"); + + const scriptContent = + inlineScriptContent ?? + `export async function main() {\n return "Hello from flow ${name}";\n}`; + + return { + metadata: { + path: `${name}${flowSuffix}/${metadataFile}`, + content: `summary: "${name} flow" +description: "A flow for testing" +value: + modules: + - id: a + value: + type: rawscript + content: | + ${scriptContent.split("\n").join("\n ")} + language: bun + input_transforms: {} +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +`, + }, + inlineScript: { + path: `${name}${flowSuffix}/a.inline_script.ts`, + content: scriptContent, + }, + }; +} + +// ============================================================================= +// App Fixtures +// ============================================================================= + +/** + * Creates an app fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: inlineScriptContent, summary, grid + * + * Use this when you need fine-grained control over the app structure. + * For simple cases, use {@link createLocalApp} instead. + * + * @param name - App name + * @returns App fixture with metadata + * + * @example + * const fixture = createAppFixture("my_app"); + * + * @keywords app fixture, create app, local app + */ +export function createAppFixture(name: string): AppFixture { + const appSuffix = getFolderSuffix("app"); + const metadataFile = getMetadataFileName("app", "yaml"); + + return { + metadata: { + path: `${name}${appSuffix}/${metadataFile}`, + content: `summary: "${name} app" +value: + type: app + grid: + - id: button1 + data: + type: buttoncomponent + componentInput: + type: runnable + runnable: + type: runnableByName + inlineScript: + content: | + export async function main() { + return "hello from app"; + } + language: bun + hiddenInlineScripts: [] + css: {} + norefreshbar: false +policy: + on_behalf_of: null + on_behalf_of_email: null + triggerables: {} + execution_mode: viewer +`, + }, + }; +} + +// ============================================================================= +// Raw App Fixtures +// ============================================================================= + +/** + * Creates a raw app fixture (data structure, no disk I/O). + * See file header for cross-links to related helpers. + * + * TODO: Add optional params: inlineScriptContent, htmlContent, jsContent + * + * Raw apps are React/frontend apps with separate inline scripts. + * Use this when you need fine-grained control over the raw app structure. + * For simple cases, use {@link createLocalRawApp} instead. + * + * @param name - Raw app name + * @returns Raw app fixture with metadata and frontend files + * + * @example + * const fixture = createRawAppFixture("my_raw_app"); + * + * @keywords raw app fixture, create raw app, local raw app, react app + */ +export function createRawAppFixture(name: string): RawAppFixture { + const rawAppSuffix = getFolderSuffix("raw_app"); + const metadataFile = getMetadataFileName("raw_app", "yaml"); + + return { + metadata: { + path: `${name}${rawAppSuffix}/${metadataFile}`, + content: `summary: "${name} raw app" +policy: + execution_mode: publisher + triggerables: {} +`, + }, + indexHtml: { + path: `${name}${rawAppSuffix}/index.html`, + content: ` + +${name} +
+`, + }, + indexJs: { + path: `${name}${rawAppSuffix}/index.tsx`, + content: `import React from 'react' +import { createRoot } from 'react-dom/client' + +const App = () =>

${name}

+ +const root = createRoot(document.getElementById('root')!) +root.render() +`, + }, + packageJson: { + path: `${name}${rawAppSuffix}/package.json`, + content: `{ + "dependencies": { + "react": "19.0.0", + "react-dom": "19.0.0" + } +}`, + }, + inlineScript: { + path: `${name}${rawAppSuffix}/inline_scripts/a.inline_script.ts`, + content: `export async function main(x: string) { + return x +} +`, + }, + inlineScriptLock: { + path: `${name}${rawAppSuffix}/inline_scripts/a.inline_script.lock`, + content: ``, + }, + }; +} + +// ============================================================================= +// Local Creation Functions (Fixture + Write to Disk) +// ============================================================================= + +/** + * Creates a script on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates a script fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Script name (without extension) + * @param language - Script language (default: "bun") + * @param content - Optional custom script content + * + * @example + * await createLocalScript(tempDir, "f/test", "my_script"); + * await createLocalScript(tempDir, "f/test", "custom", "python3", "def main(): return 42"); + * + * @keywords create local script, local script, write script, script on disk + */ +export async function createLocalScript( + tempDir: string, + path: string, + name: string, + language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql" = "bun", + content?: string +): Promise { + const fixture = createScriptFixture(name, language, content); + await mkdir(`${tempDir}/${path}`, { recursive: true }); + await writeFile( + `${tempDir}/${path}/${fixture.contentFile.path}`, + fixture.contentFile.content, + "utf-8" + ); + await writeFile( + `${tempDir}/${path}/${fixture.metadataFile.path}`, + fixture.metadataFile.content, + "utf-8" + ); +} + +/** + * Creates a flow on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates a flow fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Flow name + * @param inlineScriptContent - Optional custom inline script content + * + * @example + * await createLocalFlow(tempDir, "f/test", "my_flow"); + * + * @keywords create local flow, local flow, write flow, flow on disk + */ +export async function createLocalFlow( + tempDir: string, + path: string, + name: string, + inlineScriptContent?: string +): Promise { + const fixture = createFlowFixture(name, inlineScriptContent); + const flowDir = `${tempDir}/${path}/${name}${getFolderSuffix("flow")}`; + await mkdir(flowDir, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + await writeFile(fullPath, file.content, "utf-8"); + } +} + +/** + * Creates an app on the local filesystem. + * See file header for cross-links to related helpers. + * + * This is a convenience function that creates an app fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - App name + * + * @example + * await createLocalApp(tempDir, "f/test", "my_app"); + * + * @keywords create local app, local app, write app, app on disk + */ +export async function createLocalApp( + tempDir: string, + path: string, + name: string +): Promise { + const fixture = createAppFixture(name); + const appDir = `${tempDir}/${path}/${name}${getFolderSuffix("app")}`; + await mkdir(appDir, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + await writeFile(fullPath, file.content, "utf-8"); + } +} + +/** + * Creates a raw app on the local filesystem. + * See file header for cross-links to related helpers. + * + * Raw apps are React/frontend apps with separate inline scripts. + * This is a convenience function that creates a raw app fixture and writes it to disk. + * + * @param tempDir - Base directory for the test workspace + * @param path - Relative path within the workspace (e.g., "f/test") + * @param name - Raw app name + * + * @example + * await createLocalRawApp(tempDir, "f/test", "my_raw_app"); + * + * @keywords create local raw app, local raw app, write raw app, raw app on disk, react app + */ +export async function createLocalRawApp( + tempDir: string, + path: string, + name: string +): Promise { + const fixture = createRawAppFixture(name); + const rawAppSuffix = getFolderSuffix("raw_app"); + const appDir = `${tempDir}/${path}/${name}${rawAppSuffix}`; + await mkdir(`${appDir}/inline_scripts`, { recursive: true }); + + for (const file of Object.values(fixture)) { + const fullPath = `${tempDir}/${path}/${file.path}`; + const dir = fullPath.substring(0, fullPath.lastIndexOf("/")); + await mkdir(dir, { recursive: true }); + await writeFile(fullPath, file.content, "utf-8"); + } +} + +// ============================================================================= +// Script with Modules Fixtures +// ============================================================================= + +export interface ModuleFile { + path: string; + content: string; + lock?: string; +} + +/** + * Creates a script with module files on the local filesystem. + * + * Creates the main script, its metadata, and module files in a __mod/ folder. + * Optionally includes lock files for modules. + * + * @param tempDir - Base directory for the test workspace + * @param dir - Relative path within the workspace (e.g., "f/test") + * @param name - Script name (without extension) + * @param language - Script language (default: "bun") + * @param modules - Module files to create + * + * @example + * await createLocalScriptWithModules(tempDir, "f/test", "my_script", "bun", [ + * { path: "helper.ts", content: "export const x = 1;" }, + * { path: "utils/math.ts", content: "export function add(a, b) { return a + b; }", lock: "lodash@4.0.0\n" }, + * ]); + */ +export async function createLocalScriptWithModules( + tempDir: string, + dir: string, + name: string, + language: "python3" | "deno" | "bun" | "bash" | "go" | "postgresql" = "bun", + modules: ModuleFile[] +): Promise { + // Create the main script + await createLocalScript(tempDir, dir, name, language); + + // Create module files in __mod/ folder + const modSuffix = getModuleFolderSuffix(); + const modDir = `${tempDir}/${dir}/${name}${modSuffix}`; + + for (const mod of modules) { + const fullPath = `${modDir}/${mod.path}`; + const parentDir = fullPath.substring(0, fullPath.lastIndexOf("/")); + await mkdir(parentDir, { recursive: true }); + await writeFile(fullPath, mod.content, "utf-8"); + + // Write lock file if provided + if (mod.lock) { + const baseName = mod.path.substring(0, mod.path.indexOf(".")); + const lockPath = `${modDir}/${baseName}.lock`; + const lockDir = lockPath.substring(0, lockPath.lastIndexOf("/")); + await mkdir(lockDir, { recursive: true }); + await writeFile(lockPath, mod.lock, "utf-8"); + } + } +} + +// ============================================================================= +// Resource Fixtures (Variables, Resources, Schedules, etc.) +// ============================================================================= + +/** + * Creates a resource fixture. + * + * @keywords resource fixture, create resource + */ +export function createResourceFixture( + name: string, + resourceType: string, + value: Record +): FileFixture { + return { + path: `${name}.resource.yaml`, + content: `resource_type: "${resourceType}" +value: +${Object.entries(value) + .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`) + .join("\n")} +`, + }; +} + +/** + * Creates a variable fixture. + * + * @keywords variable fixture, create variable + */ +export function createVariableFixture( + name: string, + value: string, + isSecret: boolean = false +): FileFixture { + return { + path: `${name}.variable.yaml`, + content: `value: "${value}" +is_secret: ${isSecret} +description: "Variable ${name} for testing" +`, + }; +} + +/** + * Creates a schedule fixture. + * + * @keywords schedule fixture, create schedule + */ +export function createScheduleFixture( + name: string, + scriptPath: string, + schedule: string = "0 * * * *" +): FileFixture { + return { + path: `${name}.schedule.yaml`, + content: `path: "${name}" +schedule: "${schedule}" +script_path: "${scriptPath}" +is_flow: false +args: {} +enabled: true +timezone: "UTC" +`, + }; +} diff --git a/cli/test/unified_generate_metadata.test.ts b/cli/test/unified_generate_metadata.test.ts new file mode 100644 index 0000000000..a25d7f8bd3 --- /dev/null +++ b/cli/test/unified_generate_metadata.test.ts @@ -0,0 +1,779 @@ +/** + * Unified generate-metadata Command Tests + * + * Tests the new unified `generate-metadata` command that processes + * scripts, flows, and apps together. + */ + +import { expect, test, describe } from "bun:test"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { + createLocalScript, + createLocalFlow, + createLocalApp, + createLocalRawApp, + createLocalScriptWithModules, +} from "./test_fixtures.ts"; + +/** + * Helper to set up a workspace with wmill.yaml + */ +async function setupWorkspace( + backend: any, + tempDir: string, + workspaceName: string, + nonDottedPaths = false +) { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: workspaceName, + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +${nonDottedPaths ? "nonDottedPaths: true\n" : ""}includes: + - "**" +excludes: []`, "utf-8"); +} + +async function createLocalNonDottedFlow(tempDir: string, name: string) { + const flowDir = `${tempDir}/f/test/${name}__flow`; + await mkdir(flowDir, { recursive: true }); + + await writeFile( + `${flowDir}/a.ts`, + `export async function main() {\n return "Hello from flow ${name}";\n}`, + "utf-8" + ); + + await writeFile( + `${flowDir}/flow.yaml`, + `summary: "${name} flow" +description: "A flow for testing" +value: + modules: + - id: a + value: + type: rawscript + content: "!inline a.ts" + language: bun + input_transforms: {} +schema: + $schema: "https://json-schema.org/draft/2020-12/schema" + type: object + properties: {} + required: [] +`, + "utf-8" + ); +} + +async function createLocalNonDottedApp(tempDir: string, name: string) { + const appDir = `${tempDir}/f/test/${name}__app`; + await mkdir(appDir, { recursive: true }); + + await writeFile( + `${appDir}/app.yaml`, + `summary: "${name} app" +value: + type: app + grid: + - id: button1 + data: + type: buttoncomponent + componentInput: + type: runnable + runnable: + type: runnableByName + inlineScript: + content: | + export async function main() { + return "hello from app"; + } + language: bun + hiddenInlineScripts: [] + css: {} + norefreshbar: false +policy: + on_behalf_of: null + on_behalf_of_email: null + triggerables: {} + execution_mode: viewer +`, + "utf-8" + ); +} + +async function fileExists(filePath: string): Promise { + try { + await stat(filePath); + return true; + } catch { + return false; + } +} + +// ============================================================================= +// Main test: processes scripts, flows, and apps together +// ============================================================================= + +test("generate-metadata: processes scripts, flows, and apps together", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "unified_all_test"); + + // Create one of each type + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "unified_all_test" + ); + + expect(result.code).toEqual(0); + // Should find stale items + expect(result.stdout).toContain("Found"); + expect(result.stdout).toContain("stale metadata"); + }); +}); + +// ============================================================================= +// Flag tests +// ============================================================================= + +describe("generate-metadata flags", () => { + test("--includes filters to specific paths", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "includes_test"); + + // Create two scripts in different folders + await createLocalScript(tempDir, "f/included", "script_a"); + await createLocalScript(tempDir, "f/excluded", "script_b"); + + // Run with --includes to only process f/included + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "-i", "f/included/**"], + tempDir, + "includes_test" + ); + + expect(result.code).toEqual(0); + // Should only mention the included script + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).not.toContain("script_b"); + }); + }); + + test("--excludes filters out specific paths", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "excludes_test"); + + // Create two scripts + await createLocalScript(tempDir, "f/keep", "script_keep"); + await createLocalScript(tempDir, "f/skip", "script_skip"); + + // Run with --excludes to skip f/skip + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "-e", "f/skip/**"], + tempDir, + "excludes_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_keep"); + expect(output).not.toContain("script_skip"); + }); + }); + + test("--dry-run shows stale items without updating", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "dry_run_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // Run with --dry-run + const result = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "dry_run_test" + ); + + expect(result.code).toEqual(0); + // Should show stale items (Scripts section header) + expect(result.stdout).toContain("Scripts"); + expect(result.stdout).toContain("my_script"); + // Should NOT show "Done" (didn't actually update) + expect(result.stdout).not.toContain("Done"); + + // Run again without --dry-run to verify it would still be stale + const result2 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "dry_run_test" + ); + expect(result2.stdout).toContain("Scripts"); + }); + }); + + test("--lock-only only regenerates locks", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "lock_only_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--lock-only"], + tempDir, + "lock_only_test" + ); + + expect(result.code).toEqual(0); + }); + }); + + test("--lock-only preserves non-dotted flow filenames", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "lock_only_non_dotted_test", true); + + await createLocalNonDottedFlow(tempDir, "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--lock-only"], + tempDir, + "lock_only_non_dotted_test" + ); + + expect(result.code).toEqual(0); + + const flowDir = `${tempDir}/f/test/my_flow__flow`; + const flowYaml = await readFile(`${flowDir}/flow.yaml`, "utf-8"); + + expect(flowYaml).toContain("!inline a.ts"); + expect(flowYaml).toContain("!inline a.lock"); + expect(flowYaml).not.toContain(".inline_script."); + expect(await fileExists(`${flowDir}/a.lock`)).toEqual(true); + expect(await fileExists(`${flowDir}/a.inline_script.ts`)).toEqual(false); + expect(await fileExists(`${flowDir}/a.inline_script.lock`)).toEqual(false); + }); + }); + + test("generate-metadata preserves non-dotted flow inline script filenames", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "full_gen_non_dotted_flow_test", true); + + await createLocalNonDottedFlow(tempDir, "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "full_gen_non_dotted_flow_test" + ); + + expect(result.code).toEqual(0); + + const flowDir = `${tempDir}/f/test/my_flow__flow`; + const flowYaml = await readFile(`${flowDir}/flow.yaml`, "utf-8"); + + // Inline script references should use non-dotted naming + expect(flowYaml).toContain("!inline a.ts"); + expect(flowYaml).toContain("!inline a.lock"); + expect(flowYaml).not.toContain(".inline_script."); + expect(await fileExists(`${flowDir}/a.ts`)).toEqual(true); + expect(await fileExists(`${flowDir}/a.lock`)).toEqual(true); + expect(await fileExists(`${flowDir}/a.inline_script.ts`)).toEqual(false); + expect(await fileExists(`${flowDir}/a.inline_script.lock`)).toEqual(false); + }); + }); + + test("generate-metadata uses non-dotted app inline script filenames", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "non_dotted_app_gen_test", true); + + await createLocalNonDottedApp(tempDir, "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "non_dotted_app_gen_test" + ); + + expect(result.code).toEqual(0); + + const appDir = `${tempDir}/f/test/my_app__app`; + const appYaml = await readFile(`${appDir}/app.yaml`, "utf-8"); + + // Inline script references should use non-dotted naming + expect(appYaml).not.toContain(".inline_script."); + // Verify no dotted inline script files were created + const { readdir: readdirAsync } = await import("node:fs/promises"); + const files = await readdirAsync(appDir); + const dottedFiles = files.filter((f: string) => f.includes(".inline_script.")); + expect(dottedFiles.length).toEqual(0); + }); + }); + + test("--schema-only only processes scripts (skips flows and apps)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "schema_only_test"); + + // Create one of each type + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--schema-only"], + tempDir, + "schema_only_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should show "Checking scripts..." only + expect(output).toContain("Checking scripts..."); + // Should find the script (Scripts section header) + expect(output).toContain("Scripts"); + // Should NOT find flows or apps + expect(output).not.toContain("Flows"); + expect(output).not.toContain("Apps"); + }); + }); + + test("--skip-scripts skips scripts", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_scripts_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-scripts"], + tempDir, + "skip_scripts_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should NOT contain script + expect(output).not.toContain("Scripts"); + // Should contain flow + expect(output).toContain("Flows"); + }); + }); + + test("--skip-flows skips flows", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_flows_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalFlow(tempDir, "f/test", "my_flow"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-flows"], + tempDir, + "skip_flows_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should contain script + expect(output).toContain("Scripts"); + // Should NOT contain flow + expect(output).not.toContain("Flows"); + }); + }); + + test("--skip-apps skips apps", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_apps_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + await createLocalApp(tempDir, "f/test", "my_app"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "--skip-apps"], + tempDir, + "skip_apps_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should contain script + expect(output).toContain("Scripts"); + // Should NOT contain app + expect(output).not.toContain("Apps"); + }); + }); + + test("shows 'All metadata up-to-date' when nothing to update", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "uptodate_test"); + + // Create a script and run generate-metadata twice + await createLocalScript(tempDir, "f/test", "my_script"); + + // First run - generates metadata + await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "uptodate_test" + ); + + // Second run - should be up-to-date + const result = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "uptodate_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); + + test("skipping all types shows warning", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "skip_all_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--skip-scripts", "--skip-flows", "--skip-apps"], + tempDir, + "skip_all_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("Nothing to check"); + }); + }); +}); + +// ============================================================================= +// Folder argument tests +// ============================================================================= + +describe("generate-metadata folder argument", () => { + test("filters to specific script folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_script_test"); + + // Create scripts in different folders + await createLocalScript(tempDir, "f/included", "script_a"); + await createLocalScript(tempDir, "f/excluded", "script_b"); + + // Run with folder argument + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/script_a.ts"], + tempDir, + "folder_script_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).not.toContain("script_b"); + }); + }); + + test("filters to specific flow folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_flow_test"); + + // Create flows in different folders + await createLocalFlow(tempDir, "f/included", "flow_a"); + await createLocalFlow(tempDir, "f/excluded", "flow_b"); + + // Run with folder argument (flow folder path - uses .flow suffix by default) + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/flow_a.flow"], + tempDir, + "folder_flow_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("flow_a"); + expect(output).not.toContain("flow_b"); + }); + }); + + test("filters to specific app folder", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_app_test"); + + // Create apps in different folders + await createLocalApp(tempDir, "f/included", "app_a"); + await createLocalApp(tempDir, "f/excluded", "app_b"); + + // Run with folder argument (app folder path - uses .app suffix by default) + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/included/app_a.app"], + tempDir, + "folder_app_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("app_a"); + expect(output).not.toContain("app_b"); + }); + }); + + test("shows up-to-date when folder has no stale items", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "folder_uptodate_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // First run to generate metadata + await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "folder_uptodate_test" + ); + + // Second run with folder - should be up-to-date + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/test/my_script.ts"], + tempDir, + "folder_uptodate_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); + + test("trailing slash is stripped (matches deprecated behavior)", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "trailing_slash_test"); + + await createLocalScript(tempDir, "f/test", "my_script"); + + // Run with trailing slash + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/test/my_script.ts/"], + tempDir, + "trailing_slash_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("my_script"); + }); + }); + + test("parent folder matches all children", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "parent_folder_test"); + + // Create scripts in nested folders + await createLocalScript(tempDir, "f/parent", "script_a"); + await createLocalScript(tempDir, "f/parent/child", "script_b"); + await createLocalScript(tempDir, "f/other", "script_c"); + + // Run with parent folder - should match both scripts in f/parent tree + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/parent"], + tempDir, + "parent_folder_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + expect(output).toContain("script_a"); + expect(output).toContain("script_b"); + expect(output).not.toContain("script_c"); + }); + }); + + test("non-existent folder shows up-to-date", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "nonexistent_folder_test"); + + await createLocalScript(tempDir, "f/exists", "my_script"); + + // Run with non-existent folder + const result = await backend.runCLICommand( + ["generate-metadata", "--yes", "f/does_not_exist"], + tempDir, + "nonexistent_folder_test" + ); + + expect(result.code).toEqual(0); + expect(result.stdout).toContain("up-to-date"); + }); + }); +}); + +// ============================================================================= +// Scripts with modules +// ============================================================================= + +describe("generate-metadata with script modules", () => { + test("script with modules is detected as a single stale item", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "module_script_test"); + + // Create a script with module files + await createLocalScriptWithModules(tempDir, "f/test", "order_workflow", "bun", [ + { path: "helper.ts", content: 'export function validate() { return true; }\n' }, + { path: "utils.ts", content: 'export const VERSION = "1.0";\n' }, + ]); + + const result = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "module_script_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // The main script should be listed as stale + expect(output).toContain("order_workflow"); + // Module files should NOT appear as separate stale scripts (only within [changed modules: ...]) + const lines = output.split("\n"); + const staleLines = lines.filter((l: string) => l.includes("f/test/")); + expect(staleLines.length).toBe(1); + expect(staleLines[0]).toContain("order_workflow"); + }); + }); + + test("module files are not treated as standalone scripts", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "module_not_standalone_test"); + + // Create a script with modules plus a regular script + await createLocalScriptWithModules(tempDir, "f/test", "my_script", "bun", [ + { path: "helper.ts", content: 'export function greet() { return "hi"; }\n' }, + ]); + await createLocalScript(tempDir, "f/test", "standalone_script"); + + const result = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "module_not_standalone_test" + ); + + expect(result.code).toEqual(0); + const output = result.stdout + result.stderr; + // Should list both the main script and standalone script + expect(output).toContain("my_script"); + expect(output).toContain("standalone_script"); + // Should NOT list module helper as a separate entry + // Count occurrences of "Scripts" header — there should be exactly one + expect(output).toContain("Scripts"); + }); + }); + + test("script with modules generates metadata and becomes up-to-date", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "module_uptodate_test"); + + await createLocalScriptWithModules(tempDir, "f/test", "my_script", "bun", [ + { path: "helper.ts", content: 'export function greet() { return "hi"; }\n' }, + ]); + + // First run — should find stale items and generate metadata + const result1 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "module_uptodate_test" + ); + expect(result1.code).toEqual(0); + expect(result1.stdout).toContain("Done"); + + // Second run — should be up-to-date + const result2 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "module_uptodate_test" + ); + expect(result2.code).toEqual(0); + expect(result2.stdout).toContain("up-to-date"); + }); + }); + + test("modifying a module re-triggers stale detection", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "module_modify_test"); + + await createLocalScriptWithModules(tempDir, "f/test", "order_workflow", "bun", [ + { path: "helper.ts", content: 'export function greet() { return "hi"; }\n' }, + { path: "utils.ts", content: 'export const VERSION = "1.0";\n' }, + ]); + + // First run — generate metadata + const result1 = await backend.runCLICommand( + ["generate-metadata", "--yes"], + tempDir, + "module_modify_test" + ); + expect(result1.code).toEqual(0); + + // Second run — should be up-to-date + const result2 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "module_modify_test" + ); + expect(result2.code).toEqual(0); + const output2 = result2.stdout + result2.stderr; + expect(output2).not.toContain("order_workflow"); + + // Modify one module + await writeFile( + `${tempDir}/f/test/order_workflow__mod/helper.ts`, + 'export function greet() { return "hello world"; }\n', + "utf-8" + ); + + // Third run — should detect the script as stale with the changed module + const result3 = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "module_modify_test" + ); + expect(result3.code).toEqual(0); + const output3 = result3.stdout + result3.stderr; + expect(output3).toContain("order_workflow"); + expect(output3).toContain("helper.ts"); + // utils.ts was not modified, should not be listed as changed + expect(output3).not.toContain("utils.ts"); + }); + }); + + test("script with modules and lock files does not crash", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspace(backend, tempDir, "module_with_locks_test"); + + // Create a script with modules that have lock files + await createLocalScriptWithModules(tempDir, "f/test", "my_script", "bun", [ + { + path: "helper.ts", + content: 'import lodash from "lodash";\nexport const x = lodash.identity(1);\n', + lock: "lodash@4.17.21\n", + }, + ]); + + // Should not crash on lock files inside __mod/ + const result = await backend.runCLICommand( + ["generate-metadata", "--dry-run"], + tempDir, + "module_with_locks_test" + ); + + expect(result.code).toEqual(0); + // Should find the main script as stale, not crash on .lock files + expect(result.stdout).toContain("my_script"); + }); + }); +}); diff --git a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts index df9a1db08b..a3572ce7eb 100644 --- a/cli/windmill-utils-internal/src/inline-scripts/extractor.ts +++ b/cli/windmill-utils-internal/src/inline-scripts/extractor.ts @@ -150,8 +150,17 @@ export function extractInlineScripts( */ export function extractCurrentMapping( modules: FlowModule[] | undefined, - mapping: Record = {} + mapping: Record = {}, + failureModule?: FlowModule, + preprocessorModule?: FlowModule, ): Record { + if (failureModule) { + extractCurrentMapping([failureModule], mapping); + } + if (preprocessorModule) { + extractCurrentMapping([preprocessorModule], mapping); + } + if (!modules || !Array.isArray(modules)) { return mapping; } diff --git a/docs/enterprise.md b/docs/enterprise.md index bfed61a2e6..5e2b7c9b61 100644 --- a/docs/enterprise.md +++ b/docs/enterprise.md @@ -15,17 +15,22 @@ - Standard location: `~/windmill-ee-private` - Worktree location: `~/windmill-ee-private__worktrees//` +## Detecting EE Changes + +The `*_ee.rs` files in the windmill repo are symlinks — changes won't appear in `git diff` of the windmill repo. Check the EE repo directly: `git -C status --short` + ## EE PR Workflow (MUST DO when modifying `*_ee.rs` files) When you modify any `*_ee.rs` file and create a PR on windmill: -1. **Create a matching branch** in `windmill-ee-private` (same branch name) -2. **Commit and push** the `_ee.rs` changes in that branch -3. **Create a PR** on `windmill-ee-private` with a link to the companion windmill PR -4. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` +1. **Prefix the windmill PR title** with `[ee]`: `[ee] : ` +2. **Create a matching branch** in `windmill-ee-private` (same branch name) +3. **Commit and push** the `_ee.rs` changes in that branch +4. **Create a companion PR** on `windmill-ee-private` with a link to the windmill PR (no `[ee]` prefix on this one) +5. **Update `ee-repo-ref.txt`**: Run `bash write_latest_ee_ref.sh` from `backend/` - **Verify** it wrote the correct commit hash from your branch, not from main (the script may fall back to `~/windmill-ee-private` on main) - If wrong, manually write the correct hash -5. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref +6. **Commit `ee-repo-ref.txt`** in the windmill repo so CI picks up the correct EE ref ## Validation diff --git a/flake.nix b/flake.nix index 135005b83d..5f644d93dc 100644 --- a/flake.nix +++ b/flake.nix @@ -229,13 +229,20 @@ # --------------------------------------------------------------- devEnvVars = { - DATABASE_URL = "postgres://postgres:changeme@127.0.0.1:5432/windmill?sslmode=disable"; - REMOTE = "http://127.0.0.1:8000"; - REMOTE_LSP = "http://127.0.0.1:3001"; NODE_ENV = "development"; NODE_OPTIONS = "--max-old-space-size=16384"; }; + # Connection-specific defaults — set via shellHook so they respect + # pre-existing values (e.g. from webmux runtime.env / .env.local). + # Nix attrs are injected unconditionally and would override per-worktree + # values set by webmux before the interactive shell starts. + devShellHook = '' + export DATABASE_URL="''${DATABASE_URL:-postgres://postgres:changeme@127.0.0.1:5432/windmill?sslmode=disable}" + export REMOTE="''${REMOTE:-http://127.0.0.1:8000}" + export REMOTE_LSP="''${REMOTE_LSP:-http://127.0.0.1:3001}" + ''; + # --------------------------------------------------------------- # Helper scripts — base set (default + full) # --------------------------------------------------------------- @@ -402,6 +409,7 @@ # ============================================================= devShells.default = pkgs.mkShell (buildEnvVars // commonRuntimeVars // devEnvVars // browserVars // { + shellHook = devShellHook; buildInputs = coreBuildInputs; packages = helperScriptsBase ++ [ playwrightWrapper ]; @@ -413,6 +421,7 @@ # ============================================================= devShells.full = pkgs.mkShell (buildEnvVars // commonRuntimeVars // extraRuntimeVars // devEnvVars // browserVars // { + shellHook = devShellHook; buildInputs = coreBuildInputs ++ extraRuntimes ++ (with pkgs; [ # Python extras poetry diff --git a/frontend/.npmrc b/frontend/.npmrc index 521a9f7c07..8b13789179 100644 --- a/frontend/.npmrc +++ b/frontend/.npmrc @@ -1 +1 @@ -legacy-peer-deps=true + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index aebb63af83..9f9a907e19 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-components", - "version": "1.654.0", + "version": "1.658.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-components", - "version": "1.654.0", + "version": "1.658.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -75,17 +75,18 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", + "windmill-parser-wasm": "file:../backend/parsers/windmill-parser-wasm/pkg-ts", "windmill-parser-wasm-asset": "1.653.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.510.1", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.653.0", + "windmill-parser-wasm-py": "1.657.2", "windmill-parser-wasm-regex": "1.653.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.653.0", + "windmill-parser-wasm-ts": "1.657.2", "windmill-parser-wasm-yaml": "1.593.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "^1.3.4", @@ -147,9 +148,9 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0-beta.16", + "vite": "^8.0.0", "vite-plugin-mkcert": "^1.17.5", - "vitest": "^4.1.0-beta.5", + "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" }, "optionalDependencies": { @@ -160,6 +161,10 @@ "svelte": "^5.0.0" } }, + "../backend/parsers/windmill-parser-wasm/pkg-ts": { + "name": "windmill-parser-wasm", + "version": "1.654.0" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -256,6 +261,33 @@ "tslib": "^2.3.1" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -736,23 +768,91 @@ "@codingame/monaco-vscode-view-title-bar-service-override": "25.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "node_modules/@csstools/css-parser-algorithms": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", + "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^2.4.1" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", + "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", + "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", + "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", + "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", "license": "MIT", "optional": true, "dependencies": { @@ -760,10 +860,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", "license": "MIT", "optional": true, "dependencies": { @@ -1098,7 +1197,6 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1109,7 +1207,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1120,7 +1217,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1130,14 +1226,12 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -1258,7 +1352,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1326,7 +1419,7 @@ "version": "0.115.0", "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1336,7 +1429,7 @@ "version": "0.115.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -1389,7 +1482,7 @@ "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@popperjs/core": { @@ -1409,13 +1502,12 @@ "license": "SEE LICENSE IN LICENSE" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.6.tgz", - "integrity": "sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1426,13 +1518,12 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.6.tgz", - "integrity": "sha512-+tJhD21KvGNtUrpLXrZQlT+j5HZKiEwR2qtcZb3vNOUpvoT9QjEykr75ZW/Kr0W89gose/HVXU6351uVZD8Qvw==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1443,13 +1534,12 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.6.tgz", - "integrity": "sha512-DKNhjMk38FAWaHwUt1dFR3rA/qRAvn2NUvSG2UGvxvlMxSmN/qqww/j4ABAbXhNRXtGQNmrAINMXRuwHl16ZHg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1460,13 +1550,12 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.6.tgz", - "integrity": "sha512-8TThsRkCPAnfyMBShxrGdtoOE6h36QepqRQI97iFaQSCRbHFWHcDHppcojZnzXoruuhPnjMEygzaykvPVJsMRg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", + "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1477,13 +1566,12 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.6.tgz", - "integrity": "sha512-ZfmFoOwPUZCWtGOVC9/qbQzfc0249FrRUOzV2XabSMUV60Crp211OWLQN1zmQAsRIVWRcEwhJ46Z1mXGo/L/nQ==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", + "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1494,13 +1582,12 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.6.tgz", - "integrity": "sha512-ZsGzbNETxPodGlLTYHaCSGVhNN/rvkMDCJYHdT7PZr5jFJRmBfmDi2awhF64Dt2vxrJqY6VeeYSgOzEbHRsb7Q==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1511,13 +1598,44 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.6.tgz", - "integrity": "sha512-elPpdevtCdUOqziemR86C4CSCr/5sUxalzDrf/CJdMT+kZt2C556as++qHikNOz0vuFf52h+GJNXZM08eWgGPQ==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", "cpu": [ "arm64" ], - "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "cpu": [ + "s390x" + ], "license": "MIT", "optional": true, "os": [ @@ -1528,13 +1646,12 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.6.tgz", - "integrity": "sha512-IBwXsf56o3xhzAyaZxdM1CX8UFiBEUFCjiVUgny67Q8vPIqkjzJj0YKhd3TbBHanuxThgBa59f6Pgutg2OGk5A==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", + "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1545,13 +1662,12 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.6.tgz", - "integrity": "sha512-vOk7G8V9Zm+8a6PL6JTpCea61q491oYlGtO6CvnsbhNLlKdf0bbCPytFzGQhYmCKZDKkEbmnkcIprTEGCURnwg==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", + "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1562,13 +1678,12 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.6.tgz", - "integrity": "sha512-ASjEDI4MRv7XCQb2JVaBzfEYO98JKCGrAgoW6M03fJzH/ilCnC43Mb3ptB9q/lzsaahoJyIBoAGKAYEjUvpyvQ==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", + "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1579,13 +1694,12 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.6.tgz", - "integrity": "sha512-mYa1+h2l6Zc0LvmwUh0oXKKYihnw/1WC73vTqw+IgtfEtv47A+rWzzcWwVDkW73+UDr0d/Ie/HRXoaOY22pQDw==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", + "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1596,13 +1710,12 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.6.tgz", - "integrity": "sha512-e2ABskbNH3MRUBMjgxaMjYIw11DSwjLJxBII3UgpF6WClGLIh8A20kamc+FKH5vIaFVnYQInmcLYSUVpqMPLow==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1613,13 +1726,12 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.6.tgz", - "integrity": "sha512-dJVc3ifhaRXxIEh1xowLohzFrlQXkJ66LepHm+CmSprTWgVrPa8Fx3OL57xwIqDEH9hufcKkDX2v65rS3NZyRA==", + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", + "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1630,10 +1742,10 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.6.tgz", - "integrity": "sha512-Y0+JT8Mi1mmW08K6HieG315XNRu4L0rkfCpA364HtytjgiqYnMYRdFPcxRl+BQQqNXzecL2S9nii+RUpO93XIA==", - "dev": true, + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", + "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "devOptional": true, "license": "MIT" }, "node_modules/@rollup/rollup-linux-x64-gnu": { @@ -1682,7 +1794,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@streamparser/json": { @@ -1704,7 +1816,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1724,7 +1835,7 @@ "version": "2.53.4", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1819,7 +1930,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", @@ -1926,7 +2037,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1948,7 +2058,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/d3": { @@ -2256,7 +2366,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/@types/geojson": { @@ -2304,12 +2413,28 @@ "@types/unist": "*" } }, + "node_modules/@types/minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/normalize-package-data": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", + "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -2321,7 +2446,6 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "devOptional": true, "license": "MIT" }, "node_modules/@types/unist": { @@ -2657,16 +2781,16 @@ "license": "ISC" }, "node_modules/@vitest/expect": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0-beta.5.tgz", - "integrity": "sha512-rGZIMfkb+iEjL5+ulpWON5NY1y3bEc+I3btLlayKZ8wvsLhpS+wduc6DaNLWhFWxYwFXmnrLdMd9NRUE9cwySw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0-beta.5", - "@vitest/utils": "4.1.0-beta.5", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" }, @@ -2675,13 +2799,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0-beta.5.tgz", - "integrity": "sha512-oUE5vFOY7onbFjFGSbWSV6ryDbVmymRUQOQf978k6ZA7EmyRykUISqTiVZVzv/dpPH3IhnctJtHdoglYNG+SPQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0-beta.5", + "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2690,7 +2814,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -2702,9 +2826,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0-beta.5.tgz", - "integrity": "sha512-QH/FGecnl2uwLveL/n1awB/nm/dJL9M0vMKVwmW0tvLAqTOp5GQQOypRuVvpXNFGhIl2bfpUSjruuDQlCBeFjw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { @@ -2715,13 +2839,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0-beta.5.tgz", - "integrity": "sha512-9OP3INBsI9NhX0+n5syXtZOHooSFl+ctkDVbav6KlCO3CBV2g4TUPllYRAHsKWb6FvkpM2BVO8yO5jgqaZqnBA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0-beta.5", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -2736,14 +2860,14 @@ "license": "MIT" }, "node_modules/@vitest/snapshot": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0-beta.5.tgz", - "integrity": "sha512-ny6wFeFmA700AzTuB5qMSTKsLXtCz8m7CI2ESlcuGMrI7d9kdVUVD6ziRt0Hp2M3C3jIWvd5C6f4ZczAEFoytQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0-beta.5", - "@vitest/utils": "4.1.0-beta.5", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2759,9 +2883,9 @@ "license": "MIT" }, "node_modules/@vitest/spy": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0-beta.5.tgz", - "integrity": "sha512-eeiIMRR/xBXJxzGhbims+4UOim2bVXzSNf9bLRi0iHWWnXXaK/DS0pYpkLVO83EcqUWbBlqMx9R8Y6tLF6aCJQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", "funding": { @@ -2769,13 +2893,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0-beta.5.tgz", - "integrity": "sha512-yDobPgmVL/4YhVXsbBcmeUb5CIdZiJkoonPnuJXKOxmnj0XZyu7OgIX3KLOcRStbia3nJZ9VIIBWoSv+HS+wVA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0-beta.5", + "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" }, @@ -2813,6 +2937,13 @@ "@xterm/xterm": "^5.0.0" } }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT", + "peer": true + }, "node_modules/@xyflow/svelte": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@xyflow/svelte/-/svelte-1.3.1.tgz", @@ -2887,7 +3018,6 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3066,7 +3196,6 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -3082,6 +3211,17 @@ "node": ">=8" } }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -3099,6 +3239,17 @@ "node": ">=12" } }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/async-limiter": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", @@ -3167,7 +3318,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -3183,6 +3333,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/balanced-match": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz", + "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -3499,6 +3657,68 @@ "node": ">= 6" } }, + "node_modules/camelcase-keys": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-7.0.2.tgz", + "integrity": "sha512-Rjs1H+A9R+Ig+4E/9oyB66UC5Mj9Xq3N//vcLf2WzgdTi/3gUu3Z9KoqmlrEG4VuuLK8wJHofxzdQXz/knhiYg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "camelcase": "^6.3.0", + "map-obj": "^4.1.0", + "quick-lru": "^5.1.1", + "type-fest": "^1.2.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camelcase-keys/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -3726,7 +3946,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3843,7 +4062,7 @@ "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -3862,6 +4081,34 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/cross-fetch": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz", @@ -3899,6 +4146,17 @@ "postcss": "^8.0.9" } }, + "node_modules/css-functions-list": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.3.3.tgz", + "integrity": "sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -4241,6 +4499,60 @@ } } }, + "node_modules/decamelize": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-5.0.1.tgz", + "integrity": "sha512-VfxadyCECXgQlkoEAjeghAr5gY3Hf+IKjKb+X8tGVDtveCjN+USwprd2q3QXBR9T1+x2DG0XZF5/w+7HAtSaXA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.1.tgz", + "integrity": "sha512-WiPxgEirIV0/eIOMcnFBA3/IJZAZqKnwAwWyvvdi4lsr1WCN22nhdf/3db3DoZcUjTV2SqfzIwNyp6y2xs3nmg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "decamelize": "^1.1.0", + "map-obj": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decamelize-keys/node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/decamelize-keys/node_modules/map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -4318,7 +4630,7 @@ "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4420,7 +4732,6 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", - "dev": true, "license": "MIT" }, "node_modules/devlop": { @@ -4716,6 +5027,17 @@ "errno": "cli.js" } }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5143,7 +5465,6 @@ "version": "2.2.3", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -5340,6 +5661,17 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4.9.1" + } + }, "node_modules/fastpriorityqueue": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/fastpriorityqueue/-/fastpriorityqueue-0.7.2.tgz", @@ -5710,6 +6042,50 @@ "node": "*" } }, + "node_modules/global-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", + "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "global-prefix": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz", + "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ini": "^1.3.5", + "kind-of": "^6.0.2", + "which": "^1.3.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, "node_modules/globals": { "version": "13.24.0", "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", @@ -5747,6 +6123,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globjoin": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz", + "integrity": "sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5816,6 +6200,17 @@ "uglify-js": "^3.1.4" } }, + "node_modules/hard-rejection": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", + "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -6027,6 +6422,56 @@ "node": ">=12.0.0" } }, + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/html-tags": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.3.1.tgz", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -6097,6 +6542,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-lazy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -6107,6 +6563,20 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -6129,8 +6599,8 @@ "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "license": "ISC", - "optional": true + "devOptional": true, + "license": "ISC" }, "node_modules/inline-style-parser": { "version": "0.1.1", @@ -6165,6 +6635,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -6262,11 +6740,32 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -6346,7 +6845,7 @@ "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "jiti": "bin/jiti.js" @@ -6358,6 +6857,14 @@ "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==", "license": "BSD-3-Clause" }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -6387,6 +6894,14 @@ "dev": true, "license": "MIT" }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/json-refs": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/json-refs/-/json-refs-3.0.15.tgz", @@ -6546,11 +7061,22 @@ "json-buffer": "3.0.1" } }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -6782,10 +7308,10 @@ "license": "MIT" }, "node_modules/lightningcss": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", - "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", - "dev": true, + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "devOptional": true, "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -6798,27 +7324,26 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.31.1", - "lightningcss-darwin-arm64": "1.31.1", - "lightningcss-darwin-x64": "1.31.1", - "lightningcss-freebsd-x64": "1.31.1", - "lightningcss-linux-arm-gnueabihf": "1.31.1", - "lightningcss-linux-arm64-gnu": "1.31.1", - "lightningcss-linux-arm64-musl": "1.31.1", - "lightningcss-linux-x64-gnu": "1.31.1", - "lightningcss-linux-x64-musl": "1.31.1", - "lightningcss-win32-arm64-msvc": "1.31.1", - "lightningcss-win32-x64-msvc": "1.31.1" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", - "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6833,13 +7358,12 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", - "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6854,13 +7378,12 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", - "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6875,13 +7398,12 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", - "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6896,13 +7418,12 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", - "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6917,13 +7438,12 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", - "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6938,13 +7458,12 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", - "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6959,13 +7478,12 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", - "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -6980,13 +7498,12 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", - "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7001,13 +7518,12 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", - "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7022,13 +7538,12 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.31.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", - "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -7066,7 +7581,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/locate-path": { @@ -7131,6 +7645,14 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.truncate": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", + "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -7186,12 +7708,25 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/map-obj": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz", + "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mapbox-to-css-font": { "version": "2.4.5", "resolved": "https://registry.npmjs.org/mapbox-to-css-font/-/mapbox-to-css-font-2.4.5.tgz", @@ -7229,6 +7764,18 @@ "node": ">= 0.4" } }, + "node_modules/mathml-tag-names": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz", + "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -7458,6 +8005,48 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/meow": { + "version": "10.1.5", + "resolved": "https://registry.npmjs.org/meow/-/meow-10.1.5.tgz", + "integrity": "sha512-/d+PQ4GKmGvM9Bee/DPa8z3mXs/pkvJE2KEThngVNOqtmljC6K7NMPxtc2JeZYTmpWb9k/TmxjeL18ez3h7vCw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/minimist": "^1.2.2", + "camelcase-keys": "^7.0.0", + "decamelize": "^5.0.0", + "decamelize-keys": "^1.1.0", + "hard-rejection": "^2.1.0", + "minimist-options": "4.1.0", + "normalize-package-data": "^3.0.2", + "read-pkg-up": "^8.0.0", + "redent": "^4.0.0", + "trim-newlines": "^4.0.2", + "type-fest": "^1.2.2", + "yargs-parser": "^20.2.9" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/meow/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8138,6 +8727,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -8267,7 +8872,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -8424,6 +9029,23 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-package-data": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", + "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "hosted-git-info": "^4.0.1", + "is-core-module": "^2.5.0", + "semver": "^7.3.4", + "validate-npm-package-license": "^3.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -8547,7 +9169,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, + "devOptional": true, "funding": [ "https://github.com/sponsors/sxzz", "https://opencollective.com/debug" @@ -8753,6 +9375,26 @@ "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", "license": "MIT" }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parse5": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", @@ -8908,7 +9550,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/picomatch": { @@ -9020,10 +9662,10 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "devOptional": true, "funding": [ { "type": "opencollective", @@ -9611,6 +10253,14 @@ "postcss": "^8.4.31" } }, + "node_modules/postcss-resolve-nested-selector": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", + "integrity": "sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/postcss-safe-parser": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz", @@ -9727,7 +10377,7 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -10089,6 +10739,73 @@ "pify": "^2.3.0" } }, + "node_modules/read-pkg": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-6.0.0.tgz", + "integrity": "sha512-X1Fu3dPuk/8ZLsMhEj5f4wFAF0DWoK7qhGJvgaijocXxBmSToKfbFtqbxMO7bVjNA1dmE5huAzjXj/ey86iw9Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^3.0.2", + "parse-json": "^5.2.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-8.0.0.tgz", + "integrity": "sha512-snVCqPczksT0HS2EC+SxUndvSzn6LRCwpfSvLrIfR5BKDQQZMaI6jPRC9dYvYFDRAuFEAnkwww8kBBNE/3VvzQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "find-up": "^5.0.0", + "read-pkg": "^6.0.0", + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg-up/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/read-pkg/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -10118,6 +10835,24 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-4.0.0.tgz", + "integrity": "sha512-tYkDkVVtYkSVhuQ4zBgfvciymHaeuel+zFKXShfDnFP5SyVEP7qo70Rf1jTOTCx3vGNAbnEi/xFkcfQVMIBWag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "indent-string": "^5.0.0", + "strip-indent": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -10324,14 +11059,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.6", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.6.tgz", - "integrity": "sha512-B8vFPV1ADyegoYfhg+E7RAucYKv0xdVlwYYsIJgfPNeiSxZGWNxts9RqhyGzC11ULK/VaeXyKezGCwpMiH8Ktw==", - "dev": true, + "version": "1.0.0-rc.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", + "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.6" + "@rolldown/pluginutils": "1.0.0-rc.9" }, "bin": { "rolldown": "bin/cli.mjs" @@ -10340,19 +11075,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.6", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.6", - "@rolldown/binding-darwin-x64": "1.0.0-rc.6", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.6", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.6", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.6", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.6", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.6", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.6", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.6", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.6", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.6", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.6" + "@rolldown/binding-android-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", + "@rolldown/binding-darwin-x64": "1.0.0-rc.9", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" } }, "node_modules/run-parallel": { @@ -10469,7 +11206,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz", "integrity": "sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/set-function-length": { @@ -10680,7 +11417,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -10701,6 +11438,25 @@ "node": ">=8" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/sort-asc": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/sort-asc/-/sort-asc-0.1.0.tgz", @@ -10743,7 +11499,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -10759,6 +11515,46 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0", + "peer": true + }, + "node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0", + "peer": true + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10774,9 +11570,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, @@ -10845,6 +11641,20 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", + "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -10858,6 +11668,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-search": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz", + "integrity": "sha512-Dj1Okke1C3uKKwQcetra4jSuk0DqbzbYtXipzFlFMZtowbF1x7BKJwB9AayVMyFARvU8EDrZdcax4At/452cAg==", + "dev": true, + "license": "ISC", + "peer": true + }, "node_modules/style-to-object": { "version": "0.4.4", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", @@ -10899,6 +11717,66 @@ "node": ">=4" } }, + "node_modules/stylelint": { + "version": "15.11.0", + "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-15.11.0.tgz", + "integrity": "sha512-78O4c6IswZ9TzpcIiQJIN49K3qNoXTM8zEJzhaTE/xRTCZswaovSEVIa/uwbOltZrk16X4jAxjaOhzz/hTm1Kw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@csstools/css-parser-algorithms": "^2.3.1", + "@csstools/css-tokenizer": "^2.2.0", + "@csstools/media-query-list-parser": "^2.1.4", + "@csstools/selector-specificity": "^3.0.0", + "balanced-match": "^2.0.0", + "colord": "^2.9.3", + "cosmiconfig": "^8.2.0", + "css-functions-list": "^3.2.1", + "css-tree": "^2.3.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.1", + "fastest-levenshtein": "^1.0.16", + "file-entry-cache": "^7.0.0", + "global-modules": "^2.0.0", + "globby": "^11.1.0", + "globjoin": "^0.1.4", + "html-tags": "^3.3.1", + "ignore": "^5.2.4", + "import-lazy": "^4.0.0", + "imurmurhash": "^0.1.4", + "is-plain-object": "^5.0.0", + "known-css-properties": "^0.29.0", + "mathml-tag-names": "^2.1.3", + "meow": "^10.1.5", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.28", + "postcss-resolve-nested-selector": "^0.1.1", + "postcss-safe-parser": "^6.0.0", + "postcss-selector-parser": "^6.0.13", + "postcss-value-parser": "^4.2.0", + "resolve-from": "^5.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "style-search": "^0.1.0", + "supports-hyperlinks": "^3.0.0", + "svg-tags": "^1.0.0", + "table": "^6.8.1", + "write-file-atomic": "^5.0.1" + }, + "bin": { + "stylelint": "bin/stylelint.mjs" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/stylelint" + } + }, "node_modules/stylelint-config-recommended": { "version": "13.0.0", "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-13.0.0.tgz", @@ -10912,6 +11790,78 @@ "stylelint": "^15.10.0" } }, + "node_modules/stylelint/node_modules/@csstools/selector-specificity": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/selector-specificity/-/selector-specificity-3.1.1.tgz", + "integrity": "sha512-a7cxGcJ2wIlMFLlh8z2ONm+715QkPHiyJcxwQlKOz/03GPw1COpfhcmC9wm4xlZfp//jWHNNMwzjtqHXVWU9KA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peer": true, + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^6.0.13" + } + }, + "node_modules/stylelint/node_modules/file-entry-cache": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-7.0.2.tgz", + "integrity": "sha512-TfW7/1iI4Cy7Y8L6iqNdZQVvdXn0f8B4QcIXmkIbtTIe/Okm/nSlHb4IwGzRVOd3WfSieCgvf5cMzEfySAIl0g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "flat-cache": "^3.2.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/stylelint/node_modules/known-css-properties": { + "version": "0.29.0", + "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.29.0.tgz", + "integrity": "sha512-Ne7wqW7/9Cz54PDt4I3tcV+hAyat8ypyOGzYRJQfdxnnjeWsTxt1cy8pjvvKeI5kfXuyvULyeeAvwvvtAX3ayQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/stylelint/node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/stylelint/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -11036,6 +11986,24 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -11053,7 +12021,6 @@ "version": "5.53.5", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.5.tgz", "integrity": "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", @@ -11377,6 +12344,13 @@ "typescript": "^4.9.4 || ^5.0.0" } }, + "node_modules/svg-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz", + "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", + "dev": true, + "peer": true + }, "node_modules/svgo": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", @@ -11420,6 +12394,24 @@ "dev": true, "license": "MIT" }, + "node_modules/table": { + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", + "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "ajv": "^8.0.1", + "lodash.truncate": "^4.4.2", + "slice-ansi": "^4.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/tailwind-merge": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-1.14.0.tgz", @@ -11651,7 +12643,7 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -11668,7 +12660,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -11686,7 +12678,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -11696,9 +12688,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -11722,7 +12714,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -11744,6 +12736,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/trim-newlines": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-4.1.1.tgz", + "integrity": "sha512-jRKj0n0jXWo6kh62nA5TEh3+4igKDXLvzBJcPpiizP7oOolUrYIxmVBG9TOtHYFHoddUk6YvAkGeGoSVTXfQXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -11843,7 +12849,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12052,6 +13058,18 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -12095,17 +13113,17 @@ } }, "node_modules/vite": { - "version": "8.0.0-beta.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0-beta.16.tgz", - "integrity": "sha512-c0t7hYkxsjws89HH+BUFh/sL3BpPNhNsL9CJrTpMxBmwKQBRSa5OJ5w4o9O0bQVI/H/vx7UpUUIevvXa37NS/Q==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", + "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "devOptional": true, "license": "MIT", "dependencies": { "@oxc-project/runtime": "0.115.0", - "lightningcss": "^1.31.1", + "lightningcss": "^1.32.0", "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rolldown": "1.0.0-rc.6", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.9", "tinyglobby": "^0.2.15" }, "bin": { @@ -12195,7 +13213,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -12208,7 +13226,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", - "dev": true, + "devOptional": true, "license": "MIT", "workspaces": [ "tests/deps/*", @@ -12225,26 +13243,26 @@ } }, "node_modules/vitest": { - "version": "4.1.0-beta.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0-beta.5.tgz", - "integrity": "sha512-oFoeAOQednbyV7mR1hAmT4/yQ4xnNzvHUcU0lFwxo8riim0wsuh2EXF/xOtsT3q33ACeufP5BkWFVyJUC5B/DQ==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0-beta.5", - "@vitest/mocker": "4.1.0-beta.5", - "@vitest/pretty-format": "4.1.0-beta.5", - "@vitest/runner": "4.1.0-beta.5", - "@vitest/snapshot": "4.1.0-beta.5", - "@vitest/spy": "4.1.0-beta.5", - "@vitest/utils": "4.1.0-beta.5", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", @@ -12265,10 +13283,10 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0-beta.5", - "@vitest/browser-preview": "4.1.0-beta.5", - "@vitest/browser-webdriverio": "4.1.0-beta.5", - "@vitest/ui": "4.1.0-beta.5", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" @@ -12581,6 +13599,10 @@ "node": ">=8" } }, + "node_modules/windmill-parser-wasm": { + "resolved": "../backend/parsers/windmill-parser-wasm/pkg-ts", + "link": true + }, "node_modules/windmill-parser-wasm-asset": { "version": "1.653.0", "resolved": "https://registry.npmjs.org/windmill-parser-wasm-asset/-/windmill-parser-wasm-asset-1.653.0.tgz", @@ -12612,9 +13634,9 @@ "integrity": "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg==" }, "node_modules/windmill-parser-wasm-py": { - "version": "1.653.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.653.0.tgz", - "integrity": "sha512-vMkSL3JpELpag7nmyGA8onhYNiAG3K1mkh2k4vwVHC3W5dUd12fSS9gsBco2FqPGUPnWv1gCaHwmjBrOBVGL1w==" + "version": "1.657.2", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.657.2.tgz", + "integrity": "sha512-3CN2rziafgCWcZri812+CkzuaE3P3/7dXmV9lSDpK9ma6Esd4zkHRXUFSyRzQE/R7Fxj5mSmSNX6xTff8eX5mw==" }, "node_modules/windmill-parser-wasm-regex": { "version": "1.653.0", @@ -12632,9 +13654,9 @@ "integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ==" }, "node_modules/windmill-parser-wasm-ts": { - "version": "1.653.0", - "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.653.0.tgz", - "integrity": "sha512-zwBUy7ijo58ooAKcsYflISY/+xllCw3Aq34Kj1PED6uABWVbV6A8MWHqEsjiVzC6iWfihWNtZJpck8zsRr9DCg==" + "version": "1.657.2", + "resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.657.2.tgz", + "integrity": "sha512-tiOUVsMKTc85m/a2BKpgAN3xTz+OPrUhcjEBPJNTdzrQOir1G5WeNkQ403BW+d1qI0BAVWT0gZnJ+4AhavBP+w==" }, "node_modules/windmill-parser-wasm-yaml": { "version": "1.593.0", @@ -12779,6 +13801,21 @@ "devOptional": true, "license": "ISC" }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", @@ -12960,6 +13997,17 @@ "node": ">=12" } }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": ">=10" + } + }, "node_modules/yargs/node_modules/yargs-parser": { "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", @@ -13003,7 +14051,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, "license": "MIT" }, "node_modules/zod": { diff --git a/frontend/package.json b/frontend/package.json index 45a7f26220..854d85fa75 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "windmill-components", - "version": "1.654.0", + "version": "1.658.0", "scripts": { "dev": "vite dev", "build": "vite build", @@ -70,9 +70,9 @@ "tar": "^7.5.4", "tslib": "^2.6.1", "typescript": "^5.5.0", - "vite": "^8.0.0-beta.16", + "vite": "^8.0.0", "vite-plugin-mkcert": "^1.17.5", - "vitest": "^4.1.0-beta.5", + "vitest": "^4.1.0", "vitest-browser-svelte": "^2.0.1" }, "overrides": { @@ -148,17 +148,18 @@ "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", "vscode-ws-jsonrpc": "~3.5.0", + "windmill-parser-wasm": "file:../backend/parsers/windmill-parser-wasm/pkg-ts", "windmill-parser-wasm-asset": "1.653.0", "windmill-parser-wasm-csharp": "1.510.1", "windmill-parser-wasm-go": "1.510.1", "windmill-parser-wasm-java": "1.510.1", "windmill-parser-wasm-nu": "1.510.1", "windmill-parser-wasm-php": "1.647.1", - "windmill-parser-wasm-py": "1.653.0", + "windmill-parser-wasm-py": "1.657.2", "windmill-parser-wasm-regex": "1.653.0", "windmill-parser-wasm-ruby": "1.526.1", "windmill-parser-wasm-rust": "1.647.1", - "windmill-parser-wasm-ts": "1.653.0", + "windmill-parser-wasm-ts": "1.657.2", "windmill-parser-wasm-yaml": "1.593.0", "windmill-sql-datatype-parser-wasm": "1.512.0", "windmill-utils-internal": "^1.3.4", diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index 8a79bd7109..636a20f290 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -21,7 +21,7 @@ resourceType: string resourceTypeInfo: ResourceType | undefined args?: Record | any - linkedSecret?: string | undefined + linkedSecrets?: string[] isValid?: boolean linkedSecretCandidates?: string[] | undefined description?: string | undefined @@ -31,7 +31,7 @@ resourceType, resourceTypeInfo, args = $bindable({}), - linkedSecret = $bindable(undefined), + linkedSecrets = $bindable([]), isValid = $bindable(true), linkedSecretCandidates = undefined, description = $bindable(undefined) @@ -152,7 +152,7 @@ /> @@ -246,9 +246,7 @@ {/await} {:else if resourceTypeInfo?.is_fileset} -
- Fileset -
+
Fileset
{:else if resourceTypeInfo?.format_extension}
@@ -273,7 +271,7 @@ onlyMaskPassword noDelete {linkedSecretCandidates} - bind:linkedSecret + bind:linkedSecrets isValid {schema} bind:args diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index 5b4faf2fe6..d7b37fcdd9 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -84,14 +84,22 @@ ) } - let linkedSecret: string | undefined = $state(undefined) + let linkedSecrets: string[] = $state([]) let linkedSecretCandidates: string[] | undefined = $state(undefined) - function computeLinkedSecret(resourceType: string, argsKeys: string[], passwords: string[]) { + function computeDefaultLinkedSecrets( + resourceType: string, + argsKeys: string[], + passwords: string[] + ): string[] { linkedSecretCandidates = computeCandidates(resourceType, argsKeys, passwords) - return ( - forceSecretValue(resourceType) ?? - linkedSecretCandidates?.sort((ua, ub) => linkedSecretValue(ub) - linkedSecretValue(ua))?.[0] - ) + const forced = forceSecretValue(resourceType) + if (forced) { + return [forced] + } + const best = linkedSecretCandidates?.sort( + (ua, ub) => linkedSecretValue(ub) - linkedSecretValue(ua) + )?.[0] + return best ? [best] : [] } let scopes: string[] = $state([]) @@ -194,7 +202,7 @@ args['password'] == '' && args['api_key'] == '' && args['key'] == '' && - linkedSecret != undefined + linkedSecrets.length > 0 : false)) || step == 3 || (step == 4 && pathError != '') || @@ -317,13 +325,13 @@ const passwords = newArgsKeys.filter((x) => { return props?.[x]?.password }) - if (!linkedSecret) { - linkedSecret = computeLinkedSecret(resourceType, newArgsKeys, passwords) + if (linkedSecrets.length === 0) { + linkedSecrets = computeDefaultLinkedSecrets(resourceType, newArgsKeys, passwords) } } export async function next() { if (step == 1) { - linkedSecret = undefined + linkedSecrets = [] if (manual) { getResourceTypeInfo() args = {} @@ -408,14 +416,30 @@ if (step == 2) return throw Error('Path is not set') } - let exists = await VariableService.existsVariable({ - workspace: $workspaceStore!, - path - }) - if (exists) { - throw Error(`Variable at path ${path} already exists. Delete it or pick another path`) + // Check if variable paths already exist + if (!manual || linkedSecrets.length <= 1) { + const exists = await VariableService.existsVariable({ + workspace: $workspaceStore!, + path + }) + if (exists) { + throw Error(`Variable at path ${path} already exists. Delete it or pick another path`) + } + } else { + for (const secretField of linkedSecrets) { + const varPath = `${path}_${secretField}` + const exists = await VariableService.existsVariable({ + workspace: $workspaceStore!, + path: varPath + }) + if (exists) { + throw Error( + `Variable at path ${varPath} already exists. Delete it or pick another path` + ) + } + } } - exists = await ResourceService.existsResource({ + let exists = await ResourceService.existsResource({ workspace: $workspaceStore!, path }) @@ -462,25 +486,65 @@ const resourceValue = args - let saveVariable = false - if (!manual || linkedSecret != undefined) { - let v = manual ? args[linkedSecret ?? ''] : value + let savedVariableCount = 0 + if (!manual) { + // OAuth flow: single secret variable for the token + if (typeof value == 'string' && value != '' && !value.startsWith('$var:')) { + savedVariableCount++ + await VariableService.createVariable({ + workspace: $workspaceStore!, + requestBody: { + path, + value: value, + is_secret: true, + description: emptyString(description) + ? `OAuth token for ${resourceType}` + : description, + is_oauth: true, + account: account + } + }) + resourceValue['token'] = `$var:${path}` + } + } else if (linkedSecrets.length === 1) { + // Single secret: use the resource path as variable name (original behavior) + const secretField = linkedSecrets[0] + const v = args[secretField] if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) { - saveVariable = true + savedVariableCount++ await VariableService.createVariable({ workspace: $workspaceStore!, requestBody: { path, value: v, is_secret: true, - description: emptyString(description) - ? `${manual ? 'Token' : 'OAuth token'} for ${resourceType}` - : description, - is_oauth: !manual, - account: account + description: emptyString(description) ? `Token for ${resourceType}` : description, + is_oauth: false } }) - resourceValue[linkedSecret ?? 'token'] = `$var:${path}` + resourceValue[secretField] = `$var:${path}` + } + } else if (linkedSecrets.length > 1) { + // Multiple secrets: append _field_name to each variable path + for (const secretField of linkedSecrets) { + const v = args[secretField] + if (typeof v == 'string' && v != '' && !v.startsWith('$var:')) { + const varPath = `${path}_${secretField}` + savedVariableCount++ + await VariableService.createVariable({ + workspace: $workspaceStore!, + requestBody: { + path: varPath, + value: v, + is_secret: true, + description: emptyString(description) + ? `${secretField} for ${resourceType}` + : description, + is_oauth: false + } + }) + resourceValue[secretField] = `$var:${varPath}` + } } } @@ -495,7 +559,9 @@ }) dispatch('refresh', path) dispatch('close') - sendUserToast(`Saved resource${saveVariable ? ' and variable' : ''} path: ${path}`) + sendUserToast( + `Saved resource${savedVariableCount > 0 ? ` and ${savedVariableCount} variable${savedVariableCount > 1 ? 's' : ''}` : ''} path: ${path}` + ) step = 1 resourceType = '' } @@ -738,7 +804,7 @@ {#key resourceTypeInfo} {@render right?.()} - {#if scriptPath && !noHistory} + {#if scriptPath && !noHistory && customUi?.history != false} remove - {/each} + + {/each} - {/snippet} + {/snippet} {:else}
- {#each new Array(6) as _} + {#each new Array(6) as _, i (i)} {/each}
diff --git a/frontend/src/lib/components/InstanceSetting.svelte b/frontend/src/lib/components/InstanceSetting.svelte index 663bb8aa75..1c69ac8a80 100644 --- a/frontend/src/lib/components/InstanceSetting.svelte +++ b/frontend/src/lib/components/InstanceSetting.svelte @@ -22,6 +22,7 @@ import CriticalAlertChannels from './instanceSettings/CriticalAlertChannels.svelte' import SmtpSettings from './instanceSettings/SmtpSettings.svelte' import SecretBackendConfig from './instanceSettings/SecretBackendConfig.svelte' + import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte' import IndexerMemorySettings from './instanceSettings/IndexerMemorySettings.svelte' import IndexerJobIndexSettings from './instanceSettings/IndexerJobIndexSettings.svelte' import IndexerLogIndexSettings from './instanceSettings/IndexerLogIndexSettings.svelte' @@ -716,6 +717,8 @@ {:else if setting.fieldType == 'secret_backend'} + {:else if setting.fieldType == 'github_enterprise_app'} + {/if} {#if hasError} diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index b9d355f323..5f5ac925f4 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -30,6 +30,7 @@ quickSetup?: boolean yamlMode?: boolean hasUnsavedChanges?: boolean + hasAnyInvalid?: boolean } let { @@ -40,7 +41,8 @@ onNavigateToTab, quickSetup = false, yamlMode = $bindable(false), - hasUnsavedChanges = $bindable(false) + hasUnsavedChanges = $bindable(false), + hasAnyInvalid = $bindable(false) }: Props = $props() let values: Writable> = writable({}) @@ -77,7 +79,8 @@ smtp_settings: {}, otel: {}, indexer_settings: {}, - critical_error_channels: [] + critical_error_channels: [], + github_enterprise_app: {} } function applyFormDefaults(vals: Record): void { @@ -438,6 +441,10 @@ return result }) + $effect(() => { + hasAnyInvalid = Object.values(invalidCategories).some(Boolean) + }) + export function isDirty(category: string): boolean { return dirtyCategories[category] ?? false } @@ -601,7 +608,8 @@ secret_backend: ['token'], object_store_cache_config: ['secret_key', 'serviceAccountKey'], custom_instance_pg_databases: ['user_pwd'], - rsa_keys: ['private_key'] + rsa_keys: ['private_key'], + github_enterprise_app: ['private_key'] } /** Returns SENSITIVE_UNCHANGED if the value is non-empty and matches the initial */ @@ -1008,6 +1016,11 @@ description="Configure where secrets (secret variables) are stored." link="https://www.windmill.dev/docs/core_concepts/workspace_secret_encryption" /> + {:else if category == 'GitHub Enterprise App'} + {:else if category == 'Auth/OAuth/SAML'} | null ): Promise { return abstractRun( () => @@ -310,7 +311,8 @@ tag, lock, script_hash: hash, - flow_path: flowPath + flow_path: flowPath, + modules: modules ?? undefined } }), callbacks diff --git a/frontend/src/lib/components/NoMainFuncBadge.svelte b/frontend/src/lib/components/NoMainFuncBadge.svelte index 3e2a2d28bb..c72e05583c 100644 --- a/frontend/src/lib/components/NoMainFuncBadge.svelte +++ b/frontend/src/lib/components/NoMainFuncBadge.svelte @@ -5,7 +5,7 @@ {#snippet text()} - The script has no main function exported + Library script (no exported main function) {/snippet} - No main + Library diff --git a/frontend/src/lib/components/SchemaForm.svelte b/frontend/src/lib/components/SchemaForm.svelte index cb8828286c..2cdc004559 100644 --- a/frontend/src/lib/components/SchemaForm.svelte +++ b/frontend/src/lib/components/SchemaForm.svelte @@ -34,7 +34,7 @@ defaultValues?: Record shouldHideNoInputs?: boolean compact?: boolean - linkedSecret?: string | undefined + linkedSecrets?: string[] linkedSecretCandidates?: string[] | undefined noVariablePicker?: boolean flexWrap?: boolean @@ -86,7 +86,7 @@ defaultValues = {}, shouldHideNoInputs = false, compact = false, - linkedSecret = $bindable(undefined), + linkedSecrets = $bindable([]), linkedSecretCandidates = undefined, noVariablePicker = false, flexWrap = false, @@ -333,7 +333,7 @@ {variableEditor} {itemPicker} {pickForField} - password={linkedSecret == argName} + password={linkedSecrets.includes(argName)} extra={formerProperty} {showSchemaExplorer} simpleTooltip={schemaFieldTooltip[argName]} @@ -398,22 +398,24 @@ customErrorMessage={prop?.customErrorMessage} bind:properties={ () => prop?.properties, - (v) => { if (prop) prop.properties = v } + (v) => { + if (prop) prop.properties = v + } } bind:order={ () => prop?.order, - (v) => { if (prop) prop.order = v } + (v) => { + if (prop) prop.order = v + } } nestedRequired={prop?.required} itemsType={prop?.items} - disabled={disabledArgs.includes(argName) || - disabled || - prop?.disabled} + disabled={disabledArgs.includes(argName) || disabled || prop?.disabled} {compact} {variableEditor} {itemPicker} bind:pickForField - password={linkedSecret == argName} + password={linkedSecrets.includes(argName)} extra={prop} {showSchemaExplorer} simpleTooltip={schemaFieldTooltip[argName]} @@ -440,12 +442,14 @@ {#if linkedSecretCandidates?.includes(argName)}
{ if (e.detail === 'secret') { - linkedSecret = argName - } else if (linkedSecret == argName) { - linkedSecret = undefined + if (!linkedSecrets.includes(argName)) { + linkedSecrets = [...linkedSecrets, argName] + } + } else { + linkedSecrets = linkedSecrets.filter((s) => s !== argName) } }} > diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 7de0926f0a..eb8709751c 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -107,6 +107,22 @@ import { isRuleActive } from '$lib/workspaceProtectionRules.svelte' import { buildForkEditUrl } from '$lib/utils/editInFork' import OnBehalfOfSelector, { type OnBehalfOfChoice } from './OnBehalfOfSelector.svelte' + import WacExportDrawer from './scripts/WacExportDrawer.svelte' + import Modal from './common/modal/Modal.svelte' + + const WAC_ALPHA_ACK_KEY = 'windmill_wac_alpha_ack' + let wacAlphaModalOpen = $state(false) + + function showWacAlphaModalIfNeeded() { + if (typeof sessionStorage !== 'undefined' && sessionStorage.getItem(WAC_ALPHA_ACK_KEY) !== 'true') { + wacAlphaModalOpen = true + } + } + + function acknowledgeWacAlpha() { + sessionStorage.setItem(WAC_ALPHA_ACK_KEY, 'true') + wacAlphaModalOpen = false + } let { script = $bindable(), @@ -182,6 +198,7 @@ let editor: Editor | undefined = $state(undefined) let scriptEditor: ScriptEditor | undefined = $state(undefined) let captureTable: CaptureTable | undefined = $state(undefined) + let wacExportDrawer: WacExportDrawer | undefined = $state(undefined) // Draft triggers confirmation modal let draftTriggersModalOpen = $state(false) @@ -362,6 +379,13 @@ } if (script.content == '') { + if (template === 'wac_python') { + script.modules = { 'helper.py': { content: 'def main(a: str) -> str:\n return f"hello {a}"\n', language: 'python3' } } + showWacAlphaModalIfNeeded() + } else if (template === 'wac_typescript') { + script.modules = { 'helper.ts': { content: 'export function main(a: string): string {\n return `hello ${a}`\n}\n', language: 'bun' } } + showWacAlphaModalIfNeeded() + } initContent(script.language, script.kind, template) } @@ -388,7 +412,7 @@ async function initContent( language: SupportedLanguage, kind: Script['kind'] | undefined, - template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' + template: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' | 'wac_python' | 'wac_typescript' ) { scriptEditor?.disableCollaboration() const templateScript = await isTemplateScript() @@ -403,6 +427,7 @@ } async function handleEditScript(stay: boolean, deployMsg?: string): Promise { + scriptEditor?.flushModuleState() // Fetch latest version and fetch entire script after if needed let actual_parent_hash: string | undefined = undefined @@ -510,10 +535,10 @@ script.kind === 'preprocessor' ? 'preprocessor' : undefined ) if (script.kind === 'preprocessor') { - script.no_main_func = undefined + script.auto_kind = undefined script.has_preprocessor = undefined } else { - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } } catch (error) { @@ -554,12 +579,13 @@ timeout: script.timeout, concurrency_key: emptyString(script.concurrency_key) ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, has_preprocessor: script.has_preprocessor, deployment_message: deploymentMsg || undefined, on_behalf_of_email: script.on_behalf_of_email, preserve_on_behalf_of: preserveOnBehalfOf || undefined, - assets: script.assets + assets: script.assets, + modules: script.modules } }) @@ -592,7 +618,7 @@ if (!disableHistoryChange) { history.replaceState(history.state, '', `/scripts/edit/${script.path}`) } - if (stay || (script.no_main_func && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language))) { + if (stay || (script.auto_kind === 'lib' && script.kind !== 'preprocessor' && !isWorkflowAsCode(script.content, script.language))) { script.parent_hash = newHash sendUserToast('Deployed') } else { @@ -606,6 +632,7 @@ } async function saveDraft(forceSave = false): Promise { + scriptEditor?.flushModuleState() if (initialPath != '' && !savedScript) { return } @@ -643,10 +670,10 @@ script.kind === 'preprocessor' ? 'preprocessor' : undefined ) if (script.kind === 'preprocessor') { - script.no_main_func = undefined + script.auto_kind = undefined script.has_preprocessor = undefined } else { - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } } catch (error) { @@ -707,10 +734,11 @@ ? undefined : script.concurrency_key, visible_to_runner_only: script.visible_to_runner_only, - no_main_func: script.no_main_func, + auto_kind: script.auto_kind, has_preprocessor: script.has_preprocessor, on_behalf_of_email: script.on_behalf_of_email, - assets: script.assets + assets: script.assets, + modules: script.modules } }) } @@ -816,7 +844,7 @@ } ] : []), - ...(!script.draft_only && script.kind === 'script' && !script.no_main_func + ...(!script.draft_only && script.kind === 'script' && !script.auto_kind ? [ { label: 'Exit & See details', @@ -825,10 +853,34 @@ } } ] + : []), + ...(isWorkflowAsCode(script.content, script.language) + ? [ + { + label: 'Export as YAML/JSON', + onClick: () => { + wacExportDrawer?.open(script) + } + } + ] : []) ] : [] + if ( + dropdownItems.length === 0 && + isWorkflowAsCode(script.content, script.language) + ) { + dropdownItems = [ + { + label: 'Export as YAML/JSON', + onClick: () => { + wacExportDrawer?.open(script) + } + } + ] + } + return dropdownItems.length > 0 ? dropdownItems : undefined } @@ -1201,7 +1253,7 @@
{/if} -
+
Template + + + + + +
{#if customUi?.settingsPanel?.metadata?.disableScriptKind !== true}
@@ -1948,9 +2040,23 @@ bind:hasPreprocessor bind:captureTable bind:assets={script.assets} + bind:modules={script.modules} enablePreprocessorSnippet />
{:else} Script Builder not available to operators {/if} + + + + +
+

+ Workflow-as-Code is in alpha — use in production at your own risk. It is an alternative to the Flow editor for advanced users. Feedback welcome on GitHub or Discord. +

+
+ +
+
+
diff --git a/frontend/src/lib/components/ScriptEditor.svelte b/frontend/src/lib/components/ScriptEditor.svelte index f310010c18..600dfde5af 100644 --- a/frontend/src/lib/components/ScriptEditor.svelte +++ b/frontend/src/lib/components/ScriptEditor.svelte @@ -2,7 +2,14 @@ import { BROWSER } from 'esm-env' import type { Schema, SupportedLanguage } from '$lib/common' - import { type CompletedJob, type Job, JobService, type Preview, type ScriptLang } from '$lib/gen' + import { + type CompletedJob, + type Job, + JobService, + type Preview, + type ScriptLang, + type ScriptModule + } from '$lib/gen' import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' import { copyToClipboard, @@ -25,8 +32,10 @@ import WindmillIcon from './icons/WindmillIcon.svelte' import * as Y from 'yjs' import { scriptLangToEditorLang } from '$lib/scripts' + import { langToExt } from '$lib/editorLangUtils' import { WebsocketProvider } from 'y-websocket' import Modal from './common/modal/Modal.svelte' + import Popover from './meltComponents/Popover.svelte' import DiffEditor from './DiffEditor.svelte' import { AlertTriangle, @@ -40,8 +49,11 @@ GitBranch, Play, PlayIcon, + Plus, Terminal, - WandSparkles + Pencil, + WandSparkles, + X } from 'lucide-svelte' import { DebugToolbar, @@ -100,7 +112,16 @@ path: string | undefined lang: Preview['language'] kind?: string | undefined - template?: 'pgsql' | 'mysql' | 'script' | 'docker' | 'powershell' | 'bunnative' | 'claudesandbox' + template?: + | 'pgsql' + | 'mysql' + | 'script' + | 'docker' + | 'powershell' + | 'bunnative' + | 'claudesandbox' + | 'wac_python' + | 'wac_typescript' tag: string | undefined initialArgs?: Record fixedOverflowWidgets?: boolean @@ -123,6 +144,7 @@ lastDeployedCode?: string | undefined disableAi?: boolean assets?: AssetWithAltAccessType[] + modules?: { [key: string]: ScriptModule } | null editorBarRight?: import('svelte').Snippet enablePreprocessorSnippet?: boolean } @@ -155,6 +177,7 @@ lastDeployedCode = undefined, disableAi = false, assets = $bindable(), + modules = $bindable(undefined), editorBarRight, enablePreprocessorSnippet = false }: Props = $props() @@ -163,6 +186,267 @@ let jsonView = $state(false) let schemaHeight = $state(0) + // Module tab state + let activeModuleTab: string | null = $state(null) + // editorCode is what the editor shows; code always holds the main script content + let editorCode: string = $state(code) + // Sync editorCode when code changes externally (template reset, copilot, etc.) + let lastSyncedCode = code + $effect.pre(() => { + if (activeModuleTab === null && code !== lastSyncedCode) { + editorCode = code + lastSyncedCode = code + } + }) + + function switchToModule(modulePath: string) { + if (activeModuleTab !== null && modules && activeModuleTab !== modulePath) { + // Switching from another module: save its content + modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + } + if (modules && modules[modulePath]) { + activeModuleTab = modulePath + editorCode = modules[modulePath].content + editor?.setCode(editorCode) + } + } + + function switchToMain() { + if (activeModuleTab !== null && modules) { + // Save current module content + modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + } + activeModuleTab = null + editorCode = code + lastSyncedCode = code + editor?.setCode(editorCode) + } + + let effectiveLang = $derived( + activeModuleTab && modules?.[activeModuleTab] + ? (modules[activeModuleTab].language as Preview['language']) + : lang + ) + + let isWacV2 = $derived.by(() => { + const mainCode = code + const isTsWac = + mainCode.includes('windmill-client') && + mainCode.includes('workflow') && + mainCode.includes('task') + const isPyWac = + (mainCode.includes('import wmill') || mainCode.includes('from wmill')) && + mainCode.includes('workflow') && + mainCode.includes('task') + return isTsWac || isPyWac + }) + let supportsModules = $derived((lang === 'bun' || lang === 'python3') && isWacV2) + let mainFileName = $derived('script.' + langToExt(scriptLangToEditorLang(lang))) + + let modulePathInput = $state('') + let showAddModulePopover = $state(false) + let modulePathInputEl: HTMLInputElement | undefined = $state(undefined) + let modulePathError = $state('') + + let renameModuleInput = $state('') + let renameModuleError = $state('') + let renameModuleInputEl: HTMLInputElement | undefined = $state(undefined) + + const ALL_MODULE_EXTENSIONS: Record = { + '.ts': 'bun', + '.py': 'python3', + '.go': 'go', + '.sh': 'bash', + '.ps1': 'powershell', + '.sql': 'postgresql', + '.gql': 'graphql', + '.php': 'php', + '.rs': 'rust', + '.yml': 'ansible', + '.cs': 'csharp', + '.nu': 'nu', + '.java': 'java', + '.rb': 'ruby' + } + + /** Map main script language to allowed module file extensions. */ + const LANG_MODULE_EXTENSIONS: Partial> = { + python3: ['.py'], + bun: ['.ts'], + deno: ['.ts'], + nativets: ['.ts'], + go: ['.go'], + bash: ['.sh'], + powershell: ['.ps1'], + postgresql: ['.sql'], + mysql: ['.sql'], + bigquery: ['.sql'], + snowflake: ['.sql'], + mssql: ['.sql'], + oracledb: ['.sql'], + duckdb: ['.sql'], + graphql: ['.gql'], + php: ['.php'], + rust: ['.rs'], + ansible: ['.yml'], + csharp: ['.cs'], + nu: ['.nu'], + java: ['.java'], + ruby: ['.rb'], + bunnative: ['.ts'] + } + + let allowedModuleExtensions = $derived( + lang + ? (LANG_MODULE_EXTENSIONS[lang] ?? Object.keys(ALL_MODULE_EXTENSIONS)) + : Object.keys(ALL_MODULE_EXTENSIONS) + ) + + function inferModuleLang(filePath: string): ScriptModule['language'] | undefined { + for (const [ext, moduleLang] of Object.entries(ALL_MODULE_EXTENSIONS)) { + if (filePath.endsWith(ext)) return moduleLang + } + return undefined + } + + function getModuleDefaultContent(filePath: string): string { + if (filePath.endsWith('.py')) { + return `def hello() -> str:\n return "world"\n` + } else if (filePath.endsWith('.ts')) { + return `export function hello(): string {\n return "world"\n}\n` + } else if (filePath.endsWith('.go')) { + return `package inner\n\nfunc Hello() string {\n\treturn "world"\n}\n` + } else if (filePath.endsWith('.sh')) { + return `#!/bin/bash\necho "world"\n` + } else if (filePath.endsWith('.ps1')) { + return `function Hello {\n return "world"\n}\n` + } else if (filePath.endsWith('.sql')) { + return `SELECT 'world' as result;\n` + } else if (filePath.endsWith('.gql')) { + return `query Hello {\n hello\n}\n` + } else if (filePath.endsWith('.php')) { + return ` String {\n "world".to_string()\n}\n` + } else if (filePath.endsWith('.yml')) { + return `---\n- name: Hello\n debug:\n msg: "world"\n` + } else if (filePath.endsWith('.cs')) { + return `public static string Hello() {\n return "world";\n}\n` + } else if (filePath.endsWith('.nu')) { + return `def hello [] {\n "world"\n}\n` + } else if (filePath.endsWith('.java')) { + return `public class Helper {\n public static String hello() {\n return "world";\n }\n}\n` + } else if (filePath.endsWith('.rb')) { + return `def hello\n "world"\nend\n` + } + return '' + } + + function validateModulePath(path: string): string { + if (!path.trim()) return '' + const moduleLang = inferModuleLang(path) + if (!moduleLang) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension: ${exts}` + } + const matchedExt = allowedModuleExtensions.find((ext) => path.endsWith(ext)) + if (!matchedExt) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension for this language: ${exts}` + } + if (modules?.[path.trim()]) { + return `Module ${path.trim()} already exists` + } + return '' + } + + function addModule() { + const modulePath = modulePathInput.trim() + if (!modulePath) return + const error = validateModulePath(modulePath) + if (error) { + modulePathError = error + return + } + if (!modules) { + modules = {} + } + modules[modulePath] = { + content: getModuleDefaultContent(modulePath), + language: inferModuleLang(modulePath)! + } + modulePathInput = '' + modulePathError = '' + showAddModulePopover = false + switchToModule(modulePath) + } + + function removeModule(modulePath: string) { + if (!modules) return + if (activeModuleTab === modulePath) { + switchToMain() + } + delete modules[modulePath] + modules = { ...modules } + } + + function validateRenameModulePath(newPath: string, oldPath: string): string { + if (!newPath.trim()) return '' + const moduleLang = inferModuleLang(newPath) + if (!moduleLang) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension: ${exts}` + } + const matchedExt = allowedModuleExtensions.find((ext) => newPath.endsWith(ext)) + if (!matchedExt) { + const exts = allowedModuleExtensions.join(', ') + return `File must end with a supported extension for this language: ${exts}` + } + if (newPath.trim() !== oldPath && modules?.[newPath.trim()]) { + return `Module ${newPath.trim()} already exists` + } + return '' + } + + function renameModule(oldPath: string) { + const newPath = renameModuleInput.trim() + if (!newPath || newPath === oldPath) { + return + } + const error = validateRenameModulePath(newPath, oldPath) + if (error) { + renameModuleError = error + return + } + if (!modules) return + const mod = modules[oldPath] + const newLang = inferModuleLang(newPath) + delete modules[oldPath] + modules[newPath] = { ...mod, language: newLang ?? mod.language } + modules = { ...modules } + if (activeModuleTab === oldPath) { + activeModuleTab = newPath + } + renameModuleInput = '' + renameModuleError = '' + } + + /** Save the active module tab's editor content back into the modules map (no UI side-effects). */ + function flushModuleContent() { + if (activeModuleTab !== null && modules) { + modules[activeModuleTab] = { ...modules[activeModuleTab], content: editorCode } + } + } + + /** Flush module content and reset the editor back to the main script tab. */ + export function flushModuleState() { + if (activeModuleTab !== null && modules) { + flushModuleContent() + activeModuleTab = null + editorCode = code + } + } + $effect.pre(() => { if (schema == undefined) { schema = emptySchema() @@ -329,6 +613,8 @@ export async function runTest() { // Not defined if JobProgressBar not loaded jobProgressBar?.reset() + // Flush module edits back to modules map before running preview + flushModuleContent() //@ts-ignore let job = await jobLoader.runPreview( path, @@ -355,7 +641,9 @@ } console.error(error) } - } + }, + undefined, + modules ) logPanel?.setFocusToLogs() return job @@ -410,8 +698,7 @@ selectedTab = 'main' } else { hasPreprocessor = - (selectedTab === 'preprocessor' ? !result?.no_main_func : result?.has_preprocessor) ?? - false + (selectedTab === 'preprocessor' ? !result?.auto_kind : result?.has_preprocessor) ?? false if (!hasPreprocessor && selectedTab === 'preprocessor') { selectedTab = 'main' @@ -1316,140 +1603,333 @@ +{#snippet addModuleForm(close: () => void)} +
+ + { + modulePathError = validateModulePath(modulePathInput) + }} + onkeydown={(e) => { + if (e.key === 'Enter') addModule() + if (e.key === 'Escape') close() + }} + /> + {#if modulePathError} +

{modulePathError}

+ {/if} +

Supports subfolders, e.g. utils/math{allowedModuleExtensions[0] ?? '.ts'}

+
+ + +
+
+{/snippet} + +{#snippet renameModuleForm(oldPath: string, close: () => void)} +
+ + { + renameModuleError = validateRenameModulePath(renameModuleInput, oldPath) + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + renameModule(oldPath) + close() + } + if (e.key === 'Escape') close() + }} + /> + {#if renameModuleError} +

{renameModuleError}

+ {/if} +
+ + +
+
+{/snippet} + {#snippet editorContent()} -
-
- {#if assets?.length} - - {/if} - {#if isDebuggableScript && customUi?.editorBar?.debug != false} - - {/if} - {#if showDebugPanel && !showDebugConsole} - + {#each Object.keys(modules ?? {}) as modulePath} +
+ +
+ + {#snippet trigger()} + { + e.stopPropagation() + renameModuleInput = modulePath + renameModuleError = '' + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + renameModuleInput = modulePath + renameModuleError = '' + } + }} + > + + + {/snippet} + {#snippet content({ close })} + {@render renameModuleForm(modulePath, close)} + {/snippet} + + { + e.stopPropagation() + removeModule(modulePath) + }} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.stopPropagation() + removeModule(modulePath) + } + }} + > + + +
+
+ {/each} + - Console - - {/if} - {#if lang === 'ansible' && hasDelegateToGitRepo} - - {/if} - {#if testPanelSize === 0} - +
+ {/if} +
+
+ {#if assets?.length} + + {/if} + {#if isDebuggableScript && customUi?.editorBar?.debug != false} + + {/if} + {#if showDebugPanel && !showDebugConsole} + + {/if} + {#if lang === 'ansible' && hasDelegateToGitRepo} + + {/if} + {#if testPanelSize === 0} + btnClasses="bg-marine-400 hover:bg-marine-200 !text-primary-inverse hover:!text-primary-inverse hover:dark:!text-primary-inverse dark:bg-marine-50 dark:hover:bg-marine-50/70" + color="marine" + /> {/if} + {#if !aiChatManager.open && !disableAi} + {#if customUi?.editorBar?.aiGen != false && SUPPORTED_CHAT_SCRIPT_LANGUAGES.includes(lang ?? '')} + + {/if} + {/if} +
+ + {#if debugConsoleVisible} + + + + {@render editorPane()} + + + (showDebugConsole = false)} + workspace={$workspaceStore} + jobId={debugSessionJobId ?? undefined} + /> + + + {:else} + +
+ {@render editorPane()} +
{/if}
- - {#if debugConsoleVisible} - - - - {@render editorPane()} - - - (showDebugConsole = false)} - workspace={$workspaceStore} - jobId={debugSessionJobId ?? undefined} - /> - - - {:else} - -
- {@render editorPane()} -
- {/if}
{/snippet} {#snippet editorPane()} - {#key lang} + {#key effectiveLang} { - inferSchema(e.detail) + if (activeModuleTab === null) { + code = editorCode + lastSyncedCode = code + inferSchema(e.detail) + } else { + flushModuleContent() + } // Refresh breakpoint positions when code changes (decorations track their lines) if (debugMode && breakpointDecorations.length > 0) { refreshBreakpointPositions() @@ -1458,20 +1938,24 @@ on:saveDraft on:toggleTestPanel={toggleTestPanel} cmdEnterAction={async () => { - await inferSchema(code) + if (activeModuleTab === null) { + await inferSchema(editorCode) + } runTest() }} formatAction={async () => { - await inferSchema(code) + if (activeModuleTab === null) { + await inferSchema(editorCode) + } try { - localStorage.setItem(path ?? 'last_save', code) + localStorage.setItem(path ?? 'last_save', activeModuleTab === null ? editorCode : code) } catch (e) { console.error('Could not save last_save to local storage', e) } dispatch('format') }} class="flex flex-1 h-full !overflow-visible" - scriptLang={lang} + scriptLang={effectiveLang} automaticLayout={true} {fixedOverflowWidgets} {args} diff --git a/frontend/src/lib/components/SuperadminSettings.svelte b/frontend/src/lib/components/SuperadminSettings.svelte index c6cbe9f421..b9dbb7c6d6 100644 --- a/frontend/src/lib/components/SuperadminSettings.svelte +++ b/frontend/src/lib/components/SuperadminSettings.svelte @@ -23,6 +23,7 @@ let uptodateVersion: string | undefined = $state(undefined) let yamlMode = $state(false) let hasUnsavedChanges = $state(false) + let hasAnyInvalid = $state(false) let showCloseConfirmModal = $state(false) let diffData: { original: string; modified: string } = $state({ original: '', modified: '' }) let inlineDiff = $state(false) @@ -147,6 +148,7 @@ showHeaderInfo={false} bind:yamlMode bind:hasUnsavedChanges + bind:hasAnyInvalid /> @@ -159,7 +161,7 @@ options={{ right: 'Unified' }} size="xs" /> - + {/snippet}
diff --git a/frontend/src/lib/components/SuperadminSettingsInner.svelte b/frontend/src/lib/components/SuperadminSettingsInner.svelte index 1f33e768d8..964414cafa 100644 --- a/frontend/src/lib/components/SuperadminSettingsInner.svelte +++ b/frontend/src/lib/components/SuperadminSettingsInner.svelte @@ -46,7 +46,8 @@ closeDrawer, showHeaderInfo = true, yamlMode = $bindable(false), - hasUnsavedChanges = $bindable(false) + hasUnsavedChanges = $bindable(false), + hasAnyInvalid = $bindable(false) } = $props() function removeHash() { @@ -342,7 +343,7 @@ {#if filteredUsers && users} - {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only }, i (email)} + {#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source }, i (email)} {/if} - { - if (email == $userStore?.email) { - sendUserToast('You cannot demote yourself', true) - listUsers(activeOnly) - return - } +
@@ -510,6 +542,7 @@ hideTabs bind:yamlMode bind:hasUnsavedChanges + bind:hasAnyInvalid tab={instanceSettingsCategory} {authSubTab} {closeDrawer} diff --git a/frontend/src/lib/components/WorkerGroup.svelte b/frontend/src/lib/components/WorkerGroup.svelte index da43629294..c050d2b5c0 100644 --- a/frontend/src/lib/components/WorkerGroup.svelte +++ b/frontend/src/lib/components/WorkerGroup.svelte @@ -291,7 +291,12 @@ workers.some(([_, pings]) => pings.some((p) => p.native_mode === true))) ) let nonNativeTags = $derived( - (nconfig?.worker_tags ?? []).filter((t) => !nativeTags.includes(t) && t !== 'flow') + (nconfig?.worker_tags ?? []).filter( + (t) => + !nativeTags.some((nt) => t === nt || t.startsWith(`${nt}-`)) && + t !== 'flow' && + !t.startsWith('flow-') + ) ) let isAutoNativeMode = $derived(name === 'native') let isNativeModeEnabled = $derived(nconfig?.native_mode === true || isAutoNativeMode) @@ -569,8 +574,8 @@ This worker group has native mode enabled but includes non-native tags: {nonNativeTags.join( ', ' - )}. Non-native jobs will be failed. This is fine if those custom tags are only used - for native language jobs. + )}. This is fine if jobs sent to those tags are native only, otherwise they will be + failed. {/if} {#if isNativeModeEnabled && nconfig?.worker_tags != undefined && !nconfig.worker_tags.includes(defaultTagPerWorkspace && workspaceTag ? `flow-${workspaceTag}` : 'flow')} diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 66fbb5b6c5..d71a4bc015 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -3,17 +3,33 @@ import { displayDate, msToSec } from '$lib/utils' import { onDestroy } from 'svelte' import { getDbClockNow } from '$lib/forLater' - import { Loader2 } from 'lucide-svelte' + import { ChevronDown, ChevronRight, Loader2 } from 'lucide-svelte' import TimelineBar from './TimelineBar.svelte' - import type { WorkflowStatus } from '$lib/gen' + import LogViewer from './LogViewer.svelte' + import ObjectViewer from './propertyPicker/ObjectViewer.svelte' + import { CheckCircle2, XCircle } from 'lucide-svelte' + import { JobService, type Job, type WorkflowStatus } from '$lib/gen' + import { workspaceStore } from '$lib/stores' interface Props { - flow_status: Record; - flowDone?: boolean; + flow_status: Record + flowDone?: boolean + stepResults?: Record + result?: any + success?: boolean + autoExpandResult?: boolean } - let { flow_status, flowDone = false }: Props = $props(); + let { flow_status, flowDone = false, stepResults = {}, result = undefined, success = true, autoExpandResult = false }: Props = $props() + let resultExpanded = $state(false) + + // Auto-expand result row when job completes (only if requested) + $effect(() => { + if (autoExpandResult && flowDone && result !== undefined) { + resultExpanded = true + } + }) let now = $state(getDbClockNow().getTime()) @@ -25,35 +41,94 @@ onDestroy(() => { interval && clearInterval(interval) + pollInterval && clearInterval(pollInterval) }) - let min = $derived(Object.values(flow_status).reduce( - (a, b) => Math.min(a, b.scheduled_for ? new Date(b.scheduled_for).getTime() : Infinity), - Infinity - )) - let max = $derived(flowDone - ? Object.values(flow_status).reduce( - (a, b) => - Math.max(a, b.started_at ? new Date(b.started_at).getTime() + (b.duration_ms ?? 0) : 0), - 0 - ) - : undefined) + + let min = $derived( + Object.values(flow_status).reduce( + (a, b) => Math.min(a, b.scheduled_for ? new Date(b.scheduled_for).getTime() : Infinity), + Infinity + ) + ) + let max = $derived( + flowDone + ? Object.values(flow_status).reduce( + (a, b) => + Math.max( + a, + b.started_at ? new Date(b.started_at).getTime() + (b.duration_ms ?? 0) : 0 + ), + 0 + ) + : undefined + ) let total = $derived(flowDone && max ? max - min : Math.max(now - min, 2000)) + + // Collapsible state + let expandedRows: Record = $state({}) + let childJobs: Record = $state({}) + let loadingJobs: Record = $state({}) + + function isStep(key: string): boolean { + return key.startsWith('_step/') + } + + function stepKey(key: string): string { + return key.slice('_step/'.length) + } + + function toggleRow(id: string) { + expandedRows[id] = !expandedRows[id] + if (expandedRows[id] && !isStep(id) && !childJobs[id]) { + fetchChildJob(id) + } + } + + async function fetchChildJob(id: string) { + const ws = $workspaceStore + if (!ws) return + loadingJobs[id] = true + try { + const job = await JobService.getJob({ workspace: ws, id }) + childJobs[id] = job as Job & { result?: any } + } catch (e) { + console.error(`Failed to fetch job ${id}:`, e) + } finally { + loadingJobs[id] = false + } + } + + // Poll for updates on expanded in-progress jobs + let pollInterval = setInterval(() => { + for (const [id, v] of Object.entries(flow_status)) { + if (isStep(id)) continue + const isRunning = v.duration_ms == undefined && v.started_at != undefined + if (expandedRows[id] && isRunning) { + fetchChildJob(id) + } + } + }, 2000) {#if flow_status}
-
-
{min ? displayDate(new Date(min), true) : ''}
{#if max && min} - {/if}
{max ? displayDate(new Date(max), true) : ''}{#if !max && min}{#if now} +
+
+
+
{min ? displayDate(new Date(min), true) : ''}
+ {#if max && min} + + {/if} +
+ {max ? displayDate(new Date(max), true) : ''} + {#if !max && min} + {#if now} {msToSec(now - min, 3)}s - {/if}{/if}
+ {/if} + + {/if} +
+
@@ -61,26 +136,57 @@
Waiting for executor
-
Execution
- {#each Object.entries(flow_status) as [k, v] (k)} -
-
- {v.name ?? k} -
+ {#each Object.entries(flow_status).sort(([, a], [, b]) => { + const ta = new Date(a.started_at ?? a.scheduled_for ?? 0).getTime() + const tb = new Date(b.started_at ?? b.scheduled_for ?? 0).getTime() + return ta - tb + }) as [k, v] (k)} + {@const isInlineStep = isStep(k)} + {@const isRunning = v.duration_ms == undefined && v.started_at != undefined} + {@const isDone = v.duration_ms != undefined} + {@const isExpanded = expandedRows[k] ?? false} +
+
+ {/if} +
+ + + {#if isExpanded} +
+ {#if isInlineStep} + + {@const result = stepResults[stepKey(k)]} + {#if isDone && result !== undefined} +
+
Result
+
+ +
+
+ {:else} +
Step completed (no result)
+ {/if} + {:else if loadingJobs[k] && !childJobs[k]} +
+ + Loading... +
+ {:else if childJobs[k]} + {@const job = childJobs[k]} + + {#if job.logs || isRunning} +
+
Logs
+ +
+ {/if} + + + {#if isDone && job.result !== undefined} +
+
Result
+
+ +
+
+ {/if} + {:else} +
No data available
+ {/if} +
+ {/if}
{/each} + {#if flowDone && result !== undefined} +
+ + {#if resultExpanded} +
+
+ +
+
+ {/if} +
+ {/if}
{:else} diff --git a/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte b/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte index ec0b342f56..7bc0351c82 100644 --- a/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte +++ b/frontend/src/lib/components/WorkspaceDependenciesEditor.svelte @@ -40,6 +40,19 @@ return deps.name || `Default (${deps.language})` } + function getEditorLang(language: ScriptLang): string { + switch (language) { + case 'bun': + case 'php': + case 'powershell': + return 'json' + case 'python3': + return 'plaintext' + default: + return 'markdown' + } + } + export function getFileExtension(language: ScriptLang): string | null { switch (language) { case 'python3': @@ -50,6 +63,8 @@ // return 'go.mod' case 'php': return 'composer.json' + case 'powershell': + return 'modules.json' default: return null } @@ -113,7 +128,8 @@ { value: 'python3', label: 'Python' }, { value: 'bun', label: 'TypeScript (Bun/Bunnative)' }, // { value: 'go', label: 'Go' }, - { value: 'php', label: 'PHP' } + { value: 'php', label: 'PHP' }, + { value: 'powershell', label: 'PowerShell' } ] // Default templates for each language @@ -157,6 +173,13 @@ numpy>=1.24.0 "vlucas/phpdotenv": "^5.6", "symfony/console": "^6.4" } +}`, + + powershell: `{ + "modules": { + "PSWriteColor": "*", + "ImportExcel": "7.8.6" + } }` } @@ -497,7 +520,7 @@ numpy>=1.24.0 handleEditorChange(e.detail)} fixedOverflowWidgets={false} @@ -505,6 +528,15 @@ numpy>=1.24.0 /> {/await}
+ {#if workspaceDependencies.language === 'powershell'} +
+ JSON object with a "modules" key mapping module names to versions. Use + "*" + or null for latest version, or a specific version string to pin. These + modules are merged with script-level + Import-Module statements at runtime (workspace versions take precedence). +
+ {/if}
diff --git a/frontend/src/lib/components/apps/editor/appUtilsS3.ts b/frontend/src/lib/components/apps/editor/appUtilsS3.ts index 5e35f1a4bd..f9811edd76 100644 --- a/frontend/src/lib/components/apps/editor/appUtilsS3.ts +++ b/frontend/src/lib/components/apps/editor/appUtilsS3.ts @@ -153,8 +153,8 @@ export function computeS3FileViewerPolicy(config: RichConfigurations) { } else if ( config.source.type === 'static' && typeof config.source.value === 'string' && - ((config.sourceKind.type === 'static' && - config.sourceKind.value === 's3 (workspace storage)') || + ((config.sourceKind?.type === 'static' && + config.sourceKind?.value === 's3 (workspace storage)') || config.source.value.startsWith('s3://')) ) { return { diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 68f079dac0..e2898eb9ba 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -1,5 +1,16 @@ {#if menuOpen} @@ -115,7 +119,7 @@ Archived {/if} - {#if script.no_main_func && script.kind !== 'preprocessor'} + {#if script.auto_kind === 'lib' && script.kind !== 'preprocessor'} {/if} + {#if script.auto_kind === 'wac'} + + {#snippet text()} + Workflow-as-Code + {/snippet} + wac + + {/if} {#if script.kind !== 'script'} {script.kind === 'failure' ? 'Error handler' : capitalize(script.kind)} { + const fullScript = await ScriptService.getScriptByPath({ + workspace: $workspaceStore!, + path: script.path + }) + wacExportDrawer?.open(fullScript) + } + } + ] + : []), { displayName: 'Duplicate/Fork', icon: GitFork, @@ -412,3 +439,5 @@ {/if} + + diff --git a/frontend/src/lib/components/copilot/MetadataGen.svelte b/frontend/src/lib/components/copilot/MetadataGen.svelte index bb18b3dc1e..1ef4b2ff08 100644 --- a/frontend/src/lib/components/copilot/MetadataGen.svelte +++ b/frontend/src/lib/components/copilot/MetadataGen.svelte @@ -12,7 +12,7 @@ import { yamlStringifyExceptKeys } from './utils' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte' - import { validateToolName } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte' + import { getToolNameError } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte' import { inputBaseClass, inputBorderClass, @@ -117,6 +117,7 @@ Generate a tool name for the script below: elementProps?: Record class?: string onChange?: (content: string) => void + siblingToolNames?: string[] } let { @@ -130,9 +131,16 @@ Generate a tool name for the script below: elementType = 'input', elementProps = {}, class: clazz = '', - onChange = undefined + onChange = undefined, + siblingToolNames = undefined }: Props = $props() + let toolNameError = $derived( + promptConfigName === 'agentToolFunctionName' + ? getToolNameError(content ?? '', undefined, siblingToolNames) + : undefined + ) + let el: HTMLElement | undefined = $state() let generatedContent = $state('') let active = $state(false) @@ -347,16 +355,16 @@ Generate a tool name for the script below: inputBaseClass, inputSizeClasses.md, inputBorderClass({ - error: promptConfigName === 'agentToolFunctionName' && !validateToolName(content ?? '') + error: !!toolNameError }), 'w-full' )} onfocus={() => (focused = true)} onblur={() => (focused = false)} /> - {#if promptConfigName === 'agentToolFunctionName' && !validateToolName(content ?? '')} + {#if toolNameError}

- Invalid tool name, should only contain letters, numbers and underscores + {toolNameError}

{/if} {/if} diff --git a/frontend/src/lib/components/custom_ui.ts b/frontend/src/lib/components/custom_ui.ts index 090e0a8eff..536471f74d 100644 --- a/frontend/src/lib/components/custom_ui.ts +++ b/frontend/src/lib/components/custom_ui.ts @@ -41,6 +41,7 @@ export type FlowBuilderWhitelabelCustomUi = { tagSelectNoLabel?: boolean tagLabel?: string aiAgent?: boolean + aiSandbox?: boolean } export type DisplayResultUi = { @@ -81,6 +82,8 @@ export type EditorBarUi = { ducklake?: boolean dataTable?: boolean debug?: boolean + history?: boolean + saveToWorkspace?: boolean } export type EditableSchemaFormUi = { diff --git a/frontend/src/lib/components/flows/CreateActionsFlow.svelte b/frontend/src/lib/components/flows/CreateActionsFlow.svelte index f2080fb281..73f8cfedba 100644 --- a/frontend/src/lib/components/flows/CreateActionsFlow.svelte +++ b/frontend/src/lib/components/flows/CreateActionsFlow.svelte @@ -7,11 +7,28 @@ import Drawer from '$lib/components/common/drawer/Drawer.svelte' import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte' import { importFlowStore } from '$lib/components/flows/flowStore.svelte' - import { Loader2, Plus } from 'lucide-svelte' + import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte' + import Modal from '$lib/components/common/modal/Modal.svelte' + import Toggle from '$lib/components/Toggle.svelte' + import Tabs from '$lib/components/common/tabs/Tabs.svelte' + import Tab from '$lib/components/common/tabs/Tab.svelte' + import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons' + import { Code2, Loader2, Plus } from 'lucide-svelte' import YAML from 'yaml' + + const SKIP_FLOW_MODAL_KEY = 'windmill_skip_flow_modal' + let drawer: Drawer | undefined = $state(undefined) + let wacDrawer: Drawer | undefined = $state(undefined) let pendingRaw: string | undefined = $state(undefined) + let pendingWacRaw: string | undefined = $state(undefined) let importType: 'yaml' | 'json' = $state('yaml') + let wacImportType: 'yaml' | 'json' = $state('yaml') + let flowModalOpen = $state(false) + let wacHovered = $state(false) + let skipModal = $state( + typeof localStorage !== 'undefined' && localStorage.getItem(SKIP_FLOW_MODAL_KEY) === 'true' + ) async function importRaw() { $importFlowStore = @@ -19,58 +36,229 @@ await goto('/flows/add') drawer?.closeDrawer?.() } + + async function importWacRaw() { + const parsed = + wacImportType === 'yaml' ? YAML.parse(pendingWacRaw ?? '') : JSON.parse(pendingWacRaw ?? '') + $importScriptStore = parsed + await goto(`${base}/scripts/add?import=true`) + wacDrawer?.closeDrawer?.() + } + + function handleFlowClick() { + if (skipModal) { + goto(`${base}/flows/add?nodraft=true`) + } else { + flowModalOpen = true + } + } + + function selectFlowEditor() { + flowModalOpen = false + goto(`${base}/flows/add?nodraft=true`) + } + + function selectWacPython() { + flowModalOpen = false + goto(`${base}/scripts/add?nodraft=true&wac=python`) + } + + function selectWacTypescript() { + flowModalOpen = false + goto(`${base}/scripts/add?nodraft=true&wac=typescript`) + } + + function toggleSkipModal() { + skipModal = !skipModal + localStorage.setItem(SKIP_FLOW_MODAL_KEY, String(skipModal)) + }
- -
- - - - drawer?.toggleDrawer?.()} + }, + { + label: 'Workflow-as-Code in TypeScript', + onClick: () => selectWacTypescript() + }, + { + label: 'Workflow-as-Code in Python', + onClick: () => selectWacPython() + }, + { + label: 'Import Workflow-as-Code', + onClick: () => { + wacDrawer?.toggleDrawer?.() + } + } + ]} > - {#await import('$lib/components/SimpleEditor.svelte')} - - {:then Module} - - {/await} + Flow + +
+ + + +
+
+ + + + + +
(wacHovered = true)} + onmouseleave={() => (wacHovered = false)} + > + +
+ Alpha +
+ + +
+
+ +
+
+

Workflow-as-Code

+

+ Write workflows as Python or TypeScript code as a regular Windmill script. +

+
+
+ + +
+ + +
+
+
+ +
+ + Always use the Flow editor (skip this modal) +
+
+
+ + + + drawer?.toggleDrawer?.()}> + + + + {#snippet content()} +
+ {#key importType} + {#await import('$lib/components/SimpleEditor.svelte')} + + {:then Module} + + {/await} + {/key} +
+ {/snippet} +
{#snippet actions()} {/snippet}
+ + + + wacDrawer?.toggleDrawer?.()}> + + + + {#snippet content()} +
+ {#key wacImportType} + {#await import('$lib/components/SimpleEditor.svelte')} + + {:then Module} + + {/await} + {/key} +
+ {/snippet} +
+ {#snippet actions()} + + {/snippet} +
+
diff --git a/frontend/src/lib/components/flows/DebounceLimit.svelte b/frontend/src/lib/components/flows/DebounceLimit.svelte index 8c8937016d..1889d964ba 100644 --- a/frontend/src/lib/components/flows/DebounceLimit.svelte +++ b/frontend/src/lib/components/flows/DebounceLimit.svelte @@ -30,11 +30,37 @@ fontClass?: string } = $props() - // Get list of array-type arguments from schema + // Check if an originalType like "string | string[]" is a top-level + // union where at least one member is an array type (ends with "[]"). + // Splits on "|" only at the top level (not inside {}, <>, or ()). + function isUnionWithArray(originalType: string | undefined): boolean { + if (!originalType) return false + let depth = 0 + const parts: string[] = [] + let cur = '' + for (const ch of originalType) { + if (ch === '{' || ch === '<' || ch === '(') depth++ + else if (ch === '}' || ch === '>' || ch === ')') depth-- + else if (ch === '|' && depth === 0) { + parts.push(cur.trim()) + cur = '' + continue + } + cur += ch + } + parts.push(cur.trim()) + // Match TS array syntax (T[]) and Python list syntax (list[T] / List[T]) + return parts.length > 1 && parts.some((p) => p.endsWith('[]') || /^[Ll]ist\[.+\]$/.test(p)) + } + + // Get list of arguments eligible for accumulation from schema. + // Includes array-type arguments and union types like T | T[] + // whose scalar values are wrapped into single-element arrays + // at aggregation time. let arrayArgs = $derived( schema?.properties ? Object.entries(schema.properties) - .filter(([_, prop]) => prop.type === 'array') + .filter(([_, prop]) => prop.type === 'array' || isUnionWithArray(prop.originalType)) .map(([key, _]) => key) : [] ) @@ -111,8 +137,8 @@ {/if} diff --git a/frontend/src/lib/components/flows/common/FlowCard.svelte b/frontend/src/lib/components/flows/common/FlowCard.svelte index f780331a82..43cabc7aa0 100644 --- a/frontend/src/lib/components/flows/common/FlowCard.svelte +++ b/frontend/src/lib/components/flows/common/FlowCard.svelte @@ -12,6 +12,7 @@ action?: import('svelte').Snippet children?: import('svelte').Snippet isAgentTool?: boolean + siblingToolNames?: string[] } let { @@ -23,7 +24,8 @@ header, action, children, - isAgentTool = false + isAgentTool = false, + siblingToolNames = undefined }: Props = $props() @@ -38,6 +40,7 @@ {flowModuleValue} {action} {isAgentTool} + {siblingToolNames} > {@render header?.()} diff --git a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte index 401bf232b1..a5ff48053e 100644 --- a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte +++ b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte @@ -18,7 +18,7 @@ import { Flag, Lock, RefreshCw, Unlock } from 'lucide-svelte' import { createEventDispatcher, untrack } from 'svelte' import { twMerge } from 'tailwind-merge' - import { validateToolName } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte' + import { getToolNameError } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte' import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub' interface Props { @@ -28,6 +28,7 @@ children?: import('svelte').Snippet action?: import('svelte').Snippet isAgentTool?: boolean + siblingToolNames?: string[] } let { @@ -36,9 +37,14 @@ summary = $bindable(undefined), children, action, - isAgentTool = false + isAgentTool = false, + siblingToolNames = undefined }: Props = $props() + let toolNameError = $derived( + isAgentTool ? getToolNameError(summary ?? '', undefined, siblingToolNames) : undefined + ) + let latestHash: string | undefined = $state(undefined) // Extract version_id from hub path (format: hub/{version_id}/{app}/{summary}) @@ -103,6 +109,7 @@ elementProps={{ placeholder: isAgentTool ? 'Tool name' : 'Summary' }} + {siblingToolNames} /> {:else if flowModuleValue.type === 'script' && 'path' in flowModuleValue && flowModuleValue.path} @@ -173,14 +180,16 @@ /> {/if} - +
+ + {#if toolNameError} +

{toolNameError}

+ {/if} +
{:else if flowModuleValue.type === 'flow'} flow diff --git a/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte b/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte index d624c2132f..9807e96603 100644 --- a/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte +++ b/frontend/src/lib/components/flows/content/AgentToolWrapper.svelte @@ -14,6 +14,7 @@ previousModule?: FlowModule | undefined forceTestTab?: Record highlightArg?: Record + siblingToolNames?: string[] } let { @@ -23,7 +24,8 @@ parentModule = undefined, previousModule = undefined, forceTestTab, - highlightArg + highlightArg, + siblingToolNames = undefined }: Props = $props() @@ -43,6 +45,7 @@ forceTestTab={forceTestTab?.[tool.id]} highlightArg={highlightArg?.[tool.id]} isAgentTool={true} + {siblingToolNames} /> {:else if isMcpTool(tool)} diff --git a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte index c8418e5183..3d9a533f48 100644 --- a/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte +++ b/frontend/src/lib/components/flows/content/FlowEditorPanel.svelte @@ -101,9 +101,7 @@ ) let canMoveSelected = $derived( resolvedModuleIds.length > 0 && - areContiguousSiblings( - locateModules(resolvedModuleIds, flowStore.val.value.modules ?? []) - ) + areContiguousSiblings(locateModules(resolvedModuleIds, flowStore.val.value.modules ?? [])) ) diff --git a/frontend/src/lib/components/flows/content/FlowInputs.svelte b/frontend/src/lib/components/flows/content/FlowInputs.svelte index 730dd5985d..353dbb3a2f 100644 --- a/frontend/src/lib/components/flows/content/FlowInputs.svelte +++ b/frontend/src/lib/components/flows/content/FlowInputs.svelte @@ -288,7 +288,7 @@ {/each} - {#if !failureModule && !preprocessorModule} + {#if !failureModule && !preprocessorModule && customUi?.aiSandbox != false}

AI Sandbox

{/if} - {#if selectedKind === 'script' && preFilter === 'all' && !selected} + {#if selectedKind === 'script' && preFilter === 'all' && !selected && customUi?.aiSandbox != false}
AI Sandbox
noEditor) ? 0 : flowModule.value.type == 'script' ? 30 : 50) + let editorPanelSize = $state( + untrack(() => noEditor) ? 0 : flowModule.value.type == 'script' ? 30 : 50 + ) let editorSettingsPanelSize = $state(100 - untrack(() => editorPanelSize)) let stepHistoryLoader = getStepHistoryLoaderContext() @@ -726,6 +730,7 @@ }} bind:summary={flowModule.summary} {isAgentTool} + {siblingToolNames} > {#snippet header()} @@ -1106,8 +1111,8 @@ {#if !selectedId.includes('failure')} @@ -1147,6 +1152,22 @@ {/if}
{#if advancedSelected === 'retries'} +
+ {#snippet header()} + + When enabled, the flow will continue to the next step even if this step fails (after exhausting all retries, if any). This enables to process the error in a branch one for instance. + + {/snippet} + +
+
{#snippet header()} {/snippet} - -
{:else if advancedSelected === 'runtime' && advancedRuntimeSelected === 'concurrency'} diff --git a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte index cb50e8a327..c58819d59c 100644 --- a/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte +++ b/frontend/src/lib/components/flows/content/FlowModuleWrapper.svelte @@ -305,6 +305,7 @@ {enableAi} {forceTestTab} {highlightArg} + siblingToolNames={flowModule.value.tools.map((t) => t.summary ?? '')} /> {/if} {/each} diff --git a/frontend/src/lib/components/flows/content/FlowRetries.svelte b/frontend/src/lib/components/flows/content/FlowRetries.svelte index 6a59b4fbc7..71e4d67f09 100644 --- a/frontend/src/lib/components/flows/content/FlowRetries.svelte +++ b/frontend/src/lib/components/flows/content/FlowRetries.svelte @@ -110,7 +110,7 @@ const u32Max = 4294967295 -
+
{/if} + {#if delayType === 'constant' || delayType === 'exponential'}
{#if delayType === 'constant'} @@ -296,66 +297,65 @@
{#if true} - {@const { attempts: cAttempts, seconds: cSeconds } = flowModuleRetry?.constant || {}} - {@const { - attempts: eAttempts, - seconds: eSeconds, - multiplier, - random_factor - } = flowModuleRetry?.exponential || {}} - {@const cArray = Array.from({ length: Math.min(cAttempts || 0, 100) }, () => cSeconds)} - {@const eArray = Array.from( - { length: Math.min(eAttempts || 0, 100) }, - (_, i) => (multiplier || 0) * (eSeconds || 0) ** (i + cArray.length + 1) - )} - {@const array = [...cArray, ...eArray]} -
-
Retry attempts
- {#if array.length > 0} - - + {@const { attempts: cAttempts, seconds: cSeconds } = flowModuleRetry?.constant || {}} + {@const { + attempts: eAttempts, + seconds: eSeconds, + multiplier, + random_factor + } = flowModuleRetry?.exponential || {}} + {@const cArray = Array.from({ length: Math.min(cAttempts || 0, 100) }, () => cSeconds)} + {@const eArray = Array.from( + { length: Math.min(eAttempts || 0, 100) }, + (_, i) => (multiplier || 0) * (eSeconds || 0) ** (i + cArray.length + 1) + )} + {@const array = [...cArray, ...eArray]} +
+
Retry attempts
+ {#if array.length > 0} +
+ + + + + + + + {#each array.slice(1, 100) as delay, i} + {@const index = i + 2} - - + + seconds){/if} + after attempt #{index - 1} + {#if i > cArray.length - 2} + + ({multiplier} * {eSeconds}{index}) + + {/if} + - - - {#each array.slice(1, 100) as delay, i} - {@const index = i + 2} - - - - - {/each} - {#if (cAttempts ?? 0) > 100 || (eAttempts ?? 0) > 100} - - - - - {/if} - -
1:After {array[0]} second{array[0] === 1 ? '' : 's'} + {#if (random_factor ?? 0) > 0}(+/- {((array[0] ?? 0) * (random_factor ?? 0)) / + 100} + seconds){/if}
1:After {array[0]} second{array[0] === 1 ? '' : 's'} - {#if (random_factor ?? 0) > 0}(+/- {((array[0] ?? 0) * (random_factor ?? 0)) / + {index}: + {delay} second{delay === 1 ? '' : 's'} + {#if (random_factor ?? 0) > 0}(+/- {((delay ?? 0) * (random_factor ?? 0)) / 100} - seconds){/if}
{index}: - {delay} second{delay === 1 ? '' : 's'} - {#if (random_factor ?? 0) > 0}(+/- {((delay ?? 0) * (random_factor ?? 0)) / - 100} - seconds){/if} - after attempt #{index - 1} - {#if i > cArray.length - 2} - - ({multiplier} * {eSeconds}{index}) - - {/if} -
......
- {:else} -
No retries
- {/if} -
+ {/each} + {#if (cAttempts ?? 0) > 100 || (eAttempts ?? 0) > 100} + + ... + ... + + {/if} + + + {/if} +
{/if}
+ {/if}
diff --git a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte index abdb497e93..ae9d75daeb 100644 --- a/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte +++ b/frontend/src/lib/components/flows/content/ScriptEditorDrawer.svelte @@ -50,7 +50,7 @@ dedicated_worker?: boolean visible_to_runner_only?: boolean on_behalf_of_email?: string - no_main_func?: boolean + auto_kind?: string has_preprocessor?: boolean } | undefined = $state(undefined) @@ -71,7 +71,7 @@ dedicated_worker?: boolean visible_to_runner_only?: boolean on_behalf_of_email?: string - no_main_func?: boolean + auto_kind?: string has_preprocessor?: boolean } | undefined = $state(undefined) @@ -82,7 +82,7 @@ script.schema = script.schema ?? emptySchema() try { const result = await inferArgs(script.language, script.content, script.schema) - script.no_main_func = result?.no_main_func || undefined + script.auto_kind = result?.auto_kind || undefined script.has_preprocessor = result?.has_preprocessor || undefined } catch (error) { sendUserToast(`Could not parse code, are you sure it is valid?`, true) diff --git a/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte b/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte index 25d2591f73..002a539d79 100644 --- a/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte +++ b/frontend/src/lib/components/flows/map/FlowErrorHandlerItem.svelte @@ -15,11 +15,13 @@ let { disableAi, small, - diffManager + diffManager, + compact = false }: { small: boolean disableAi?: boolean diffManager?: FlowDiffManager + compact?: boolean } = $props() const dispatch = createEventDispatcher<{ @@ -58,6 +60,8 @@ selectionManager.selectId('failure') refreshStateStore(flowStore) } + + const smallFailureModule = $derived(!(failureModuleId && diffManager && moduleAction) && compact) {#if flowStore.val?.value?.failure_module} @@ -67,7 +71,7 @@ + {/if} + + {#if smallFailureModule} - + {/if}
{:else} @@ -124,14 +142,17 @@ {#snippet trigger()} {/snippet} diff --git a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte index eada672f24..fc8b68afb9 100644 --- a/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte +++ b/frontend/src/lib/components/flows/map/FlowModuleSchemaMap.svelte @@ -271,6 +271,8 @@ let sidebarMode: 'list' | 'graph' = 'graph' let minHeight = $state(0) + let flowPaneWidth = $state(0) + let compactTopbar = $derived(flowPaneWidth < 700) export function selectNextId(id: any) { if (flowStore.val.value.modules) { @@ -505,11 +507,12 @@ {/each} -
+
void disableAi?: boolean diffManager?: FlowDiffManager + compact?: boolean } let { @@ -33,7 +34,8 @@ noteMode, toggleNoteMode, disableAi, - diffManager + diffManager, + compact = false }: Props = $props() const { selectionManager, flowStore } = getContext('FlowEditorContext') @@ -42,23 +44,31 @@
{#if !disableSettings} - + + + {#snippet text()} + Settings + {/snippet} + {/if} - + {#snippet text()} Error Handler {/snippet} diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index ba19244f31..5aa844ec47 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -187,6 +187,7 @@ }} /> {/if} + {#if customUi?.aiSandbox != false} {/if} + {/if}
{/if} diff --git a/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte b/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte index c1662a8f80..6bc4686644 100644 --- a/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte +++ b/frontend/src/lib/components/flows/propPicker/OutputPickerInner.svelte @@ -204,7 +204,8 @@ } function updateLastJob() { - if (testJob) { + // Prefer testJob only when actively running/streaming (individual step test in progress) + if (testJob && (testJob.result_stream || testJob.type === 'QueuedJob')) { return testJob } if ( @@ -214,6 +215,8 @@ ) { return } + // Use flowStateStore as source of truth — it's updated by both individual step tests + // (ModuleTest.jobDone) and flow tests (FlowStatusViewerInner.onJobsLoadedInner) return { id: flowStateStore.val[moduleId]?.previewJobId ?? '', result: flowStateStore.val[moduleId]?.previewResult, diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 1f6e8fbfaa..834982c3f3 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -184,7 +184,7 @@ export function createGitSyncContext(workspace: string) { function addRepository() { repositories.push({ git_repo_resource_path: '', - script_path: hubPaths.gitSync, + script_path: undefined, use_individual_branch: false, group_by_folder: false, settings: { @@ -541,7 +541,7 @@ export function createGitSyncContext(workspace: string) { async function runTestJob(idx: number) { const repo = repositories[idx] - if (!repo?.git_repo_resource_path || !repo?.script_path) { + if (!repo?.git_repo_resource_path) { return } @@ -564,8 +564,8 @@ export function createGitSyncContext(workspace: string) { // Use JobManager for polling await jobManager.runWithProgress(() => Promise.resolve(jobId), { workspace, - timeout: 5000, - timeoutMessage: 'Git sync test job timed out after 5s', + timeout: 10000, + timeoutMessage: 'Git sync test job timed out after 10s', onProgress: (status) => { gitSyncTestJobs[idx].status = status.status === 'success' @@ -648,7 +648,7 @@ export function createGitSyncContext(workspace: string) { function addSyncRepository() { repositories.push({ git_repo_resource_path: '', - script_path: hubPaths.gitSync, + script_path: undefined, use_individual_branch: false, group_by_folder: false, settings: { @@ -671,7 +671,7 @@ export function createGitSyncContext(workspace: string) { function addPromotionRepository() { repositories.push({ git_repo_resource_path: '', - script_path: hubPaths.gitSync, + script_path: undefined, use_individual_branch: true, group_by_folder: false, settings: { diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte index af6f361286..8d4b7a0357 100644 --- a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -21,10 +21,8 @@ import Toggle from '$lib/components/Toggle.svelte' import { fade } from 'svelte/transition' import { workspaceStore } from '$lib/stores' - import hubPaths from '$lib/hubPaths.json' import type { GitSyncRepository } from './GitSyncContext.svelte' import GitSyncModeDisplay from './GitSyncModeDisplay.svelte' - import { DEFAULT_HUB_BASE_URL } from '$lib/hub' import { ResourceService, VariableService } from '$lib/gen' let { @@ -375,7 +373,7 @@
{#if !emptyString(repo.git_repo_resource_path)}
diff --git a/frontend/src/lib/components/graph/graphBuilder.svelte.ts b/frontend/src/lib/components/graph/graphBuilder.svelte.ts index 1d01b2e252..192b5c0c34 100644 --- a/frontend/src/lib/components/graph/graphBuilder.svelte.ts +++ b/frontend/src/lib/components/graph/graphBuilder.svelte.ts @@ -300,6 +300,7 @@ export type AiToolN = { data: { tool: string type?: string + nameError?: string eventHandlers: GraphEventHandlers moduleId: string insertable: boolean diff --git a/frontend/src/lib/components/graph/model.ts b/frontend/src/lib/components/graph/model.ts index d6fcf4a33a..3618406371 100644 --- a/frontend/src/lib/components/graph/model.ts +++ b/frontend/src/lib/components/graph/model.ts @@ -1,4 +1,4 @@ -import type { FlowStatusModule, Job } from '$lib/gen' +import type { FlowStatusModule, Job, WorkflowStatus } from '$lib/gen' import type { StateStore } from '$lib/utils' import type { FlowState } from '../flows/flowState' @@ -67,6 +67,7 @@ export type GraphModuleState = { skipped?: boolean agent_actions?: FlowStatusModule['agent_actions'] script_hash?: string + workflow_as_code_status?: WorkflowStatus } export type NestedNodes = GraphItem[] diff --git a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte index 28e9d58fa4..1913d927c8 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AIToolNode.svelte @@ -1,10 +1,29 @@ + +
+ { + $values['github_enterprise_app'] = { + ...$values['github_enterprise_app'], + self_managed: !selfManaged + } + }} + /> + + {#if !selfManaged} +

+ Using the managed Windmill GitHub App via stats.windmill.dev. Enable self-managed mode to + configure your own GitHub App (required for GitHub Enterprise Server). +

+ {:else} +
+ How to create a GitHub App +
+

+ 1. On your GitHub instance, go to + Settings → Developer settings → GitHub Apps → New GitHub App. +

+

2. Fill in the required fields:

+
    +
  • + GitHub App name: e.g. windmill-sync (this becomes the app + slug) +
  • +
  • + Homepage URL: your Windmill instance URL +
  • +
  • + Callback URL: <your-windmill-url>/gh_success +
  • +
  • + Setup URL (optional): + <your-windmill-url>/gh_success with "Redirect on update" checked +
  • +
  • Uncheck Active under Webhook (not needed)
  • +
+

3. Set repository permissions:

+
    +
  • Contents: Read & write
  • +
  • Metadata: Read-only
  • +
+

+ 4. Under "Where can this GitHub App be installed?", choose + Any account (or restrict to your organization). +

+

+ 5. Click Create GitHub App. On the next page, note the + App ID and Client ID. +

+

+ 6. Scroll down and click Generate a private key. Save the + downloaded .pem file — paste its contents into the Private Key field below. +

+

+ 7. The App Slug is the URL-friendly name shown in the + app's URL (e.g. github.com/apps/windmill-sync). +

+

+ 8. The Base URL is your GitHub instance root (e.g. + https://github.com or https://github.mycompany.com). +

+
+
+ {/if} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte index a3a762ffca..a88d90c7ff 100644 --- a/frontend/src/lib/components/meltComponents/Popover.svelte +++ b/frontend/src/lib/components/meltComponents/Popover.svelte @@ -121,7 +121,6 @@ escapeBehavior: untrack(() => escapeBehavior), openFocus: untrack(() => openFocus), onOpenChange: ({ curr, next }) => { - console.log('Popover open state changed:', { curr, next }) if (curr != next) { dispatch('openChange', next) if (!next) { diff --git a/frontend/src/lib/components/runs/JobRunsPreview.svelte b/frontend/src/lib/components/runs/JobRunsPreview.svelte index 274679d3bc..62722daac8 100644 --- a/frontend/src/lib/components/runs/JobRunsPreview.svelte +++ b/frontend/src/lib/components/runs/JobRunsPreview.svelte @@ -50,11 +50,15 @@ if (!x || typeof x !== 'object') return {} const result: Record = {} for (const [k, v] of Object.entries(x)) { - if (!k.startsWith('_')) result[k] = v as WorkflowStatus + if (!k.startsWith('_') || k.startsWith('_step/')) result[k] = v as WorkflowStatus } return result } + function getStepResults(x: any): Record { + return x?._checkpoint?.completed_steps ?? {} + } + function handleFilterByConcurrencyKey(key: string) { dispatch('filterByConcurrencyKey', key) } @@ -156,6 +160,9 @@
{/if} diff --git a/frontend/src/lib/components/runs/RunBadges.svelte b/frontend/src/lib/components/runs/RunBadges.svelte index 7f32cbe588..197b939751 100644 --- a/frontend/src/lib/components/runs/RunBadges.svelte +++ b/frontend/src/lib/components/runs/RunBadges.svelte @@ -2,7 +2,7 @@ import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte' import { truncateHash } from '$lib/utils' - import { base } from '$app/paths' + import { base } from '$lib/base' import { truncateRev } from '$lib/utils' import { workspaceStore } from '$lib/stores' import Badge from '$lib/components/common/badge/Badge.svelte' diff --git a/frontend/src/lib/components/runs/TimeframeSelect.svelte b/frontend/src/lib/components/runs/TimeframeSelect.svelte index feb223f213..cc5a99f70c 100644 --- a/frontend/src/lib/components/runs/TimeframeSelect.svelte +++ b/frontend/src/lib/components/runs/TimeframeSelect.svelte @@ -1,53 +1,11 @@ @@ -141,16 +147,20 @@ {#snippet content()}
{#if selectedTab === 'logs'} + {#if isWac} +
+ +
+ {:else} - {#if previewJob?.workflow_as_code_status} - - - - {/if} + {/if} {/if} {#if selectedTab === 'history'}
diff --git a/frontend/src/lib/components/script_builder.ts b/frontend/src/lib/components/script_builder.ts index f224c3bd6a..ddc5a01b89 100644 --- a/frontend/src/lib/components/script_builder.ts +++ b/frontend/src/lib/components/script_builder.ts @@ -14,7 +14,7 @@ export interface ScriptBuilderProps { disableAi?: boolean fullyLoaded?: boolean initialPath?: string - template?: 'docker' | 'bunnative' | 'claudesandbox' | 'script' + template?: 'docker' | 'bunnative' | 'claudesandbox' | 'wac_python' | 'wac_typescript' | 'script' initialArgs?: Record lockedLanguage?: boolean showMeta?: boolean diff --git a/frontend/src/lib/components/scripts/CreateActionsScript.svelte b/frontend/src/lib/components/scripts/CreateActionsScript.svelte index 9c847c9a3a..48480918e0 100644 --- a/frontend/src/lib/components/scripts/CreateActionsScript.svelte +++ b/frontend/src/lib/components/scripts/CreateActionsScript.svelte @@ -1,7 +1,7 @@ diff --git a/frontend/src/lib/components/scripts/WacExportDrawer.svelte b/frontend/src/lib/components/scripts/WacExportDrawer.svelte new file mode 100644 index 0000000000..59f1dca1b0 --- /dev/null +++ b/frontend/src/lib/components/scripts/WacExportDrawer.svelte @@ -0,0 +1,113 @@ + + + + + + drawer?.toggleDrawer()}> +
+ + + + {#snippet content()} +
+
+
+ {#key rawType} + + {/key} +
+ {/snippet} +
+
+
+
diff --git a/frontend/src/lib/components/scripts/scriptStore.svelte.ts b/frontend/src/lib/components/scripts/scriptStore.svelte.ts new file mode 100644 index 0000000000..abc1fb0d63 --- /dev/null +++ b/frontend/src/lib/components/scripts/scriptStore.svelte.ts @@ -0,0 +1,4 @@ +import type { NewScript } from '$lib/gen' +import { writable } from 'svelte/store' + +export const importScriptStore = writable(undefined) diff --git a/frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte b/frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte new file mode 100644 index 0000000000..b261aa8ed7 --- /dev/null +++ b/frontend/src/lib/components/triggers/TriggerAdvancedBadges.svelte @@ -0,0 +1,28 @@ + + +{#if allBadges.length > 0} +
+ {#each allBadges as badge} + {badge.name} + {/each} +
+{/if} diff --git a/frontend/src/lib/components/triggers/TriggerTokens.svelte b/frontend/src/lib/components/triggers/TriggerTokens.svelte index 4cf1a5d027..11d0bfca74 100644 --- a/frontend/src/lib/components/triggers/TriggerTokens.svelte +++ b/frontend/src/lib/components/triggers/TriggerTokens.svelte @@ -1,5 +1,5 @@