Merge remote-tracking branch 'origin/main' into store-hash

This commit is contained in:
HugoCasa
2026-03-13 15:14:43 +01:00
187 changed files with 11633 additions and 3914 deletions
+4 -2
View File
@@ -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
+4 -2
View File
@@ -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
+6
View File
@@ -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:*)",
+98
View File
@@ -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. <description> (<reason: CLAUDE.md adherence | bug | security>)
<file_path:line_number>
2. <description> (<reason>)
<file_path:line_number>
```
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 "<summary>"
```
Or for inline comments on specific lines:
```bash
gh api repos/{owner}/{repo}/pulls/{pr}/reviews -f body="<summary>" -f event="COMMENT" -f comments="[...]"
```
+23
View File
@@ -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] <type>: <description>`
## 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 <ee-path> status --short`
- If there are no changes in the EE repo, skip this entire section
3. Follow steps 15 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 "<type>: <description>" --body "$(cat <<'EOF'
Companion PR for windmill-labs/windmill#<PR_NUMBER>
---
Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
```
5. Commit `ee-repo-ref.txt` and push the updated windmill branch
+12
View File
@@ -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
+12
View File
@@ -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"
@@ -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
+209
View File
@@ -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
+2 -2
View File
@@ -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 }}
+1
View File
@@ -27,3 +27,4 @@ typescript-client/node_modules
frontend/.svelte-kit
backend/chrome_profiler.json
.fast-check/
__pycache__/
+1
View File
@@ -67,6 +67,7 @@ files:
copy:
- backend/.env
- scripts/
- wm-ts-nav/target/release/wm-ts-nav
sandbox:
enabled: false
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## [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)
+37 -1
View File
@@ -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, use `wm-ts-nav` to explore (see Code Navigation below). Use `outline` to understand file structure, `body` to read specific symbols, `def`/`callers`/`callees` to trace code. 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,43 @@ 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 **Grep** for regex/pattern search. Use **wm-ts-nav** for structural queries — it skips comments/strings and understands symbol boundaries.
**Prefer wm-ts-nav over Read** to save context window:
- `outline <file>` instead of reading a full file — understand structure first, then `body` or Read for specifics
- `body "X"` instead of reading a full file to see one function/struct
- `refs "X" --caller` instead of reading files to find which function contains each reference
- `callers "X"` / `callees "X"` for call-graph questions
```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:
- Import paths are stored literally — `crate::X` and `super::X` pointing to the same type won't be linked
- Re-export chains (`pub use`) aren't followed — refs through different re-export paths won't connect
- Trait methods can't be resolved to their trait definition
- Nested `use` trees (`use foo::{bar::{A, B}, baz::C}`) aren't parsed correctly
- Glob imports (`use foo::*`) — refs won't show import origin
- Macro-generated symbols (e.g. `sqlx::FromRow`) — invisible to tree-sitter
- Single-char identifiers — intentionally filtered out of refs
- `callees` shows all identifiers in a function body, not just actual calls
- `import * as ns` namespace imports — member accesses through `ns.X` aren't resolved
## Core Principles
- **Use `outline`/`body` to explore, then `Read` with offset/limit from the results before editing** — avoid reading full files
- 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
-78
View File
@@ -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/<name>` 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 <value>` 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`
@@ -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"
}
@@ -46,11 +46,11 @@
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
true,
true
]
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM variable WHERE path = $1 AND workspace_id = $2 RETURNING path",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "3317484a9c09c07c2c9db9debaecc4a4d518093ab48e79365dbb808068e0b8ff"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+86 -85
View File
@@ -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",
@@ -15741,7 +15741,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15808,7 +15808,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15821,7 +15821,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"argon2",
@@ -15962,7 +15962,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15985,7 +15985,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15998,7 +15998,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16024,7 +16024,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16034,7 +16034,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16051,7 +16051,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.654.0"
version = "1.655.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.655.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16097,7 +16097,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16113,7 +16113,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16133,7 +16133,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16153,7 +16153,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16167,7 +16167,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.654.0"
version = "1.655.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.655.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16219,7 +16220,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"flate2",
@@ -16237,7 +16238,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16258,7 +16259,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16278,7 +16279,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16308,7 +16309,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16335,7 +16336,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"lazy_static",
"serde",
@@ -16347,7 +16348,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16370,7 +16371,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16384,7 +16385,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16415,7 +16416,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"chrono",
"lazy_static",
@@ -16429,7 +16430,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16448,7 +16449,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16547,7 +16548,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16566,7 +16567,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"regex",
"serde",
@@ -16581,7 +16582,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16605,7 +16606,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"futures",
@@ -16622,7 +16623,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16638,7 +16639,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16659,7 +16660,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16690,7 +16691,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16714,7 +16715,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-stream",
@@ -16748,7 +16749,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"futures",
@@ -16766,7 +16767,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16775,7 +16776,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16787,7 +16788,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16799,7 +16800,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"gosyn",
@@ -16811,7 +16812,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16823,7 +16824,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16835,7 +16836,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16846,7 +16847,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16857,7 +16858,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16869,7 +16870,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.653.0"
version = "1.655.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -16880,7 +16881,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16904,7 +16905,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16918,7 +16919,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16935,7 +16936,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16949,7 +16950,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.653.0"
version = "1.655.0"
dependencies = [
"anyhow",
"serde",
@@ -16961,7 +16962,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16979,7 +16980,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.653.0"
version = "1.655.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16995,7 +16996,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -17011,7 +17012,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"serde",
@@ -17022,7 +17023,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17059,7 +17060,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"const_format",
@@ -17097,7 +17098,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17108,7 +17109,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17137,7 +17138,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -17160,7 +17161,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17193,7 +17194,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17213,7 +17214,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17247,7 +17248,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17282,7 +17283,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17305,7 +17306,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17329,7 +17330,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-nats",
@@ -17353,7 +17354,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17388,7 +17389,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17416,7 +17417,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17439,7 +17440,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17457,7 +17458,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17563,7 +17564,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.654.0"
version = "1.655.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.654.0"
version = "1.655.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.655.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
2f52c015bc6c81391234fa87b27ee1d4cd3a48a3
c74c86b78a66b976fd9968b21f77903723e668ec
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS kafka_pending_commits;
ALTER TABLE kafka_trigger DROP COLUMN auto_commit;
@@ -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);
+8 -1
View File
@@ -243,7 +243,14 @@ async fn cache_hub_scripts(file_path: Option<String>) -> 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<String> = 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
+3 -1
View File
@@ -109,7 +109,9 @@ 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)
@@ -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
@@ -251,7 +251,8 @@ async fn test_websocket_e2e(db: Pool<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> 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,
@@ -214,12 +214,9 @@ async fn test_capture_delete(db: Pool<Postgres>) -> 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<Postgres>) -> 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<CaptureResponse> = response.json().await?;
assert_eq!(captures.len(), 3);
@@ -480,12 +480,9 @@ async fn test_capture_api_delete(db: Pool<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> 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(())
}
+98 -2
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.654.0
version: 1.655.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
@@ -22098,6 +22183,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 +22254,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 +22317,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
@@ -23532,7 +23629,6 @@ components:
items:
$ref: "#/components/schemas/GitSyncObjectType"
required:
- script_path
- git_repo_resource_path
MetricMetadata:
+1
View File
@@ -1012,6 +1012,7 @@ async fn http_payload(
.to_v2_preprocessor_args(
&http_trigger_config.route_path,
&route_path,
"",
&params,
headers,
query,
@@ -468,6 +468,7 @@ async fn route_job(
.to_args_from_format(
&trigger.route_path,
&called_path,
&trigger.path,
&params,
runnable_format,
trigger.wrap_body,
@@ -68,6 +68,7 @@ struct HttpTriggerPreprocessorEvent<'a> {
kind: String,
route: &'a str,
path: &'a str,
trigger_path: &'a str,
body: Box<RawValue>,
raw_string: Option<String>,
params: &'a HashMap<String, String>,
@@ -117,6 +118,7 @@ impl HttpTriggerArgs {
self,
route_path: &str,
called_path: &str,
trigger_path: &str,
params: &HashMap<String, String>,
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<String, String>,
headers: HashMap<String, Box<RawValue>>,
query: HashMap<String, Box<RawValue>>,
@@ -193,6 +203,7 @@ impl HttpTriggerArgs {
method: (&self.0.metadata.method).try_into()?,
route: route_path,
path: called_path,
trigger_path,
params,
}),
);
+5 -6
View File
@@ -21,15 +21,14 @@ pub fn prepend_token_to_github_url(
) -> crate::error::Result<String> {
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()
))
}
@@ -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",
+3 -3
View File
@@ -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,
+16 -8
View File
@@ -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<Vec<ObjectType>>,
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<String>,
pub git_repo_resource_path: String,
pub use_individual_branch: Option<bool>,
pub group_by_folder: Option<bool>,
@@ -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<bool> {
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
});
+29 -4
View File
@@ -891,12 +891,12 @@ 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",
let deleted_linked_variable = sqlx::query_scalar!(
"DELETE FROM variable WHERE path = $1 AND workspace_id = $2 RETURNING path",
path,
w_id
)
.execute(&mut *tx)
.fetch_optional(&mut *tx)
.await?;
audit_log(
&mut *tx,
@@ -924,9 +924,34 @@ 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() },
);
if deleted_linked_variable.is_some() {
handle_deployment_metadata(
&authed.email,
&authed.username,
&db,
&w_id,
DeployedObject::Variable {
path: path.to_string(),
parent_path: Some(path.to_string()),
},
Some(format!(
"Variable '{}' deleted (linked resource deleted)",
path
)),
true,
None,
)
.await?;
webhook.send_message(
w_id.clone(),
WebhookMessage::DeleteVariable { workspace: w_id, path: path.to_owned() },
);
}
Ok(format!("resource {} deleted", path))
}
+29 -4
View File
@@ -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))
}
@@ -322,7 +322,7 @@ impl Listener for WebsocketTrigger {
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
mut trigger_info: HashMap<String, Box<RawValue>>,
extra: Option<Self::Extra>,
) -> 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,
+11 -1
View File
@@ -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<Self::TriggerConfig>,
payload: Self::Payload,
trigger_info: HashMap<String, Box<RawValue>>,
mut trigger_info: HashMap<String, Box<RawValue>>,
_extra: Option<Self::Extra>,
) -> 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,
@@ -422,11 +422,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(
+22 -2
View File
@@ -895,6 +895,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)]
@@ -902,6 +915,7 @@ pub struct SqlJobCompletedSender {
sender: flume::Sender<SendResult>,
unbounded_sender: flume::Sender<SendResult>,
killpill_tx: broadcast::Sender<()>,
worker_killpill_tx: Option<KillpillSender>,
}
pub struct JobCompletedReceiver {
@@ -926,7 +940,12 @@ impl JobCompletedSender {
let (unbounded_sender, unbounded_rx) = flume::unbounded::<SendResult>();
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 },
)
}
@@ -1722,7 +1741,8 @@ pub async fn run_worker(
let (same_worker_tx, mut same_worker_rx) = mpsc::channel::<SameWorkerPayload>(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());
+1 -1
View File
@@ -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.655.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+3
View File
@@ -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);
});
+11 -7
View File
@@ -157,7 +157,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
@@ -180,9 +180,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);
@@ -230,7 +232,7 @@ export async function generateAppLocksInternal(
yamlStringify(appFile as Record<string, any>, yamlOptions)
);
}
} else {
} else if (!noStaleMessage) {
log.info(colors.gray(`No scripts changed in ${appFolder}`));
}
}
@@ -246,7 +248,9 @@ 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`));
}
}
/**
@@ -767,7 +771,7 @@ export async function inferRunnableSchemaFromFile(
}
}
function getAppFolders(elems: Record<string, any>, extension: string) {
export function getAppFolders(elems: Record<string, any>, extension: string) {
return Object.keys(elems)
.filter((p) => p.endsWith(SEP + extension))
.map((p) => p.substring(0, p.length - (SEP + extension).length));
+20 -3
View File
@@ -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);
+34 -6
View File
@@ -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";
@@ -97,7 +98,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
@@ -120,15 +121,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 +148,20 @@ export async function generateFlowLockInternal(
filteredDeps
);
const lockAssigner = newPathAssigner(opts.defaultTs ?? "bun");
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 +182,9 @@ 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`));
}
}
/**
@@ -176,7 +196,15 @@ async function filterWorkspaceDependenciesForFlow(
rawWorkspaceDependencies: Record<string, string>,
folder: string
): Promise<Record<string, string>> {
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
@@ -0,0 +1,332 @@
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 } from "../flow/flow_metadata.ts";
import { generateAppLocksInternal, getAppFolders } from "../app/app_metadata.ts";
import {
elementsToMap,
FSFSElement,
ignoreF,
} from "../sync/sync.ts";
import { exts } from "../script/script.ts";
import { isFlowPath, isAppPath } 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)
);
},
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) {
// Strip trailing separator to match deprecated flow/app handler behavior
// (see generateFlowLockInternal line 64-66, generateAppLocksInternal line 109-110)
if (folder.endsWith(SEP)) {
folder = folder.substring(0, folder.length - 1);
}
filteredItems = staleItems.filter((item) => item.folder === folder || item.folder.startsWith(folder + SEP));
}
// === 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++;
log.info(`${formatProgress(current)} flow ${colors.cyan(item.path)}`);
await generateFlowLockInternal(
item.folder,
false, // dryRun
workspace,
opts,
false,
true // noStaleMessage - we handle output
);
}
// Process apps
for (const item of apps) {
current++;
log.info(`${formatProgress(current)} app ${colors.cyan(item.path)}`);
await generateAppLocksInternal(
item.folder,
item.isRawApp!, // rawApp
false, // dryRun
workspace,
opts,
false,
true // noStaleMessage - we handle output
);
}
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 <patterns:file[]>",
"Comma separated patterns to specify which files to include"
)
.option(
"-e --excludes <patterns:file[]>",
"Comma separated patterns to specify which files to exclude"
)
.action(generateMetadata as any);
export default command;
+4 -1
View File
@@ -978,7 +978,7 @@ export type GlobalDeps = Map<
Record<string, string>
>;
async function generateMetadata(
export async function generateMetadata(
opts: GlobalOptions & {
lockOnly?: boolean;
schemaOnly?: boolean;
@@ -986,6 +986,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`"
);
+22 -1
View File
@@ -592,14 +592,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}`,
+1882 -1781
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -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.655.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) => {
+2 -2
View File
@@ -35,7 +35,7 @@ function loadParser(pkgName: string): Promise<any> {
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);
@@ -223,7 +223,7 @@ export async function generateScriptMetadataInternal(
return `${remotePath} (${language})`;
}
if (!justUpdateMetadataLock) {
if (!justUpdateMetadataLock && !noStaleMessage) {
log.info(colors.gray(`Generating metadata for ${scriptPath}`));
}
@@ -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<string, string> = {
"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<string, string> = {
"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<string, string> = {
"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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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<string, string> = {};
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();
});
});
+18 -5
View File
@@ -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";
@@ -37,10 +44,13 @@ import { newPathAssigner } from "../windmill-utils-internal/src/path-utils/path-
// =============================================================================
// 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 +99,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<string, { path: string; content: string }> {
const flowSuffix = getFolderSuffix("flow");
@@ -123,7 +134,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<string, { path: string; content: string }> {
const appSuffix = getFolderSuffix("app");
@@ -151,7 +163,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<string, { path: string; content: string }> {
const rawAppSuffix = getFolderSuffix("raw_app");
@@ -1920,7 +1933,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,
+13
View File
@@ -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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
const response = await this.backend.apiRequest(
`/api/w/${this.workspace}/variables/create`,
+531
View File
@@ -0,0 +1,531 @@
/**
* 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,
} 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<string, string> = {
python3: ".py",
deno: ".ts",
bun: ".ts",
bash: ".sh",
go: ".go",
postgresql: ".sql",
};
const ext = extensions[language];
const defaultContent: Record<string, string> = {
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: `<!DOCTYPE html>
<html>
<head><title>${name}</title></head>
<body><div id="root"></div></body>
</html>`,
},
indexJs: {
path: `${name}${rawAppSuffix}/index.tsx`,
content: `import React from 'react'
import { createRoot } from 'react-dom/client'
const App = () => <div><h1>${name}</h1></div>
const root = createRoot(document.getElementById('root')!)
root.render(<App/>)
`,
},
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<void> {
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<void> {
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<void> {
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<void> {
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");
}
}
// =============================================================================
// Resource Fixtures (Variables, Resources, Schedules, etc.)
// =============================================================================
/**
* Creates a resource fixture.
*
* @keywords resource fixture, create resource
*/
export function createResourceFixture(
name: string,
resourceType: string,
value: Record<string, unknown>
): 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"
`,
};
}
+451
View File
@@ -0,0 +1,451 @@
/**
* 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 { writeFile } from "node:fs/promises";
import {
createLocalScript,
createLocalFlow,
createLocalApp,
createLocalRawApp,
} from "./test_fixtures.ts";
/**
* Helper to set up a workspace with wmill.yaml
*/
async function setupWorkspace(backend: any, tempDir: string, workspaceName: string) {
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
includes:
- "**"
excludes: []`, "utf-8");
}
// =============================================================================
// 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("--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");
});
});
});
@@ -150,8 +150,17 @@ export function extractInlineScripts(
*/
export function extractCurrentMapping(
modules: FlowModule[] | undefined,
mapping: Record<string, string> = {}
mapping: Record<string, string> = {},
failureModule?: FlowModule,
preprocessorModule?: FlowModule,
): Record<string, string> {
if (failureModule) {
extractCurrentMapping([failureModule], mapping);
}
if (preprocessorModule) {
extractCurrentMapping([preprocessorModule], mapping);
}
if (!modules || !Array.isArray(modules)) {
return mapping;
}
+10 -5
View File
@@ -15,17 +15,22 @@
- Standard location: `~/windmill-ee-private`
- Worktree location: `~/windmill-ee-private__worktrees/<branch-name>/`
## 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 <ee-path> 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] <type>: <description>`
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
+1 -1
View File
@@ -1 +1 @@
legacy-peer-deps=true
+1297 -259
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.654.0",
"version": "1.655.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": {
+2 -2
View File
@@ -1096,7 +1096,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
<div class="flex flex-row items-center gap-2 whitespace-nowrap">
{@render right?.()}
{#if scriptPath && !noHistory}
{#if scriptPath && !noHistory && customUi?.history != false}
<Button
unifiedSize="sm"
variant="subtle"
@@ -1120,7 +1120,7 @@ JsonNode ${windmillPathToCamelCaseName(path)} = JsonNode.Parse(await client.GetS
Library
</Button>
{/if}
{#if saveToWorkspace}
{#if saveToWorkspace && customUi?.saveToWorkspace != false}
<Button
unifiedSize="sm"
variant="subtle"
@@ -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 @@
<SmtpSettings {values} disabled={loading} />
{:else if setting.fieldType == 'secret_backend'}
<SecretBackendConfig {values} disabled={loading} />
{:else if setting.fieldType == 'github_enterprise_app'}
<GhesAppSettings {values} disabled={loading || !$enterpriseLicense} />
{/if}
{#if hasError}
<span class="text-red-600 dark:text-red-400 text-xs">
@@ -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<Record<string, any>> = writable({})
@@ -77,7 +79,8 @@
smtp_settings: {},
otel: {},
indexer_settings: {},
critical_error_channels: []
critical_error_channels: [],
github_enterprise_app: {}
}
function applyFormDefaults(vals: Record<string, any>): 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'}
<SettingsPageHeader
title="GitHub Enterprise App"
description="Configure a self-managed GitHub App for GitHub Enterprise Server git sync."
/>
{:else if category == 'Auth/OAuth/SAML'}
<AuthSettings
bind:oauths
@@ -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
/>
</DrawerContent>
</Drawer>
@@ -159,7 +161,7 @@
options={{ right: 'Unified' }}
size="xs"
/>
<SaveButton onSave={handleSaveAndCloseDiff} disabled={!hasUnsavedChanges} size="xs" />
<SaveButton onSave={handleSaveAndCloseDiff} disabled={!hasUnsavedChanges || hasAnyInvalid} size="xs" />
{/snippet}
<!-- DiffEditor reacts to inlineDiff changes via $effect — no {#key} needed -->
<div class="h-full">
@@ -46,7 +46,8 @@
closeDrawer,
showHeaderInfo = true,
yamlMode = $bindable(false),
hasUnsavedChanges = $bindable(false)
hasUnsavedChanges = $bindable(false),
hasAnyInvalid = $bindable(false)
} = $props()
function removeHash() {
@@ -510,6 +511,7 @@
hideTabs
bind:yamlMode
bind:hasUnsavedChanges
bind:hasAnyInvalid
tab={instanceSettingsCategory}
{authSubTab}
{closeDrawer}
+3
View File
@@ -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 = {
@@ -288,7 +288,7 @@
{/each}
</div>
{#if !failureModule && !preprocessorModule}
{#if !failureModule && !preprocessorModule && customUi?.aiSandbox != false}
<h3 class="pb-2 pt-4">AI Sandbox</h3>
<div class="flex flex-row flex-wrap gap-2">
<FlowScriptPicker
@@ -491,7 +491,7 @@
{/await}
<div class="pb-1"></div>
{/if}
{#if selectedKind === 'script' && preFilter === 'all' && !selected}
{#if selectedKind === 'script' && preFilter === 'all' && !selected && customUi?.aiSandbox != false}
<div class="pb-0 text-2xs font-normal text-secondary ml-2">AI Sandbox</div>
<FlowScriptPickerQuick
eeRestricted={false}
@@ -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)
</script>
{#if flowStore.val?.value?.failure_module}
@@ -67,7 +71,7 @@
<Button
variant="default"
unifiedSize="sm"
wrapperClasses={twMerge('min-w-36', small ? 'max-w-52' : 'max-w-64')}
wrapperClasses={compact ? undefined : twMerge('min-w-36', small ? 'max-w-52' : 'max-w-64')}
id="flow-editor-error-handler"
selected={selectionManager.getSelectedId()?.includes('failure')}
onClick={() => {
@@ -87,26 +91,40 @@
/>
{/if}
<Bug size={14} class="shrink-0" />
{#if !smallFailureModule}
<div class="truncate grow min-w-0 text-center text-xs">
{flowStore.val.value.failure_module?.summary ||
(flowStore.val.value.failure_module?.value.type === 'rawscript'
? `${flowStore.val.value.failure_module?.value.language}`
: 'TBD')}
</div>
<div class="truncate grow min-w-0 text-center text-xs">
{flowStore.val.value.failure_module?.summary ||
(flowStore.val.value.failure_module?.value.type === 'rawscript'
? `${flowStore.val.value.failure_module?.value.language}`
: 'TBD')}
</div>
<button
title="Delete failure script"
type="button"
class="ml-1"
onclick={() => {
flowStore.val.value.failure_module = undefined
selectionManager.selectId('settings-metadata')
}}
>
<X size={12} />
</button>
{/if}
</Button>
{#if smallFailureModule}
<button
title="Delete failure script"
type="button"
class="ml-1"
class="absolute -top-1.5 -right-1.5 rounded-full bg-surface border border-border p-0.5 hover:bg-surface-hover"
onclick={() => {
flowStore.val.value.failure_module = undefined
selectionManager.selectId('settings-metadata')
}}
>
<X size={12} />
<X size={10} />
</button>
</Button>
{/if}
</div>
{:else}
<!-- Index 0 is used by the tutorial to identify the first "Add step" -->
@@ -124,14 +142,17 @@
{#snippet trigger()}
<Button
unifiedSize="sm"
wrapperClasses="min-w-36"
wrapperClasses={compact ? undefined : 'min-w-36'}
title={`Add failure module`}
variant="default"
id={`flow-editor-add-step-error-handler-button`}
nonCaptureEvent
startIcon={{ icon: Bug }}
iconOnly={compact}
>
Error Handler
{#if !compact}
Error Handler
{/if}
</Button>
{/snippet}
</InsertModulePopover>
@@ -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}
</ConfirmationModal>
</Portal>
<div class="flex flex-col h-full relative -pt-1">
<div class="flex flex-col h-full relative -pt-1" bind:clientWidth={flowPaneWidth}>
<div
class={`z-50 absolute inline-flex flex-col gap-2 top-3 left-1/2 -translate-x-1/2 flex-initial items-center transition-colors duration-[400ms] ease-linear bg-surface-100`}
>
<FlowStickyNode
compact={compactTopbar}
{disableAi}
{showFlowAiButton}
{disableSettings}
@@ -21,6 +21,7 @@
toggleNoteMode?: () => 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>('FlowEditorContext')
@@ -42,23 +44,31 @@
<div class="flex flex-row gap-2 p-1 rounded-md bg-surface">
{#if !disableSettings}
<Button
unifiedSize="sm"
wrapperClasses="min-w-36"
startIcon={{ icon: Settings }}
selected={selectedId?.startsWith('settings')}
variant="default"
title="Settings"
onClick={() => selectionManager.selectId('settings')}
>
Settings
{#if flowStore.val.value.same_worker}
<Badge color="blue" wrapperClass="max-h-[18px]">./shared</Badge>
{/if}
</Button>
<Popover>
<Button
unifiedSize="sm"
wrapperClasses={compact ? undefined : 'min-w-36'}
startIcon={{ icon: Settings }}
selected={selectedId?.startsWith('settings')}
variant="default"
title="Settings"
iconOnly={compact && !flowStore.val.value.same_worker}
onClick={() => selectionManager.selectId('settings')}
>
{#if !compact}
Settings
{/if}
{#if flowStore.val.value.same_worker}
<Badge color="blue" wrapperClass="max-h-[18px]">./shared</Badge>
{/if}
</Button>
{#snippet text()}
Settings
{/snippet}
</Popover>
{/if}
<Popover>
<FlowErrorHandlerItem {disableAi} small={smallErrorHandler} {diffManager} on:generateStep />
<FlowErrorHandlerItem {disableAi} small={smallErrorHandler} {compact} {diffManager} on:generateStep />
{#snippet text()}
Error Handler
{/snippet}
@@ -187,6 +187,7 @@
}}
/>
{/if}
{#if customUi?.aiSandbox != false}
<TopLevelNode
label="AI Sandbox"
selected={selectedKind === 'aisandbox'}
@@ -195,6 +196,7 @@
}}
/>
{/if}
{/if}
</div>
{/if}
@@ -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: {
@@ -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 @@
</div>
{#if !emptyString(repo.git_repo_resource_path)}
<Button
disabled={emptyString(repo.script_path)}
disabled={emptyString(repo.git_repo_resource_path)}
variant="accent"
onclick={runGitSyncTestJob}
size="xs"
@@ -447,29 +445,21 @@
</Alert>
{/if}
{#if repo.script_path != hubPaths.gitSync}
<Alert type="warning" title="Script version mismatch">
The git sync version for this repository is not latest. Current: <a
target="_blank"
href="{DEFAULT_HUB_BASE_URL}/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{repo.script_path}</a
>, latest:
<a
target="_blank"
href="{DEFAULT_HUB_BASE_URL}/scripts/windmill/6943/sync-script-to-git-repo-windmill/9014/versions"
>{hubPaths.gitSync}</a
>
{#if repo.script_path}
<Alert type="warning" title="Pinned git sync script version">
This repository uses a pinned sync script: <code>{repo.script_path}</code>.
Switch to auto-managed to always use the latest version bundled with Windmill.
<div class="flex mt-2">
<Button
size="xs"
variant="accent"
onclick={() => {
if (repo) {
repo.script_path = hubPaths.gitSync
repo.script_path = undefined
}
}}
>
Update git sync script (require save git settings to be applied)
Switch to auto-managed (requires save)
</Button>
</div>
</Alert>
@@ -51,6 +51,7 @@ export interface Setting {
| 'otel'
| 'otel_tracing_proxy'
| 'secret_backend'
| 'github_enterprise_app'
storage: SettingStorage
advancedToggle?: {
label: string
@@ -665,6 +666,23 @@ export const settings: Record<string, Setting[]> = {
storage: 'setting',
ee_only: 'HashiCorp Vault integration is an Enterprise Edition feature'
}
],
'GitHub Enterprise App': [
{
label: 'GitHub Enterprise App',
description:
'Configure a self-managed GitHub App for GitHub Enterprise Server (or any GitHub instance) to enable git sync without stats.windmill.dev.',
key: 'github_enterprise_app',
fieldType: 'github_enterprise_app',
storage: 'setting',
ee_only: '',
error:
'When self-managed mode is enabled, Base URL, App ID, App Slug, and Private Key are required.',
isValid: (v: any) => {
if (!v?.self_managed) return true
return !!(v?.base_url && v?.app_id && v?.app_slug && v?.private_key)
}
}
]
}
@@ -772,6 +790,13 @@ export const instanceSettingsNavigationGroups = [
{
title: 'Advanced',
items: [
{
id: 'github_enterprise_app',
label: 'GitHub Enterprise App',
aiId: 'instance-settings-github-enterprise-app',
aiDescription: 'Self-managed GitHub App for GitHub Enterprise Server git sync',
isEE: true
},
{
id: 'private_hub',
label: 'Private Hub',
@@ -809,7 +834,8 @@ export const tabToCategoryMap: Record<string, string> = {
secret_storage: 'Secret Storage',
object_storage: 'Object Storage',
jobs: 'Jobs',
private_hub: 'Private Hub'
private_hub: 'Private Hub',
github_enterprise_app: 'GitHub Enterprise App'
}
export const tabToAuthSubTab: Record<string, 'sso' | 'oauth' | 'scim'> = {
@@ -838,7 +864,8 @@ export const categoryToTabMap: Record<string, string> = {
'Secret Storage': 'secret_storage',
'Object Storage': 'object_storage',
Jobs: 'jobs',
'Private Hub': 'private_hub'
'Private Hub': 'private_hub',
'GitHub Enterprise App': 'github_enterprise_app'
}
export interface SearchableSettingItem {
@@ -0,0 +1,172 @@
<script lang="ts">
import TextInput from '../text_input/TextInput.svelte'
import Toggle from '../Toggle.svelte'
import type { Writable } from 'svelte/store'
interface Props {
values: Writable<Record<string, any>>
disabled?: boolean
}
let { values, disabled = false }: Props = $props()
// Ensure the nested object exists
if (!$values['github_enterprise_app']) {
$values['github_enterprise_app'] = {}
}
let selfManaged = $derived(!!$values['github_enterprise_app'].self_managed)
let fieldsDisabled = $derived(disabled || !selfManaged)
</script>
<div class="space-y-6">
<Toggle
size="xs"
options={{ right: 'Self-managed GitHub App (for GHES or custom GitHub App)' }}
checked={selfManaged}
on:change={() => {
$values['github_enterprise_app'] = {
...$values['github_enterprise_app'],
self_managed: !selfManaged
}
}}
/>
{#if !selfManaged}
<p class="text-xs text-secondary">
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).
</p>
{:else}
<details class="mt-1">
<summary class="text-xs font-medium text-secondary cursor-pointer hover:text-primary"
>How to create a GitHub App</summary
>
<div class="mt-2 p-3 bg-surface rounded text-2xs text-secondary space-y-2">
<p>
<strong>1.</strong> On your GitHub instance, go to
<strong>Settings &rarr; Developer settings &rarr; GitHub Apps &rarr; New GitHub App</strong
>.
</p>
<p><strong>2.</strong> Fill in the required fields:</p>
<ul class="list-disc ml-4 space-y-1">
<li>
<strong>GitHub App name</strong>: e.g. <code>windmill-sync</code> (this becomes the app
slug)
</li>
<li>
<strong>Homepage URL</strong>: your Windmill instance URL
</li>
<li>
<strong>Callback URL</strong>: <code>&lt;your-windmill-url&gt;/gh_success</code>
</li>
<li>
<strong>Setup URL</strong> (optional):
<code>&lt;your-windmill-url&gt;/gh_success</code> with "Redirect on update" checked
</li>
<li>Uncheck <strong>Active</strong> under Webhook (not needed)</li>
</ul>
<p><strong>3.</strong> Set repository permissions:</p>
<ul class="list-disc ml-4 space-y-1">
<li><strong>Contents</strong>: Read &amp; write</li>
<li><strong>Metadata</strong>: Read-only</li>
</ul>
<p>
<strong>4.</strong> Under "Where can this GitHub App be installed?", choose
<strong>Any account</strong> (or restrict to your organization).
</p>
<p>
<strong>5.</strong> Click <strong>Create GitHub App</strong>. On the next page, note the
<strong>App ID</strong> and <strong>Client ID</strong>.
</p>
<p>
<strong>6.</strong> Scroll down and click <strong>Generate a private key</strong>. Save the
downloaded <code>.pem</code> file — paste its contents into the Private Key field below.
</p>
<p>
<strong>7.</strong> The <strong>App Slug</strong> is the URL-friendly name shown in the
app's URL (e.g. <code>github.com/apps/<strong>windmill-sync</strong></code
>).
</p>
<p>
<strong>8.</strong> The <strong>Base URL</strong> is your GitHub instance root (e.g.
<code>https://github.com</code> or <code>https://github.mycompany.com</code>).
</p>
</div>
</details>
{/if}
<div class="grid grid-cols-2 gap-x-2 gap-y-6">
<div class="flex flex-col gap-1">
<label for="ghes_base_url" class="block text-xs font-semibold text-emphasis mb-1">
Base URL
</label>
<TextInput
inputProps={{
type: 'text',
id: 'ghes_base_url',
placeholder: 'https://github.mycompany.com',
disabled: fieldsDisabled
}}
bind:value={$values['github_enterprise_app'].base_url}
/>
</div>
<div class="flex flex-col gap-1">
<label for="ghes_app_id" class="block text-xs font-semibold text-emphasis mb-1">
App ID
</label>
<TextInput
inputProps={{
type: 'number',
id: 'ghes_app_id',
placeholder: '12345',
disabled: fieldsDisabled
}}
bind:value={$values['github_enterprise_app'].app_id}
/>
</div>
<div class="flex flex-col gap-1">
<label for="ghes_app_slug" class="block text-xs font-semibold text-emphasis mb-1">
App Slug
</label>
<TextInput
inputProps={{
type: 'text',
id: 'ghes_app_slug',
placeholder: 'my-windmill-app',
disabled: fieldsDisabled
}}
bind:value={$values['github_enterprise_app'].app_slug}
/>
</div>
<div class="flex flex-col gap-1">
<label for="ghes_client_id" class="block text-xs font-semibold text-emphasis mb-1">
Client ID
</label>
<TextInput
inputProps={{
type: 'text',
id: 'ghes_client_id',
placeholder: 'Iv1.abc123',
disabled: fieldsDisabled
}}
bind:value={$values['github_enterprise_app'].client_id}
/>
</div>
</div>
<div class="flex flex-col gap-1">
<label for="ghes_private_key" class="block text-xs font-semibold text-emphasis mb-1">
Private Key (PEM)
</label>
<textarea
id="ghes_private_key"
class="w-full h-32 font-mono text-xs p-2 border rounded resize-y {fieldsDisabled
? 'bg-surface-disabled text-disabled cursor-not-allowed'
: 'bg-surface text-primary'}"
placeholder="-----BEGIN RSA PRIVATE KEY-----&#10;...&#10;-----END RSA PRIVATE KEY-----"
disabled={fieldsDisabled}
bind:value={$values['github_enterprise_app'].private_key}
></textarea>
</div>
</div>
@@ -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'
@@ -1,53 +1,11 @@
<script module lang="ts">
function computeMinMaxInc(inc: number) {
let minTs = new Date(new Date().getTime() - inc).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
}
export type Timeframe =
| {
label: string
computeMinMax: () => { minTs: string | null; maxTs: string | null }
type: 'dynamic'
}
| {
label: string
computeMinMax: () => { minTs: string | null; maxTs: string | null }
minTs: string | null
maxTs: string | null
type: 'manual'
}
export function buildManualTimeframe(minTs: string | null, maxTs: string | null): Timeframe {
return {
label: formatDateRange(minTs ?? undefined, maxTs ?? undefined),
minTs,
maxTs,
type: 'manual',
computeMinMax: () => ({ minTs, maxTs })
}
}
export const serviceLogsTimeframes: Timeframe[] = [
{ label: '1000 last service logs', computeMinMax: () => ({ minTs: null, maxTs: null }) },
{ label: 'Within last 5 minutes', computeMinMax: () => computeMinMaxInc(5 * 60 * 1000) },
{ label: 'Within last 30 minutes', computeMinMax: () => computeMinMaxInc(30 * 60 * 1000) },
{ label: 'Within last 24 hours', computeMinMax: () => computeMinMaxInc(24 * 60 * 60 * 1000) },
{ label: 'Within last 7 days', computeMinMax: () => computeMinMaxInc(7 * 24 * 60 * 60 * 1000) },
{ label: 'Within last month', computeMinMax: () => computeMinMaxInc(30 * 24 * 60 * 60 * 1000) }
].map((item) => ({ ...item, type: 'dynamic' }))
export const runsTimeframes: Timeframe[] = [
{ label: 'Latest runs', computeMinMax: () => ({ minTs: null, maxTs: null }) },
{ label: 'Within 30 seconds', computeMinMax: () => computeMinMaxInc(30 * 1000) },
{ label: 'Within last minute', computeMinMax: () => computeMinMaxInc(60 * 1000) },
{ label: 'Within last 5 minutes', computeMinMax: () => computeMinMaxInc(5 * 60 * 1000) },
{ label: 'Within last 30 minutes', computeMinMax: () => computeMinMaxInc(30 * 60 * 1000) },
{ label: 'Within last 24 hours', computeMinMax: () => computeMinMaxInc(24 * 60 * 60 * 1000) },
{ label: 'Within last 7 days', computeMinMax: () => computeMinMaxInc(7 * 24 * 60 * 60 * 1000) },
{ label: 'Within last month', computeMinMax: () => computeMinMaxInc(30 * 24 * 60 * 60 * 1000) }
].map((item) => ({ ...item, type: 'dynamic' }))
export {
type Timeframe,
buildManualTimeframe,
serviceLogsTimeframes,
runsTimeframes
} from './timeframes'
import { type Timeframe, buildManualTimeframe } from './timeframes'
export function useUrlSyncedTimeframe(timeframes: Timeframe[]) {
let obj = $state({ timeframe: timeframes[0] })
@@ -121,7 +79,6 @@
import { CalendarIcon, RefreshCw } from 'lucide-svelte'
import { Button } from '../common'
import Popover from '../meltComponents/Popover.svelte'
import { formatDateRange } from '$lib/utils'
import { watch } from 'runed'
import { page } from '$app/state'
import InlineCalendarInput, {
@@ -16,7 +16,7 @@ import {
} from 'lucide-svelte'
import { triggerDisplayNamesMap } from '../triggers/utils'
import type { FilterInstanceRec, FilterSchemaRec } from '../FilterSearchbar.svelte'
import { runsTimeframes } from './TimeframeSelect.svelte'
import { runsTimeframes } from './timeframes'
export function buildRunsFilterSearchbarSchema({
paths,

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