Compare commits
68 Commits
py-typeche
...
folder-dep
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba775edef2 | ||
|
|
91f0a564b9 | ||
|
|
c3a1c26be1 | ||
|
|
c87a6a0f2c | ||
|
|
350ffdce29 | ||
|
|
28c073056c | ||
|
|
c86846ac19 | ||
|
|
7ab0ea581d | ||
|
|
bcce627387 | ||
|
|
175af8032f | ||
|
|
1784bed4ac | ||
|
|
a46aa641f9 | ||
|
|
7069202190 | ||
|
|
df7a8eebcf | ||
|
|
2862c1cf56 | ||
|
|
d67223de9b | ||
|
|
da8886be85 | ||
|
|
040a199685 | ||
|
|
6c3c971af5 | ||
|
|
852c59efbb | ||
|
|
89d1acda24 | ||
|
|
12ea7e7423 | ||
|
|
375fb66abe | ||
|
|
52a04d210f | ||
|
|
cf30bcf3f9 | ||
|
|
b69d63eeb0 | ||
|
|
26050f96c3 | ||
|
|
c5fccd2f69 | ||
|
|
92b9ac72c5 | ||
|
|
fbb6170650 | ||
|
|
d6957aaf31 | ||
|
|
cd8edcd94f | ||
|
|
9d85768287 | ||
|
|
0520b11d5d | ||
|
|
c41e3adcc6 | ||
|
|
ef1757f5d7 | ||
|
|
bfc2aefdb8 | ||
|
|
3876902a7b | ||
|
|
8e973c892d | ||
|
|
2d27b17a05 | ||
|
|
96d4b3f123 | ||
|
|
4817913f0c | ||
|
|
d532c1d470 | ||
|
|
b5185b0e12 | ||
|
|
abc6b12d68 | ||
|
|
ee6231590e | ||
|
|
ee1afb2415 | ||
|
|
adfb0bd5ff | ||
|
|
98934d59c5 | ||
|
|
33032ed297 | ||
|
|
22da5bd9ea | ||
|
|
f3012ee7cc | ||
|
|
5f2d3e6812 | ||
|
|
9bcda7023f | ||
|
|
9f86c72133 | ||
|
|
754b88a52c | ||
|
|
06bbe7b94b | ||
|
|
970e859a41 | ||
|
|
845db72b73 | ||
|
|
f5fc9f8485 | ||
|
|
be7fbeb8b1 | ||
|
|
8c770a206a | ||
|
|
74fba2abf3 | ||
|
|
b4d1f2aac7 | ||
|
|
8baa7f8a20 | ||
|
|
0549f682fe | ||
|
|
73f649c152 | ||
|
|
c6ce3197a7 |
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: native-trigger
|
||||
description: Guidance for adding native trigger services to Windmill. Use when implementing or modifying native trigger integrations across the backend and frontend.
|
||||
---
|
||||
|
||||
# Skill: Adding Native Trigger Services
|
||||
|
||||
This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications.
|
||||
|
||||
25
.claude/review-prompt.md
Normal file
25
.claude/review-prompt.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Code Review Instructions
|
||||
|
||||
Review this pull request and provide comprehensive feedback.
|
||||
|
||||
## Focus Areas
|
||||
|
||||
- **Code quality and best practices** — does the code follow established patterns?
|
||||
- **Potential bugs or issues** — will this code work correctly in all cases?
|
||||
- **Performance considerations** — are there unnecessary allocations, N+1 queries, or bottlenecks?
|
||||
- **Security implications** — injection, auth bypass, data exposure?
|
||||
|
||||
## CLAUDE.md Compliance
|
||||
|
||||
Read all relevant CLAUDE.md files (root and in directories containing changed files). Check each rule against the changed code. Quote the exact rule when flagging a violation.
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
- Provide detailed feedback using inline comments for specific issues
|
||||
- Use top-level comments for general observations or praise
|
||||
- Only flag issues introduced by this PR, not pre-existing problems
|
||||
- Self-validate each finding: "Is this definitely a real issue?" If uncertain, discard it
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
At the end of your review, add complete instructions to reproduce the added changes through the app interface. These instructions will be given to a tester so they can verify the changes. It should be a short descriptive text (not a step-by-step or a list) on how to navigate the app (what page, what action, what input, etc.) to see the changes.
|
||||
@@ -6,53 +6,24 @@ description: Code review a pull request for bugs and CLAUDE.md compliance. MUST
|
||||
|
||||
# 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
|
||||
Run the same review locally that the GitHub Claude Auto Review action runs on PRs. The shared review instructions live in `.claude/review-prompt.md` — read that file first and follow its instructions.
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Determine the PR scope**:
|
||||
1. **Read `.claude/review-prompt.md`** for the review criteria and focus areas
|
||||
|
||||
2. **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
|
||||
5. **Apply the review instructions from `.claude/review-prompt.md`**
|
||||
|
||||
6. **Self-validate each finding**: Before reporting, ask yourself:
|
||||
- "Is this definitely a real issue, not a false positive?"
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: native-trigger
|
||||
description: Guidance for adding native trigger services to Windmill. Use when implementing or modifying native trigger integrations across the backend and frontend.
|
||||
---
|
||||
|
||||
# Skill: Adding Native Trigger Services
|
||||
|
||||
This skill provides comprehensive guidance for adding new native trigger services to Windmill. Native triggers allow external services (like Nextcloud, Google Drive, etc.) to trigger Windmill scripts/flows via webhooks or push notifications.
|
||||
|
||||
@@ -61,12 +61,13 @@ Generated with [Claude Code](https://claude.com/claude-code)
|
||||
1. Run `git status` to check for uncommitted changes
|
||||
2. Run `git log main..HEAD --oneline` to see all commits in this branch
|
||||
3. Run `git diff main...HEAD` to see the full diff against main
|
||||
4. Check if remote branch exists and is up to date:
|
||||
4. **Run `/local-review`** before creating the PR. If issues are found, fix them and commit before proceeding. Do not skip this step.
|
||||
5. Check if remote branch exists and is up to date:
|
||||
```bash
|
||||
git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null || echo "no upstream"
|
||||
```
|
||||
5. Push to remote if needed: `git push -u origin HEAD`
|
||||
6. Create draft PR using gh CLI:
|
||||
6. Push to remote if needed: `git push -u origin HEAD`
|
||||
7. Create draft PR using gh CLI:
|
||||
```bash
|
||||
gh pr create --draft --title "<type>: <description>" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
@@ -85,7 +86,7 @@ Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
7. Return the PR URL to the user
|
||||
8. Return the PR URL to the user
|
||||
|
||||
## EE Companion PR (when `*_ee.rs` files were modified)
|
||||
|
||||
|
||||
23
.github/codex/pr-review.prompt.md
vendored
Normal file
23
.github/codex/pr-review.prompt.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
You are reviewing a GitHub pull request for this repository.
|
||||
|
||||
Review policy:
|
||||
- Read `CLAUDE.md` before reviewing code.
|
||||
- Only report issues you are confident are real and introduced by this pull request.
|
||||
- Focus on bugs, security problems, and clear `CLAUDE.md` violations.
|
||||
- Do not report style nits, speculative concerns, pre-existing issues, or problems that a normal linter/typechecker would obviously catch.
|
||||
- Keep the review high signal. If there is no clear issue, return no findings.
|
||||
|
||||
Repository context:
|
||||
- Read `./.github/codex/pr-review-context.md` for the PR metadata and the exact diff commands to use.
|
||||
- Review only the changes introduced by this PR.
|
||||
- Read additional files only when the diff is not enough to validate a finding.
|
||||
- Do not modify any files.
|
||||
|
||||
Output requirements:
|
||||
- Return a GitHub PR comment in markdown, not JSON.
|
||||
- Start with `## Codex Review`.
|
||||
- Give a short overall summary first.
|
||||
- If you found high-signal issues, list them in a short numbered list with file paths and line numbers when you know them confidently.
|
||||
- If you found no high-signal issues, say that explicitly.
|
||||
- End with a `### Reproduction instructions` section containing a short descriptive paragraph for a tester explaining how to navigate the app to observe the change. Do not make it a numbered list. If the diff is not enough to infer this safely, say that plainly.
|
||||
- Prefer at most 10 findings.
|
||||
145
.github/workflows/codex-pr-review.yml
vendored
Normal file
145
.github/workflows/codex-pr-review.yml
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
name: Codex Auto Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ready_for_review, opened]
|
||||
|
||||
concurrency:
|
||||
group: codex-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
codex-review:
|
||||
runs-on: ubicloud-standard-2
|
||||
timeout-minutes: 30
|
||||
if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- name: Check Codex configuration
|
||||
id: codex_config
|
||||
env:
|
||||
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
|
||||
run: |
|
||||
if [ -n "$CODEX_AUTH_JSON" ]; then
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "CODEX_AUTH_JSON is not configured; skipping Codex review."
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
if: steps.codex_config.outputs.enabled == 'true'
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: refs/pull/${{ github.event.pull_request.number }}/merge
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.codex_config.outputs.enabled == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install Codex CLI
|
||||
if: steps.codex_config.outputs.enabled == 'true'
|
||||
run: npm install --global @openai/codex@0.117.0
|
||||
|
||||
- name: Configure file-backed Codex auth
|
||||
if: steps.codex_config.outputs.enabled == 'true'
|
||||
env:
|
||||
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
|
||||
run: |
|
||||
CODEX_HOME="$HOME/.codex"
|
||||
echo "CODEX_HOME=$CODEX_HOME" >> "$GITHUB_ENV"
|
||||
mkdir -p "$CODEX_HOME"
|
||||
chmod 700 "$CODEX_HOME"
|
||||
cat > "$CODEX_HOME/config.toml" <<'EOF'
|
||||
cli_auth_credentials_store = "file"
|
||||
EOF
|
||||
printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"
|
||||
chmod 600 "$CODEX_HOME/auth.json"
|
||||
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json"
|
||||
|
||||
- name: Pre-fetch base and head refs for the PR
|
||||
if: steps.codex_config.outputs.enabled == 'true'
|
||||
env:
|
||||
PR_BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
git fetch --no-tags origin \
|
||||
"$PR_BASE_REF" \
|
||||
"+refs/pull/$PR_NUMBER/head"
|
||||
|
||||
- name: Write Codex review context
|
||||
if: steps.codex_config.outputs.enabled == 'true'
|
||||
env:
|
||||
PR_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body || '' }}
|
||||
run: |
|
||||
mkdir -p .github/codex
|
||||
node <<'NODE'
|
||||
const fs = require('fs');
|
||||
const lines = [
|
||||
`Repository: ${process.env.PR_REPOSITORY}`,
|
||||
`PR number: ${process.env.PR_NUMBER}`,
|
||||
`Base SHA: ${process.env.PR_BASE_SHA}`,
|
||||
`Head SHA: ${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
'PR title:',
|
||||
process.env.PR_TITLE || '(empty)',
|
||||
'',
|
||||
'PR body:',
|
||||
process.env.PR_BODY || '(empty)',
|
||||
'',
|
||||
'Changed commits command:',
|
||||
`git log --oneline ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
'Changed files command:',
|
||||
`git diff --stat ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`,
|
||||
'',
|
||||
'Full review diff command:',
|
||||
`git diff --unified=0 ${process.env.PR_BASE_SHA}...${process.env.PR_HEAD_SHA}`
|
||||
];
|
||||
fs.writeFileSync('.github/codex/pr-review-context.md', `${lines.join('\n')}\n`);
|
||||
NODE
|
||||
|
||||
- name: Run Codex review
|
||||
if: steps.codex_config.outputs.enabled == 'true'
|
||||
run: |
|
||||
codex exec \
|
||||
-C "$GITHUB_WORKSPACE" \
|
||||
-m gpt-5.4 \
|
||||
-c 'model_reasoning_effort="xhigh"' \
|
||||
-s read-only \
|
||||
-o codex-final-message.md \
|
||||
- < .github/codex/pr-review.prompt.md
|
||||
|
||||
- name: Post Codex review comment
|
||||
if: steps.codex_config.outputs.enabled == 'true'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = `${process.env.GITHUB_WORKSPACE}/codex-final-message.md`;
|
||||
if (!fs.existsSync(path)) {
|
||||
core.info('Codex did not produce a final message; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
const body = fs.readFileSync(path, 'utf8').trim();
|
||||
if (!body) {
|
||||
core.info('Codex final message was empty; skipping PR comment.');
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body,
|
||||
});
|
||||
22
.github/workflows/pr-ready-review.yml
vendored
22
.github/workflows/pr-ready-review.yml
vendored
@@ -22,6 +22,15 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Read review prompt
|
||||
id: review-prompt
|
||||
run: |
|
||||
{
|
||||
echo 'REVIEW_PROMPT<<EOF'
|
||||
cat .claude/review-prompt.md
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Automatic PR Review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
@@ -31,18 +40,7 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide comprehensive feedback.
|
||||
|
||||
Focus on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security implications
|
||||
|
||||
Provide detailed feedback using inline comments for specific issues.
|
||||
Use top-level comments for general observations or praise.
|
||||
|
||||
At the end of your review, add complete instructions to reproduce the added changes through the app interface. These instructions will be given to a tester so he can verify the changes. It should be a short descriptive text (not a step by step or a list) on how to navigate the app (what page, what action, what input, etc) to see the changes.
|
||||
${{ env.REVIEW_PROMPT }}
|
||||
claude_args: |
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
|
||||
--model opus
|
||||
|
||||
@@ -43,7 +43,7 @@ profiles:
|
||||
- Pane 0: this pane (claude agent)
|
||||
- Pane 1: backend (cargo watch -x run)
|
||||
- Pane 2: frontend (npm run dev)
|
||||
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (backend) or \`tmux capture-pane -t .2 -p -S -50\` (frontend).
|
||||
To check logs, use: \`tmux capture-pane -t $(tmux display-message -t "$TMUX_PANE" -p '#{session_name}:#{window_name}').1 -p -S -50\` (backend) or \`tmux capture-pane -t $(tmux display-message -t "$TMUX_PANE" -p '#{session_name}:#{window_name}').2 -p -S -50\` (frontend).
|
||||
For this window specifically, backend is running on: ${BACKEND_PORT} and frontend is running on: ${FRONTEND_PORT}.
|
||||
To connect to the database, use this connection string: ${DATABASE_URL}
|
||||
Because we are running backend with cargo watch, to verify your changes, just check the logs in the backend pane. No need for cargo check.
|
||||
@@ -72,7 +72,7 @@ profiles:
|
||||
Pane layout (current window):
|
||||
- Pane 0: this pane (claude agent)
|
||||
- Pane 1: frontend (npm run dev)
|
||||
To check logs, use: \`tmux capture-pane -t .1 -p -S -50\` (frontend).
|
||||
To check logs, use: \`tmux capture-pane -t $(tmux display-message -t "$TMUX_PANE" -p '#{session_name}:#{window_name}').1 -p -S -50\` (frontend).
|
||||
On this window specifically, frontend is running on: ${FRONTEND_PORT}.
|
||||
To connect to the database, use this connection string: ${DATABASE_URL}
|
||||
Because we are running frontend with npm run dev, to verify your changes, just check the logs in the frontend pane. No need for npm run build.
|
||||
|
||||
101
CHANGELOG.md
101
CHANGELOG.md
@@ -1,5 +1,106 @@
|
||||
# Changelog
|
||||
|
||||
## [1.672.0](https://github.com/windmill-labs/windmill/compare/v1.671.0...v1.672.0) (2026-04-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add R language support ([#8263](https://github.com/windmill-labs/windmill/issues/8263)) ([a46aa64](https://github.com/windmill-labs/windmill/commit/a46aa641f9d72809c52a0eb11a877a0f2d587c32))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* approval page freeze, stale state, and missing approval link ([#8653](https://github.com/windmill-labs/windmill/issues/8653)) ([7069202](https://github.com/windmill-labs/windmill/commit/70692021909443b86ed61fa621fe49f28742fb54))
|
||||
|
||||
## [1.671.0](https://github.com/windmill-labs/windmill/compare/v1.670.0...v1.671.0) (2026-03-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add configurable preview job tag override in default tags settings ([#8649](https://github.com/windmill-labs/windmill/issues/8649)) ([da8886b](https://github.com/windmill-labs/windmill/commit/da8886be8575dd925b6d24c55ab379bc6984c5f8))
|
||||
* improve CLI flow log streaming and job inspection ([#8644](https://github.com/windmill-labs/windmill/issues/8644)) ([6c3c971](https://github.com/windmill-labs/windmill/commit/6c3c971af5aa1362632ee0deeddf91b8bc47c853))
|
||||
* support hub flows in raw app runnables ([#8627](https://github.com/windmill-labs/windmill/issues/8627)) ([040a199](https://github.com/windmill-labs/windmill/commit/040a199685cea5c99c944bacb5584a381d6ec829))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* return default_args/enums in approval info and fix subflow resume buttons ([#8648](https://github.com/windmill-labs/windmill/issues/8648)) ([852c59e](https://github.com/windmill-labs/windmill/commit/852c59efbb04510e5e6f99919707effcf6769a2f))
|
||||
|
||||
## [1.670.0](https://github.com/windmill-labs/windmill/compare/v1.669.1...v1.670.0) (2026-03-31)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add OR logic support to kafka/websocket trigger filters ([#8580](https://github.com/windmill-labs/windmill/issues/8580)) ([3876902](https://github.com/windmill-labs/windmill/commit/3876902a7be798fd5ef208bc5756b28fb55e569e))
|
||||
* expose getJob and getJobLogs as MCP tools ([#8632](https://github.com/windmill-labs/windmill/issues/8632)) ([cd8edcd](https://github.com/windmill-labs/windmill/commit/cd8edcd94f2bf44c3e771000cb0bbad08accc0e7))
|
||||
* support multiline secrets in resource password fields ([#8637](https://github.com/windmill-labs/windmill/issues/8637)) ([26050f9](https://github.com/windmill-labs/windmill/commit/26050f96c34f14826298760174a45f3559d3266c))
|
||||
* support sensitive/secret fields for non-string types ([#8635](https://github.com/windmill-labs/windmill/issues/8635)) ([375fb66](https://github.com/windmill-labs/windmill/commit/375fb66abe2d1861b53dc2b36d2cf0e2eb82c3a8))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* cap input history per_page to 100 on cloud ([#8624](https://github.com/windmill-labs/windmill/issues/8624)) ([8e973c8](https://github.com/windmill-labs/windmill/commit/8e973c892d768be2da2e6b4b7af9e40b62333052))
|
||||
* compute highest workspace role across all instance groups ([#8633](https://github.com/windmill-labs/windmill/issues/8633)) ([92b9ac7](https://github.com/windmill-labs/windmill/commit/92b9ac72c5fc9a5085fcb2e9d835ccbb53bcd4b0))
|
||||
* Ducklake UI Nits ([#8628](https://github.com/windmill-labs/windmill/issues/8628)) ([ef1757f](https://github.com/windmill-labs/windmill/commit/ef1757f5d747e513d201eb6fa48918dba8248abe))
|
||||
* preserve flow notes/groups and field ordering in generate-metadata ([#8641](https://github.com/windmill-labs/windmill/issues/8641)) ([#8642](https://github.com/windmill-labs/windmill/issues/8642)) ([52a04d2](https://github.com/windmill-labs/windmill/commit/52a04d210f476f4598007f67770bc6520b045950))
|
||||
* remove timeout on python client httpx to prevent ducklake query timeouts ([#8636](https://github.com/windmill-labs/windmill/issues/8636)) ([c5fccd2](https://github.com/windmill-labs/windmill/commit/c5fccd2f69ad8a6e46c514cf89b9aa21b380e6fe))
|
||||
* resolve missing form schema for nested suspend steps in FlowNode sub-flows ([#8643](https://github.com/windmill-labs/windmill/issues/8643)) ([12ea7e7](https://github.com/windmill-labs/windmill/commit/12ea7e74237560a9dfc99b6bc1338e3343b57640))
|
||||
* smarter secret masking based on secret length ([#8629](https://github.com/windmill-labs/windmill/issues/8629)) ([bfc2aef](https://github.com/windmill-labs/windmill/commit/bfc2aefdb8ab92b7284de7f9e485a5504502d944))
|
||||
|
||||
## [1.669.1](https://github.com/windmill-labs/windmill/compare/v1.669.0...v1.669.1) (2026-03-30)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* avoid doubled /oauth2 path in Okta custom authorization server URLs ([#8620](https://github.com/windmill-labs/windmill/issues/8620)) ([4817913](https://github.com/windmill-labs/windmill/commit/4817913f0cab49980bfeb442089631d7953955ff))
|
||||
* improve db health UI text and prevent label wrapping ([d532c1d](https://github.com/windmill-labs/windmill/commit/d532c1d470fcb0ef02ebc5342ad1cf22e58b1f4d))
|
||||
|
||||
## [1.669.0](https://github.com/windmill-labs/windmill/compare/v1.668.5...v1.669.0) (2026-03-30)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* WAC workflow diagram visualization via WASM ([#8604](https://github.com/windmill-labs/windmill/issues/8604)) ([abc6b12](https://github.com/windmill-labs/windmill/commit/abc6b12d6815edc4dda3ddf5f0572ecedcb670dd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add path traversal check in service_logs get_log_file endpoint ([#8605](https://github.com/windmill-labs/windmill/issues/8605)) ([5f2d3e6](https://github.com/windmill-labs/windmill/commit/5f2d3e6812f01fe6194bcfd976970a6e3c4186cc))
|
||||
* cast DuckDB IS_NULLABLE to string in metadata query ([#8607](https://github.com/windmill-labs/windmill/issues/8607)) ([f3012ee](https://github.com/windmill-labs/windmill/commit/f3012ee7ccc7a8947b5f6bd7c7df77984437f91e))
|
||||
* enable S3 bundle cache for PHP previews without lock file ([#8608](https://github.com/windmill-labs/windmill/issues/8608)) ([ee62315](https://github.com/windmill-labs/windmill/commit/ee6231590ed91063f104e6d054b52e88b569986f))
|
||||
* enforce workspace isolation on flow resume endpoint ([#8612](https://github.com/windmill-labs/windmill/issues/8612)) ([33032ed](https://github.com/windmill-labs/windmill/commit/33032ed297cf9ea867388d4ea2ece607c9d36dc7))
|
||||
* handle DuckDB boolean types in ColumnDef deserializers ([#8610](https://github.com/windmill-labs/windmill/issues/8610)) ([22da5bd](https://github.com/windmill-labs/windmill/commit/22da5bd9ea1ca000cfab3eecf1e3fb0fc01200cb))
|
||||
* use route_service instead of fallback_service for MCP router ([#8614](https://github.com/windmill-labs/windmill/issues/8614)) ([98934d5](https://github.com/windmill-labs/windmill/commit/98934d59c552325fcf88c016e31ae977970e8c9a))
|
||||
|
||||
## [1.668.5](https://github.com/windmill-labs/windmill/compare/v1.668.4...v1.668.5) (2026-03-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add per-IP and per-account brute force protection on login endpoint ([#8601](https://github.com/windmill-labs/windmill/issues/8601)) ([06bbe7b](https://github.com/windmill-labs/windmill/commit/06bbe7b94bfb846bd73aaf6abdc83e4c14e70adc))
|
||||
* add timestamp validation to webhook signature verification ([#8596](https://github.com/windmill-labs/windmill/issues/8596)) ([74fba2a](https://github.com/windmill-labs/windmill/commit/74fba2abf3dc68b682777c01da360258786fded8))
|
||||
* disable workspace webhook events when CLOUD_HOSTED ([#8598](https://github.com/windmill-labs/windmill/issues/8598)) ([be7fbeb](https://github.com/windmill-labs/windmill/commit/be7fbeb8b1f31d15e33b0783b2a504d6a01e532e))
|
||||
* harden login rate limiting with CLOUD_HOSTED gating and memory eviction ([#8602](https://github.com/windmill-labs/windmill/issues/8602)) ([754b88a](https://github.com/windmill-labs/windmill/commit/754b88a52c4e76421cb21c1eed87ad9d8385e9aa))
|
||||
* prevent SSRF and local file read via git repository resource URLs ([#8600](https://github.com/windmill-labs/windmill/issues/8600)) ([845db72](https://github.com/windmill-labs/windmill/commit/845db72b7344fb87ac9c5e24697750549665c7bf))
|
||||
* rename snippet param to avoid svelte compiler shadowing bug in asset usages drawer ([#8595](https://github.com/windmill-labs/windmill/issues/8595)) ([8c770a2](https://github.com/windmill-labs/windmill/commit/8c770a206a3b0704642c0bda2ab2aeb199d8af3f))
|
||||
* require mcp: scope for MCP endpoints instead of blanket bypass ([#8597](https://github.com/windmill-labs/windmill/issues/8597)) ([f5fc9f8](https://github.com/windmill-labs/windmill/commit/f5fc9f8485d2ec3e20f8b451305195446b90e5a3))
|
||||
* use constant-time comparison for API key and basic auth validation ([#8593](https://github.com/windmill-labs/windmill/issues/8593)) ([b4d1f2a](https://github.com/windmill-labs/windmill/commit/b4d1f2aac789306c2e35e123ac93e12c47c26f99))
|
||||
* validate JSON before sql_builder bind to prevent injection via JSONB queries ([#8599](https://github.com/windmill-labs/windmill/issues/8599)) ([970e859](https://github.com/windmill-labs/windmill/commit/970e859a410b0144847a1a30d7059955effdd402))
|
||||
|
||||
## [1.668.4](https://github.com/windmill-labs/windmill/compare/v1.668.3...v1.668.4) (2026-03-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update git sync version to latest cli ([0549f68](https://github.com/windmill-labs/windmill/commit/0549f682fe14f4d4b2f67941362ed2cc29d974a1))
|
||||
|
||||
## [1.668.3](https://github.com/windmill-labs/windmill/compare/v1.668.2...v1.668.3) (2026-03-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** phantom diffs, flow safety, trigger DX, lint watch, error clarity ([#8588](https://github.com/windmill-labs/windmill/issues/8588)) ([c6ce319](https://github.com/windmill-labs/windmill/commit/c6ce3197a72ceeffd702cf2263b1074ecbf1ca33))
|
||||
|
||||
## [1.668.2](https://github.com/windmill-labs/windmill/compare/v1.668.1...v1.668.2) (2026-03-28)
|
||||
|
||||
|
||||
|
||||
12
backend/.sqlx/query-077467cd813d5af161cb1cc232724f26984822d4c28ba36c0a9331273b10edc0.json
generated
Normal file
12
backend/.sqlx/query-077467cd813d5af161cb1cc232724f26984822d4c28ba36c0a9331273b10edc0.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO email_to_igroup (email, igroup) VALUES ('alice@example.com', 'admins') ON CONFLICT DO NOTHING",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "077467cd813d5af161cb1cc232724f26984822d4c28ba36c0a9331273b10edc0"
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) as cnt FROM pg_stat_activity WHERE state = 'active'",
|
||||
"query": "SELECT setting::bigint as \"max!\" FROM pg_settings WHERE name = 'max_connections'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "cnt",
|
||||
"name": "max!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
@@ -16,5 +16,5 @@
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "87d07998fe8373f5b89eccf6f0528c02e389bf827d935d867430ad3459104dd9"
|
||||
"hash": "07770a002a49428c4f956cfc7262d6b6792ae5b97ed90b0ee07d17480b2dffe2"
|
||||
}
|
||||
20
backend/.sqlx/query-1721f8b52ea265c0537fd7c742deddf0afbe5cf0d81b15e487c411ae169d3a89.json
generated
Normal file
20
backend/.sqlx/query-1721f8b52ea265c0537fd7c742deddf0afbe5cf0d81b15e487c411ae169d3a89.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT igroup FROM email_to_igroup WHERE email = 'alice@example.com'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "igroup",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1721f8b52ea265c0537fd7c742deddf0afbe5cf0d81b15e487c411ae169d3a89"
|
||||
}
|
||||
15
backend/.sqlx/query-250a4e3f1a1f95296f7075bf8780e9c7407e89c8f7636484895e99f5a5e71297.json
generated
Normal file
15
backend/.sqlx/query-250a4e3f1a1f95296f7075bf8780e9c7407e89c8f7636484895e99f5a5e71297.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, operator, added_via)\n VALUES ($1, 'alice', 'alice@example.com', false, true, $2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "250a4e3f1a1f95296f7075bf8780e9c7407e89c8f7636484895e99f5a5e71297"
|
||||
}
|
||||
15
backend/.sqlx/query-2ba03e555d2e09dbd0e2ae5ddfd9a268a675bdb23615c78904cebe7f1e31f400.json
generated
Normal file
15
backend/.sqlx/query-2ba03e555d2e09dbd0e2ae5ddfd9a268a675bdb23615c78904cebe7f1e31f400.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, operator, added_via)\n VALUES ($1, 'alice', 'alice@example.com', true, false, $2)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2ba03e555d2e09dbd0e2ae5ddfd9a268a675bdb23615c78904cebe7f1e31f400"
|
||||
}
|
||||
32
backend/.sqlx/query-2d95191e899d60385b32f36f2e38137e4173a34c54344ee522745640d48b8813.json
generated
Normal file
32
backend/.sqlx/query-2d95191e899d60385b32f36f2e38137e4173a34c54344ee522745640d48b8813.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n COUNT(*) as \"total!\",\n COUNT(*) FILTER (WHERE state = 'active') as \"active!\",\n COUNT(*) FILTER (WHERE state = 'idle') as \"idle!\"\n FROM pg_stat_activity\n WHERE backend_type = 'client backend'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "total!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "active!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "idle!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2d95191e899d60385b32f36f2e38137e4173a34c54344ee522745640d48b8813"
|
||||
}
|
||||
44
backend/.sqlx/query-30930bfb0513f1a70194a900011b2e890bc4146bb0419210cd76743cacda8bfa.json
generated
Normal file
44
backend/.sqlx/query-30930bfb0513f1a70194a900011b2e890bc4146bb0419210cd76743cacda8bfa.json
generated
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n table_name as \"table_name!\",\n SUM(live_tuples)::bigint as \"live_tuples!\",\n SUM(dead_tuples)::bigint as \"dead_tuples!\",\n MAX(last_autovacuum) as \"last_autovacuum\",\n MAX(last_autoanalyze) as \"last_autoanalyze\"\n FROM (\n SELECT\n CASE\n WHEN i.inhparent IS NOT NULL THEN schemaname || '.' || p.relname\n ELSE schemaname || '.' || s.relname\n END as table_name,\n COALESCE(n_live_tup, 0) as live_tuples,\n COALESCE(n_dead_tup, 0) as dead_tuples,\n last_autovacuum,\n last_autoanalyze\n FROM pg_stat_user_tables s\n LEFT JOIN pg_class c ON c.relname = s.relname AND c.relnamespace = (\n SELECT oid FROM pg_namespace WHERE nspname = s.schemaname\n )\n LEFT JOIN pg_inherits i ON i.inhrelid = c.oid\n LEFT JOIN pg_class p ON p.oid = i.inhparent\n ) sub\n GROUP BY table_name\n ORDER BY SUM(dead_tuples) DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "table_name!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "live_tuples!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "dead_tuples!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "last_autovacuum",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "last_autoanalyze",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "30930bfb0513f1a70194a900011b2e890bc4146bb0419210cd76743cacda8bfa"
|
||||
}
|
||||
38
backend/.sqlx/query-359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b.json
generated
Normal file
38
backend/.sqlx/query-359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b.json
generated
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH all_audit AS (SELECT username, operation, timestamp FROM audit_partitioned UNION ALL SELECT username, operation, timestamp FROM audit),\n active_users as (SELECT distinct username as email FROM all_audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "authors",
|
||||
"type_info": "VarcharArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operators",
|
||||
"type_info": "VarcharArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "author_count",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operator_count",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b"
|
||||
}
|
||||
34
backend/.sqlx/query-3bd4f38a1629a69ddda622b6b436198b47c2fe1a507358d49f05031e7beedab6.json
generated
Normal file
34
backend/.sqlx/query-3bd4f38a1629a69ddda622b6b436198b47c2fe1a507358d49f05031e7beedab6.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT workspace_id,\n auto_invite->'instance_groups_roles' as instance_groups_roles,\n auto_invite->'instance_groups' as instance_groups_json\n FROM workspace_settings\n WHERE auto_invite->'instance_groups' ? $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "instance_groups_roles",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "instance_groups_json",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "3bd4f38a1629a69ddda622b6b436198b47c2fe1a507358d49f05031e7beedab6"
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
22
backend/.sqlx/query-5d160ba4958583f1ad42de846c544d8d8e81e1b54925a0c5f2cedc1817d99a1b.json
generated
Normal file
22
backend/.sqlx/query-5d160ba4958583f1ad42de846c544d8d8e81e1b54925a0c5f2cedc1817d99a1b.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT igroup FROM email_to_igroup WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "igroup",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5d160ba4958583f1ad42de846c544d8d8e81e1b54925a0c5f2cedc1817d99a1b"
|
||||
}
|
||||
32
backend/.sqlx/query-62e8e443cf063fcb30799d9c8971c00d761d54811936deb87a0315ca9cdc9769.json
generated
Normal file
32
backend/.sqlx/query-62e8e443cf063fcb30799d9c8971c00d761d54811936deb87a0315ca9cdc9769.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT is_admin, operator, added_via FROM usr WHERE workspace_id = 'ws-multi-group' AND email = 'alice@example.com'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operator",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "added_via",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "62e8e443cf063fcb30799d9c8971c00d761d54811936deb87a0315ca9cdc9769"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n auto_invite->'instance_groups_roles' as instance_groups_roles\n FROM workspace_settings\n WHERE\n auto_invite->'instance_groups' IS NOT NULL\n AND auto_invite->'instance_groups' ? $1\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n auto_invite->'instance_groups_roles' as instance_groups_roles,\n auto_invite->'instance_groups' as instance_groups_json\n FROM workspace_settings\n WHERE\n auto_invite->'instance_groups' IS NOT NULL\n AND auto_invite->'instance_groups' ? $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -12,6 +12,11 @@
|
||||
"ordinal": 1,
|
||||
"name": "instance_groups_roles",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "instance_groups_json",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -21,8 +26,9 @@
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "7e01ef5799168c0fc2779d42ce352827e2fda6711c0a1b104ca6435ddb14b47d"
|
||||
"hash": "66e2f8468ba64f22b7a7caa18639d7c833ac2ec573bd89d878b5c8b1afc74d3a"
|
||||
}
|
||||
30
backend/.sqlx/query-68c19cb0e18b94870bbe81f9aab92ba37da67cd2a56834c9d1378eab7551284d.json
generated
Normal file
30
backend/.sqlx/query-68c19cb0e18b94870bbe81f9aab92ba37da67cd2a56834c9d1378eab7551284d.json
generated
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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 filter_logic = $5,\n auto_offset_reset = $6,\n auto_commit = $7,\n script_path = $8,\n path = $9,\n is_flow = $10,\n edited_by = $11,\n permissioned_as = $12,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $15,\n error_handler_args = $16,\n retry = $17\n WHERE\n workspace_id = $13 AND path = $14\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "68c19cb0e18b94870bbe81f9aab92ba37da67cd2a56834c9d1378eab7551284d"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO websocket_trigger (\n workspace_id,\n path,\n url,\n script_path,\n is_flow,\n mode,\n filters,\n initial_messages,\n url_runnable_args,\n edited_by,\n can_return_message,\n can_return_error_result,\n permissioned_as,\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 ",
|
||||
"query": "\n INSERT INTO websocket_trigger (\n workspace_id,\n path,\n url,\n script_path,\n is_flow,\n mode,\n filters,\n filter_logic,\n initial_messages,\n url_runnable_args,\n edited_by,\n can_return_message,\n can_return_error_result,\n permissioned_as,\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, $14, now(), $15, $16, $17\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -23,6 +23,7 @@
|
||||
}
|
||||
},
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"JsonbArray",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
@@ -36,5 +37,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "942c0abb55c910862fd45d3fa56a4eb6729f1a658101bda2d0b0fca96b3cfee5"
|
||||
"hash": "6948eb5aabf82f2f4a08dd4410eb472080ecab3ed652912397245e5216ae0389"
|
||||
}
|
||||
@@ -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 auto_commit,\n script_path,\n is_flow,\n mode,\n edited_by,\n permissioned_as,\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 ",
|
||||
"query": "\n INSERT INTO kafka_trigger (\n workspace_id,\n path,\n kafka_resource_path,\n group_id,\n topics,\n filters,\n filter_logic,\n auto_offset_reset,\n auto_commit,\n script_path,\n is_flow,\n mode,\n edited_by,\n permissioned_as,\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, $14, now(), $15, $16, $17\n )\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,6 +12,7 @@
|
||||
"VarcharArray",
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
@@ -36,5 +37,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a0a545fda5f3ebea0113d5daaf13358c964d9fb0f41bf2a1c834305b4d2398f2"
|
||||
"hash": "6a8f4ed9946bb2a3c5e90695c90b70aa2e83fcb5aa0c953febdd9bac2d95bbec"
|
||||
}
|
||||
12
backend/.sqlx/query-6f941e4454f736b32eaef80cdfb9582d6e75af3dc159e7c5f12497d3957f1eef.json
generated
Normal file
12
backend/.sqlx/query-6f941e4454f736b32eaef80cdfb9582d6e75af3dc159e7c5f12497d3957f1eef.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr (workspace_id, username, email, is_admin, operator)\n VALUES ('ws-multi-group', 'alice', 'alice@example.com', true, false)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6f941e4454f736b32eaef80cdfb9582d6e75af3dc159e7c5f12497d3957f1eef"
|
||||
}
|
||||
26
backend/.sqlx/query-88a467f3c943b134a81ac69c3c6686d1ce1ff2f5aafc15ff1b63cfa86c09c4f0.json
generated
Normal file
26
backend/.sqlx/query-88a467f3c943b134a81ac69c3c6686d1ce1ff2f5aafc15ff1b63cfa86c09c4f0.json
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT is_admin, operator FROM usr WHERE workspace_id = 'ws-multi-group' AND email = 'alice@example.com'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operator",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "88a467f3c943b134a81ac69c3c6686d1ce1ff2f5aafc15ff1b63cfa86c09c4f0"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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 auto_commit = $6,\n script_path = $7,\n path = $8,\n is_flow = $9,\n edited_by = $10,\n permissioned_as = $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": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"VarcharArray",
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a37cfc632dd37cf37c06743239b5ebc784e5da5ee25d47af187a75220d8fded7"
|
||||
}
|
||||
15
backend/.sqlx/query-a860dd9722f608184c4b1ef5e609b20cd61f9967a2012fc1c8fe352ee7596358.json
generated
Normal file
15
backend/.sqlx/query-a860dd9722f608184c4b1ef5e609b20cd61f9967a2012fc1c8fe352ee7596358.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO pip_resolution_cache (hash, lockfile, expiration) VALUES ($1, $2, now() + ('7 days')::interval) ON CONFLICT (hash) DO UPDATE SET lockfile = EXCLUDED.lockfile, expiration = EXCLUDED.expiration",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a860dd9722f608184c4b1ef5e609b20cd61f9967a2012fc1c8fe352ee7596358"
|
||||
}
|
||||
18
backend/.sqlx/query-b38bd869477a729279cac3ccd4825191fb49e17e5f7e7297c0c819f52b486f49.json
generated
Normal file
18
backend/.sqlx/query-b38bd869477a729279cac3ccd4825191fb49e17e5f7e7297c0c819f52b486f49.json
generated
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE usr SET is_admin = $1, operator = $2, added_via = $3 WHERE workspace_id = $4 AND email = $5 AND added_via->>'source' = 'instance_group'",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Jsonb",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b38bd869477a729279cac3ccd4825191fb49e17e5f7e7297c0c819f52b486f49"
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n schemaname || '.' || relname as \"table_name!\",\n COALESCE(n_live_tup, 0) as \"live_tuples!\",\n COALESCE(n_dead_tup, 0) as \"dead_tuples!\",\n last_autovacuum as \"last_autovacuum\",\n last_autoanalyze as \"last_autoanalyze\"\n FROM pg_stat_user_tables\n ORDER BY n_dead_tup DESC\n LIMIT 15",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "table_name!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "live_tuples!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "dead_tuples!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "last_autovacuum",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "last_autoanalyze",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bc54ea311580a0525c1f36aaa543c5798e6f7aca1e6e564330766d77038ef0e3"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n websocket_trigger\n SET\n url = $1,\n script_path = $2,\n path = $3,\n is_flow = $4,\n filters = $5,\n initial_messages = $6,\n url_runnable_args = $7,\n edited_by = $8,\n permissioned_as = $9,\n can_return_message = $10,\n can_return_error_result = $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": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"JsonbArray",
|
||||
"JsonbArray",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c7aed7fe3b6774477d403bc3e7fcbce7cdbdd1feb553718cbde60bb8ccff4733"
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH active_users as (SELECT distinct username as email FROM audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "authors",
|
||||
"type_info": "VarcharArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operators",
|
||||
"type_info": "VarcharArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "author_count",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "operator_count",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "cb3862634f18160207ee2621ddfca43f00456a27fda32583846497116f92f96c"
|
||||
}
|
||||
20
backend/.sqlx/query-d43a4ff78e48580815fb912c98639a08d45596a9f11a2dcf5b1e0d135844ecda.json
generated
Normal file
20
backend/.sqlx/query-d43a4ff78e48580815fb912c98639a08d45596a9f11a2dcf5b1e0d135844ecda.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM global_settings WHERE name = 'plain_emails_telemetry'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d43a4ff78e48580815fb912c98639a08d45596a9f11a2dcf5b1e0d135844ecda"
|
||||
}
|
||||
30
backend/.sqlx/query-e3d4f89ce36337af15d237b543eaca47771b480ff194884f9c947dcaf71d6cf9.json
generated
Normal file
30
backend/.sqlx/query-e3d4f89ce36337af15d237b543eaca47771b480ff194884f9c947dcaf71d6cf9.json
generated
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n websocket_trigger\n SET\n url = $1,\n script_path = $2,\n path = $3,\n is_flow = $4,\n filters = $5,\n filter_logic = $6,\n initial_messages = $7,\n url_runnable_args = $8,\n edited_by = $9,\n permissioned_as = $10,\n can_return_message = $11,\n can_return_error_result = $12,\n edited_at = now(),\n server_id = NULL,\n error = NULL,\n error_handler_path = $15,\n error_handler_args = $16,\n retry = $17\n WHERE\n workspace_id = $13 AND path = $14\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"JsonbArray",
|
||||
"Varchar",
|
||||
"JsonbArray",
|
||||
"Jsonb",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool",
|
||||
"Text",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e3d4f89ce36337af15d237b543eaca47771b480ff194884f9c947dcaf71d6cf9"
|
||||
}
|
||||
26
backend/.sqlx/query-e58ef252b0d2b81e9cd76f394a396abefd791906ada29dd5a7a9148157635ca5.json
generated
Normal file
26
backend/.sqlx/query-e58ef252b0d2b81e9cd76f394a396abefd791906ada29dd5a7a9148157635ca5.json
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT auto_invite->'instance_groups_roles' as instance_groups_roles,\n auto_invite->'instance_groups' as instance_groups_json\n FROM workspace_settings WHERE workspace_id = 'ws-multi-group'\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "instance_groups_roles",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "instance_groups_json",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e58ef252b0d2b81e9cd76f394a396abefd791906ada29dd5a7a9148157635ca5"
|
||||
}
|
||||
521
backend/Cargo.lock
generated
521
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.668.2"
|
||||
version = "1.672.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -66,10 +66,13 @@ members = [
|
||||
"./parsers/windmill-parser-nu",
|
||||
"./parsers/windmill-parser-java",
|
||||
"./parsers/windmill-parser-ruby",
|
||||
"./parsers/windmill-parser-r",
|
||||
"./parsers/windmill-parser-bash",
|
||||
"./parsers/windmill-parser-py",
|
||||
"./parsers/windmill-parser-py-asset",
|
||||
"./parsers/windmill-parser-py-imports",
|
||||
# Uncomment to build wasm parsers:
|
||||
# "./parsers/windmill-parser-wasm",
|
||||
"./parsers/windmill-parser-wac",
|
||||
"./parsers/windmill-parser-sql",
|
||||
"./parsers/windmill-parser-sql-asset",
|
||||
@@ -79,10 +82,10 @@ members = [
|
||||
"./windmill-test-utils",
|
||||
"./windmill-api-integration-tests",
|
||||
]
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.668.2"
|
||||
version = "1.672.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -163,7 +166,8 @@ csharp = ["windmill-worker/csharp"]
|
||||
nu = ["windmill-worker/nu"]
|
||||
java = ["windmill-worker/java"]
|
||||
ruby = ["windmill-worker/ruby"]
|
||||
all_languages = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql-kerberos", "bigquery", "csharp", "nu", "php", "java", "ruby"]
|
||||
rlang = ["windmill-worker/rlang"]
|
||||
all_languages = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql-kerberos", "bigquery", "csharp", "nu", "php", "java", "ruby", "rlang"]
|
||||
# For windows we have another set of languages enabled
|
||||
all_languages_windows = ["python", "deno_core", "rust", "mysql", "oracledb", "duckdb", "mssql-winauth", "bigquery", "csharp", "nu", "php", "java"]
|
||||
# Edition meta-features: shared groups
|
||||
@@ -347,6 +351,7 @@ windmill-parser-yaml = { path = "./parsers/windmill-parser-yaml" }
|
||||
windmill-parser-csharp = { path = "./parsers/windmill-parser-csharp" }
|
||||
windmill-parser-java = { path = "./parsers/windmill-parser-java" }
|
||||
windmill-parser-ruby = { path = "./parsers/windmill-parser-ruby" }
|
||||
windmill-parser-r = { path = "./parsers/windmill-parser-r" }
|
||||
windmill-parser-nu = { path = "./parsers/windmill-parser-nu" }
|
||||
windmill-parser-bash = { path = "./parsers/windmill-parser-bash" }
|
||||
windmill-parser-sql = { path = "./parsers/windmill-parser-sql" }
|
||||
@@ -613,6 +618,7 @@ tree-sitter = { version = "0.23.0", features = [] }
|
||||
tree-sitter-c-sharp = "0.23.0"
|
||||
tree-sitter-java = "0.23.0"
|
||||
tree-sitter-ruby = "0.23.0"
|
||||
tree-sitter-r = "1.2.0"
|
||||
oracle = { version = "0.6.3", features = ["chrono"] }
|
||||
rumqttc = { version = "0.24.0", features = ["use-native-tls"]}
|
||||
strum = { version = "0.27", features = ["derive"] }
|
||||
|
||||
@@ -1 +1 @@
|
||||
02c0d34e54e71c9293f9cefb56f68652cf0db8a5
|
||||
e08a87450627bef9013498e40ee93a47bedda7ee
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op: this migration is a data fixup and cannot be reversed.
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Pre-fix: before permissioned_as migration drops the email column, update edited_by
|
||||
-- for triggers where the user (edited_by) is not in the workspace but is a superadmin.
|
||||
-- This ensures the subsequent 20260318000000 migration stores the raw email as permissioned_as
|
||||
-- (via the `edited_by LIKE '%@%'` branch).
|
||||
-- For instances that already applied 20260318000000, this is a no-op (email column is gone);
|
||||
-- the 20260401000000 migration handles those as a fallback.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
trigger_table TEXT;
|
||||
has_email BOOLEAN;
|
||||
BEGIN
|
||||
FOREACH trigger_table IN ARRAY ARRAY[
|
||||
'http_trigger',
|
||||
'websocket_trigger',
|
||||
'postgres_trigger',
|
||||
'mqtt_trigger',
|
||||
'kafka_trigger',
|
||||
'nats_trigger',
|
||||
'sqs_trigger',
|
||||
'gcp_trigger',
|
||||
'email_trigger'
|
||||
]
|
||||
LOOP
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = trigger_table AND column_name = 'email'
|
||||
) INTO has_email;
|
||||
|
||||
IF has_email THEN
|
||||
EXECUTE format($q$
|
||||
UPDATE %I t
|
||||
SET edited_by = t.email
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM usr u
|
||||
WHERE u.username = t.edited_by
|
||||
AND u.workspace_id = t.workspace_id
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM password p
|
||||
WHERE p.email = t.email
|
||||
AND p.super_admin = true
|
||||
)
|
||||
$q$, trigger_table);
|
||||
END IF;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE kafka_trigger DROP COLUMN filter_logic;
|
||||
ALTER TABLE websocket_trigger DROP COLUMN filter_logic;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE kafka_trigger ADD COLUMN filter_logic VARCHAR(3) NOT NULL DEFAULT 'and';
|
||||
ALTER TABLE websocket_trigger ADD COLUMN filter_logic VARCHAR(3) NOT NULL DEFAULT 'and';
|
||||
2
backend/migrations/20260331000000_add_rlang.up.sql
Normal file
2
backend/migrations/20260331000000_add_rlang.up.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE SCRIPT_LANG ADD VALUE IF NOT EXISTS 'rlang';
|
||||
UPDATE config SET config = jsonb_set(config, '{worker_tags}', config->'worker_tags' || '["rlang"]'::jsonb) WHERE name = 'worker__default' AND config @> '{"worker_tags": ["deno", "python3", "go", "bash", "powershell", "dependency", "flow", "hub", "other", "bun", "php", "rust", "ansible", "csharp", "nu", "java", "duckdb", "ruby"]}'::jsonb AND NOT config->'worker_tags' @> '"rlang"'::jsonb;
|
||||
17
backend/parsers/windmill-parser-r/Cargo.toml
Normal file
17
backend/parsers/windmill-parser-r/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "windmill-parser-r"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "windmill_parser_r"
|
||||
path = "./src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
windmill-parser.workspace = true
|
||||
tree-sitter.workspace = true
|
||||
tree-sitter-r.workspace = true
|
||||
anyhow.workspace = true
|
||||
wasm-bindgen.workspace = true
|
||||
serde_json.workspace = true
|
||||
363
backend/parsers/windmill-parser-r/src/lib.rs
Normal file
363
backend/parsers/windmill-parser-r/src/lib.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
#![cfg_attr(target_arch = "wasm32", feature(c_variadic))]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod wasm_libc;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use serde_json::Value;
|
||||
use tree_sitter::Node;
|
||||
use tree_sitter::Range;
|
||||
use windmill_parser::json_to_typ;
|
||||
use windmill_parser::Arg;
|
||||
use windmill_parser::MainArgSignature;
|
||||
|
||||
pub fn parse_r_sig_meta(code: &str) -> anyhow::Result<MainArgSignature> {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let language = tree_sitter_r::LANGUAGE;
|
||||
parser
|
||||
.set_language(&language.into())
|
||||
.map_err(|e| anyhow!("Error setting R as language: {e}"))?;
|
||||
|
||||
let tree = parser
|
||||
.parse(code, None)
|
||||
.ok_or(anyhow!("Failed to parse code"))?;
|
||||
let root_node = tree.root_node();
|
||||
|
||||
let args = find_main_signature(root_node, code)?;
|
||||
let main_sig = MainArgSignature {
|
||||
star_args: false,
|
||||
star_kwargs: false,
|
||||
args: args.unwrap_or_default(),
|
||||
has_preprocessor: None,
|
||||
auto_kind: None,
|
||||
};
|
||||
|
||||
Ok(main_sig)
|
||||
}
|
||||
|
||||
pub fn parse_r_signature(code: &str) -> anyhow::Result<MainArgSignature> {
|
||||
Ok(parse_r_sig_meta(code)?)
|
||||
}
|
||||
|
||||
/// Extract package names from `library(...)` and `require(...)` calls in R code.
|
||||
/// Returns a newline-separated list of package names.
|
||||
pub fn parse_r_requirements(code: &str) -> anyhow::Result<String> {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let language = tree_sitter_r::LANGUAGE;
|
||||
parser
|
||||
.set_language(&language.into())
|
||||
.map_err(|e| anyhow!("Error setting R as language: {e}"))?;
|
||||
|
||||
let tree = parser
|
||||
.parse(code, None)
|
||||
.ok_or(anyhow!("Failed to parse code"))?;
|
||||
let root_node = tree.root_node();
|
||||
|
||||
let mut packages = vec![];
|
||||
find_library_calls(root_node, code, &mut packages);
|
||||
|
||||
// Deduplicate and exclude base packages
|
||||
packages.sort();
|
||||
packages.dedup();
|
||||
packages.retain(|p| !is_base_package(p));
|
||||
|
||||
Ok(packages.join("\n"))
|
||||
}
|
||||
|
||||
fn find_library_calls(node: Node, code: &str, packages: &mut Vec<String>) {
|
||||
let mut cursor = node.walk();
|
||||
for child in node.children(&mut cursor) {
|
||||
if child.kind() == "call" {
|
||||
// call node: child 0 is the function name, child 1 is arguments
|
||||
if let (Some(func_node), Some(args_node)) = (child.child(0), child.child(1)) {
|
||||
let func_name = func_node.utf8_text(code.as_bytes()).unwrap_or("");
|
||||
if func_name == "library" || func_name == "require" {
|
||||
// AST: arguments → ( + argument → identifier/string + )
|
||||
if args_node.kind() == "arguments" {
|
||||
let mut args_cursor = args_node.walk();
|
||||
for arg in args_node.children(&mut args_cursor) {
|
||||
if arg.kind() == "argument" {
|
||||
// The argument node wraps the actual value
|
||||
if let Some(value_node) = arg.child(0) {
|
||||
let pkg = value_node
|
||||
.utf8_text(code.as_bytes())
|
||||
.unwrap_or("")
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'');
|
||||
if !pkg.is_empty() {
|
||||
packages.push(pkg.to_string());
|
||||
}
|
||||
}
|
||||
break; // only first arg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Recurse into children to find nested library() calls
|
||||
find_library_calls(child, code, packages);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_base_package(pkg: &str) -> bool {
|
||||
matches!(
|
||||
pkg,
|
||||
"base"
|
||||
| "compiler"
|
||||
| "datasets"
|
||||
| "grDevices"
|
||||
| "graphics"
|
||||
| "grid"
|
||||
| "methods"
|
||||
| "parallel"
|
||||
| "splines"
|
||||
| "stats"
|
||||
| "stats4"
|
||||
| "tcltk"
|
||||
| "tools"
|
||||
| "utils"
|
||||
)
|
||||
}
|
||||
|
||||
/// Find the main function signature in R code.
|
||||
/// R function definitions look like: `main <- function(x, y = 10) { ... }`
|
||||
/// In the tree-sitter-r AST, this is a `binary_operator` node with:
|
||||
/// - child 0: identifier "main"
|
||||
/// - child 1: "<-" or "="
|
||||
/// - child 2: function_definition node
|
||||
fn find_main_signature<'a>(root_node: Node<'a>, code: &str) -> anyhow::Result<Option<Vec<Arg>>> {
|
||||
let mut cursor = root_node.walk();
|
||||
for x in root_node.children(&mut cursor) {
|
||||
if x.kind() == "binary_operator" {
|
||||
let child_count = x.child_count();
|
||||
if child_count < 3 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// First child should be identifier "main"
|
||||
let ident_node = x.child(0).unwrap();
|
||||
if ident_node.kind() != "identifier" {
|
||||
continue;
|
||||
}
|
||||
let ident = ident_node.utf8_text(code.as_bytes()).unwrap_or("");
|
||||
if ident != "main" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Second child should be "<-" or "="
|
||||
let op_node = x.child(1).unwrap();
|
||||
let op = op_node.utf8_text(code.as_bytes()).unwrap_or("");
|
||||
if op != "<-" && op != "=" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Third child should be the function_definition
|
||||
let func_node = x.child(2).unwrap();
|
||||
if func_node.kind() != "function_definition" {
|
||||
continue;
|
||||
}
|
||||
|
||||
return Ok(Some(parse_function_params(func_node, code)?));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Parse parameters from a function_definition node.
|
||||
/// function_definition has children: "function", parameters, body
|
||||
/// Each parameter node has:
|
||||
/// - 1 child (identifier) for positional args
|
||||
/// - 3 children (identifier, "=", value) for default args
|
||||
fn parse_function_params(func_node: Node, code: &str) -> anyhow::Result<Vec<Arg>> {
|
||||
let mut args = vec![];
|
||||
let mut func_cursor = func_node.walk();
|
||||
|
||||
for child in func_node.children(&mut func_cursor) {
|
||||
if child.kind() == "parameters" {
|
||||
let mut param_cursor = child.walk();
|
||||
for param in child.children(&mut param_cursor) {
|
||||
if param.kind() != "parameter" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let param_child_count = param.child_count();
|
||||
if param_child_count == 1 {
|
||||
// Simple parameter: just identifier
|
||||
let ident_node = param.child(0).unwrap();
|
||||
let name = ident_node.utf8_text(code.as_bytes())?;
|
||||
args.push(Arg { name: name.to_owned(), ..Default::default() });
|
||||
} else if param_child_count >= 3 {
|
||||
// Default parameter: identifier = value
|
||||
let ident_node = param.child(0).unwrap();
|
||||
let value_node = param.child(2).unwrap();
|
||||
let name = ident_node.utf8_text(code.as_bytes())?;
|
||||
|
||||
let Range { start_byte, end_byte, .. } = value_node.range();
|
||||
let raw = &code[start_byte..end_byte];
|
||||
// Convert R literals to JSON
|
||||
let unparsed = raw
|
||||
.replace("NULL", "null")
|
||||
.replace("TRUE", "true")
|
||||
.replace("FALSE", "false");
|
||||
match serde_json::from_str::<Value>(&unparsed) {
|
||||
Ok(default) => {
|
||||
args.push(Arg {
|
||||
name: name.to_owned(),
|
||||
typ: json_to_typ(&default, true),
|
||||
default: Some(default),
|
||||
has_default: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
args.push(Arg {
|
||||
name: name.to_owned(),
|
||||
has_default: true,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use serde_json::json;
|
||||
use windmill_parser::Typ;
|
||||
|
||||
use super::parse_r_sig_meta as parse;
|
||||
|
||||
#[test]
|
||||
fn test_parse_r_no_main() {
|
||||
let code = r#"
|
||||
not_main <- function() {}
|
||||
helper <- function(x) { x + 1 }
|
||||
"#;
|
||||
let sig = parse(code).unwrap();
|
||||
assert_eq!(
|
||||
sig,
|
||||
windmill_parser::MainArgSignature { auto_kind: None, ..Default::default() }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_r_no_args() {
|
||||
let code = r#"
|
||||
main <- function() {
|
||||
return(42)
|
||||
}
|
||||
"#;
|
||||
let sig = parse(code).unwrap();
|
||||
assert_eq!(
|
||||
sig,
|
||||
windmill_parser::MainArgSignature { auto_kind: None, ..Default::default() }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_r_positional_args() {
|
||||
let code = r#"main <- function(a, b, c) { a + b + c }"#;
|
||||
let sig = parse(code).unwrap();
|
||||
assert_eq!(
|
||||
sig,
|
||||
windmill_parser::MainArgSignature {
|
||||
args: vec![
|
||||
windmill_parser::Arg { name: "a".into(), ..Default::default() },
|
||||
windmill_parser::Arg { name: "b".into(), ..Default::default() },
|
||||
windmill_parser::Arg { name: "c".into(), ..Default::default() },
|
||||
],
|
||||
auto_kind: None,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_r_default_args() {
|
||||
let code = r#"main <- function(a = 10, b = "hey", c = FALSE) { }"#;
|
||||
let sig = parse(code).unwrap();
|
||||
assert_eq!(sig.args.len(), 3);
|
||||
assert_eq!(sig.args[0].name, "a");
|
||||
assert_eq!(sig.args[0].default, Some(json!(10)));
|
||||
assert_eq!(sig.args[0].typ, Typ::Int);
|
||||
assert_eq!(sig.args[1].name, "b");
|
||||
assert_eq!(sig.args[1].default, Some(json!("hey")));
|
||||
assert_eq!(sig.args[1].typ, Typ::Str(None));
|
||||
assert_eq!(sig.args[2].name, "c");
|
||||
assert_eq!(sig.args[2].default, Some(json!(false)));
|
||||
assert_eq!(sig.args[2].typ, Typ::Bool);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_r_equals_assignment() {
|
||||
let code = r#"main = function(x, y = 5) { x + y }"#;
|
||||
let sig = parse(code).unwrap();
|
||||
assert_eq!(sig.args.len(), 2);
|
||||
assert_eq!(sig.args[0].name, "x");
|
||||
assert_eq!(sig.args[1].name, "y");
|
||||
assert_eq!(sig.args[1].default, Some(json!(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_r_null_default() {
|
||||
let code = r#"main <- function(x = NULL) { x }"#;
|
||||
let sig = parse(code).unwrap();
|
||||
assert_eq!(sig.args.len(), 1);
|
||||
assert_eq!(sig.args[0].name, "x");
|
||||
assert_eq!(sig.args[0].default, Some(json!(null)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_r_requirements() {
|
||||
use super::parse_r_requirements;
|
||||
|
||||
let code = r#"
|
||||
library(dplyr)
|
||||
library(ggplot2)
|
||||
require(tidyr)
|
||||
library(stats)
|
||||
|
||||
main <- function(x) {
|
||||
library(stringr)
|
||||
x
|
||||
}
|
||||
"#;
|
||||
let reqs = parse_r_requirements(code).unwrap();
|
||||
let pkgs: Vec<&str> = reqs.lines().collect();
|
||||
assert!(pkgs.contains(&"dplyr"));
|
||||
assert!(pkgs.contains(&"ggplot2"));
|
||||
assert!(pkgs.contains(&"tidyr"));
|
||||
assert!(pkgs.contains(&"stringr"));
|
||||
assert!(!pkgs.contains(&"stats")); // base package excluded
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_r_requirements_string_args() {
|
||||
use super::parse_r_requirements;
|
||||
|
||||
let code = r#"
|
||||
library("data.table")
|
||||
require("jsonlite")
|
||||
|
||||
main <- function() { }
|
||||
"#;
|
||||
let reqs = parse_r_requirements(code).unwrap();
|
||||
let pkgs: Vec<&str> = reqs.lines().collect();
|
||||
assert!(pkgs.contains(&"data.table"));
|
||||
assert!(pkgs.contains(&"jsonlite"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_r_requirements_no_deps() {
|
||||
use super::parse_r_requirements;
|
||||
|
||||
let code = r#"main <- function(x) { x + 1 }"#;
|
||||
let reqs = parse_r_requirements(code).unwrap();
|
||||
assert!(reqs.is_empty());
|
||||
}
|
||||
}
|
||||
293
backend/parsers/windmill-parser-r/src/wasm_libc.rs
Normal file
293
backend/parsers/windmill-parser-r/src/wasm_libc.rs
Normal file
@@ -0,0 +1,293 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::{
|
||||
alloc::{self, Layout},
|
||||
ffi::{c_char, c_int, c_void},
|
||||
mem::align_of,
|
||||
ptr,
|
||||
};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/* -------------------------------- stdlib.h -------------------------------- */
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn abort() {
|
||||
panic!("Aborted from C");
|
||||
}
|
||||
|
||||
macro_rules! console_log {
|
||||
($($t:tt)*) => (unsafe { log(&format_args!($($t)*).to_string()) })
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
extern "C" {
|
||||
#[wasm_bindgen(js_namespace = console)]
|
||||
fn log(a: &str);
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn malloc(size: usize) -> *mut c_void {
|
||||
if size == 0 {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let (layout, offset_to_data) = layout_for_size_prepended(size);
|
||||
let buf = alloc::alloc(layout);
|
||||
store_layout(buf, layout, offset_to_data)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn calloc(count: usize, size: usize) -> *mut c_void {
|
||||
if count == 0 || size == 0 {
|
||||
return ptr::null_mut();
|
||||
}
|
||||
|
||||
let (layout, offset_to_data) = layout_for_size_prepended(size * count);
|
||||
let buf = alloc::alloc_zeroed(layout);
|
||||
store_layout(buf, layout, offset_to_data)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn realloc(buf: *mut c_void, new_size: usize) -> *mut c_void {
|
||||
if buf.is_null() {
|
||||
malloc(new_size)
|
||||
} else if new_size == 0 {
|
||||
free(buf);
|
||||
ptr::null_mut()
|
||||
} else {
|
||||
let (old_buf, old_layout) = retrieve_layout(buf);
|
||||
let (new_layout, offset_to_data) = layout_for_size_prepended(new_size);
|
||||
let new_buf = alloc::realloc(old_buf, old_layout, new_layout.size());
|
||||
store_layout(new_buf, new_layout, offset_to_data)
|
||||
}
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn free(buf: *mut c_void) {
|
||||
if buf.is_null() {
|
||||
return;
|
||||
}
|
||||
let (buf, layout) = retrieve_layout(buf);
|
||||
alloc::dealloc(buf, layout);
|
||||
}
|
||||
|
||||
// In all these allocations, we store the layout before the data for later retrieval.
|
||||
// This is because we need to know the layout when deallocating the memory.
|
||||
// Here are some helper methods for that:
|
||||
|
||||
/// Given a pointer to the data, retrieve the layout and the pointer to the layout.
|
||||
unsafe fn retrieve_layout(buf: *mut c_void) -> (*mut u8, Layout) {
|
||||
let (_, layout_offset) = Layout::new::<Layout>()
|
||||
.extend(Layout::from_size_align(0, align_of::<*const u8>() * 2).unwrap())
|
||||
.unwrap();
|
||||
|
||||
let buf = (buf as *mut u8).offset(-(layout_offset as isize));
|
||||
let layout = *(buf as *mut Layout);
|
||||
|
||||
(buf, layout)
|
||||
}
|
||||
|
||||
/// Calculate a layout for a given size with space for storing a layout at the start.
|
||||
/// Returns the layout and the offset to the data.
|
||||
fn layout_for_size_prepended(size: usize) -> (Layout, usize) {
|
||||
Layout::new::<Layout>()
|
||||
.extend(Layout::from_size_align(size, align_of::<*const u8>() * 2).unwrap())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Store a layout in the pointer, returning a pointer to where the data should be stored.
|
||||
unsafe fn store_layout(buf: *mut u8, layout: Layout, offset_to_data: usize) -> *mut c_void {
|
||||
*(buf as *mut Layout) = layout;
|
||||
(buf as *mut u8).offset(offset_to_data as isize) as *mut c_void
|
||||
}
|
||||
|
||||
/* -------------------------------- string.h -------------------------------- */
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn strncmp(ptr1: *const c_void, ptr2: *const c_void, n: usize) -> c_int {
|
||||
let s1 = std::slice::from_raw_parts(ptr1 as *const u8, n);
|
||||
let s2 = std::slice::from_raw_parts(ptr2 as *const u8, n);
|
||||
|
||||
for (a, b) in s1.iter().zip(s2.iter()) {
|
||||
if *a != *b || *a == 0 {
|
||||
return (*a as i32) - (*b as i32);
|
||||
}
|
||||
}
|
||||
|
||||
0
|
||||
}
|
||||
|
||||
// Implementation by AI:
|
||||
pub type size_t = usize;
|
||||
use std::slice;
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn memchr(haystack: *const c_void, needle: c_int, len: usize) -> *mut c_void {
|
||||
if haystack.is_null() || len == 0 {
|
||||
return ptr::null_mut(); // Return null if the input pointer is null or length is zero
|
||||
}
|
||||
|
||||
let needle_byte = needle as u8; // Convert needle to a byte
|
||||
|
||||
// Create a pointer to the start of the haystack
|
||||
let mut current = haystack as *const u8;
|
||||
|
||||
// Iterate through the memory block
|
||||
for _ in 0..len {
|
||||
if *current == needle_byte {
|
||||
return current as *mut c_void; // Return the pointer to the found byte
|
||||
}
|
||||
current = current.add(1); // Move to the next byte
|
||||
}
|
||||
|
||||
ptr::null_mut() // Return null if the byte was not found
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn strchr(mut s: *const c_char, c: c_int) -> *mut c_char {
|
||||
if s.is_null() {
|
||||
return std::ptr::null_mut(); // Return null if the input string is null
|
||||
}
|
||||
|
||||
let target = c as u8 as char; // Convert c to a char
|
||||
let mut current = s;
|
||||
|
||||
// Iterate through the string until we find the character or reach the end
|
||||
while *current != 0 {
|
||||
if *current as u8 as char == target {
|
||||
return current as *mut c_char; // Return the pointer to the found character
|
||||
}
|
||||
current = current.add(1); // Move to the next character
|
||||
}
|
||||
|
||||
std::ptr::null_mut() // Return null if the character was not found
|
||||
}
|
||||
// End of AI implemetation
|
||||
/* -------------------------------- wctype.h -------------------------------- */
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn iswspace(c: c_int) -> bool {
|
||||
char::from_u32(c as u32).map_or(false, |c| c.is_whitespace())
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn iswalnum(c: c_int) -> bool {
|
||||
char::from_u32(c as u32).map_or(false, |c| c.is_alphanumeric())
|
||||
}
|
||||
|
||||
// Implementation by AI:
|
||||
pub type wint_t = u32;
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn iswdigit(wc: wint_t) -> c_int {
|
||||
// Check if the character is a digit ('0' to '9')
|
||||
if wc >= '0' as wint_t && wc <= '9' as wint_t {
|
||||
return 1; // Return true (1)
|
||||
}
|
||||
0 // Return false (0)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn iswupper(wc: wint_t) -> c_int {
|
||||
// Check if the character is an uppercase letter ('A' to 'Z')
|
||||
if wc >= 'A' as wint_t && wc <= 'Z' as wint_t {
|
||||
return 1; // Return true (1)
|
||||
}
|
||||
0 // Return false (0)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn iswalpha(wc: wint_t) -> c_int {
|
||||
// Check if the character is an alphabetic character ('A' to 'Z' or 'a' to 'z')
|
||||
if (wc >= 'A' as wint_t && wc <= 'Z' as wint_t) || (wc >= 'a' as wint_t && wc <= 'z' as wint_t)
|
||||
{
|
||||
return 1; // Return true (1)
|
||||
}
|
||||
0 // Return false (0)
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn iswlower(wc: wint_t) -> c_int {
|
||||
// Check if the character is a lowercase letter ('a' to 'z')
|
||||
if wc >= 'a' as wint_t && wc <= 'z' as wint_t {
|
||||
return 1; // Return true (1)
|
||||
}
|
||||
0 // Return false (0)
|
||||
}
|
||||
// End of AI implemetation
|
||||
|
||||
/* --------------------------------- time.h --------------------------------- */
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn clock() -> u64 {
|
||||
panic!("clock is not supported");
|
||||
}
|
||||
|
||||
/* --------------------------------- ctype.h -------------------------------- */
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn isprint(c: c_int) -> bool {
|
||||
c >= 32 && c <= 126
|
||||
}
|
||||
|
||||
/* --------------------------------- stdio.h -------------------------------- */
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn fprintf(_file: *mut c_void, _format: *const c_void, _args: ...) -> c_int {
|
||||
panic!("fprintf is not supported");
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn fputs(_s: *const c_void, _file: *mut c_void) -> c_int {
|
||||
panic!("fputs is not supported");
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn fputc(_c: c_int, _file: *mut c_void) -> c_int {
|
||||
panic!("fputc is not supported");
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn fdopen(_fd: c_int, _mode: *const c_void) -> *mut c_void {
|
||||
panic!("fdopen is not supported");
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn fclose(_file: *mut c_void) -> c_int {
|
||||
panic!("fclose is not supported");
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn fwrite(
|
||||
_ptr: *const c_void,
|
||||
_size: usize,
|
||||
_nmemb: usize,
|
||||
_stream: *mut c_void,
|
||||
) -> usize {
|
||||
panic!("fwrite is not supported");
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn vsnprintf(
|
||||
_buf: *mut c_char,
|
||||
_size: usize,
|
||||
_format: *const c_char,
|
||||
_args: ...
|
||||
) -> c_int {
|
||||
panic!("vsnprintf is not supported");
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn clock_gettime(ptr: usize, new_size: usize) {
|
||||
panic!("clock_gettime is not supported");
|
||||
}
|
||||
|
||||
// int snprintf( char* restrict buffer, size_t bufsz, const char* restrict format, ... );
|
||||
#[no_mangle]
|
||||
pub extern "C" fn snprintf() {
|
||||
panic!("snprintf is not supported");
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub extern "C" fn __assert_fail(_: *const i32, _: *const i32, _: *const i32, _: *const i32) {
|
||||
panic!("oh no");
|
||||
}
|
||||
@@ -27,11 +27,15 @@ pub struct DagNode {
|
||||
#[serde(tag = "type")]
|
||||
pub enum DagNodeType {
|
||||
Step { name: String, script: String },
|
||||
InlineStep { name: String },
|
||||
Sleep { seconds: String },
|
||||
WaitForApproval,
|
||||
Branch { condition_source: String },
|
||||
ParallelStart,
|
||||
ParallelEnd,
|
||||
LoopStart { iter_source: String },
|
||||
LoopEnd,
|
||||
Merge,
|
||||
Return,
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ impl LineIndex {
|
||||
/// Maps task function name → optional external path (from `@task(path="...")`)
|
||||
type TaskFunctions = HashMap<String, Option<String>>;
|
||||
|
||||
/// First pass: scan top-level `@task async def foo(...)` declarations.
|
||||
/// First pass: scan top-level `@task async def foo(...)` declarations
|
||||
/// and `foo = task_script("path")` / `foo = task_flow("path")` assignments.
|
||||
fn collect_task_functions(stmts: &[Stmt]) -> TaskFunctions {
|
||||
let mut tasks = HashMap::new();
|
||||
for stmt in stmts {
|
||||
@@ -61,6 +62,30 @@ fn collect_task_functions(stmts: &[Stmt]) -> TaskFunctions {
|
||||
}
|
||||
}
|
||||
}
|
||||
// foo = task_script("path") or foo = task_flow("path")
|
||||
if let Stmt::Assign(assign) = stmt {
|
||||
if let Expr::Call(call) = assign.value.as_ref() {
|
||||
if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() {
|
||||
if id.as_str() == "task_script" || id.as_str() == "task_flow" {
|
||||
// Extract the path from the first positional argument
|
||||
let path = call.args.first().and_then(|arg| {
|
||||
if let Expr::Constant(c) = arg {
|
||||
if let rustpython_parser::ast::Constant::Str(s) = &c.value {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
});
|
||||
// Extract variable name from target
|
||||
if let Some(Expr::Name(ExprName { id: var_name, .. })) =
|
||||
assign.targets.first()
|
||||
{
|
||||
tasks.insert(var_name.to_string(), path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks
|
||||
}
|
||||
@@ -88,8 +113,6 @@ struct WacWalker {
|
||||
node_counter: usize,
|
||||
line_index: LineIndex,
|
||||
task_functions: TaskFunctions,
|
||||
in_try: bool,
|
||||
in_while: bool,
|
||||
in_nested_func: bool,
|
||||
in_comprehension: bool,
|
||||
}
|
||||
@@ -103,8 +126,6 @@ impl WacWalker {
|
||||
node_counter: 0,
|
||||
line_index: LineIndex::new(source),
|
||||
task_functions,
|
||||
in_try: false,
|
||||
in_while: false,
|
||||
in_nested_func: false,
|
||||
in_comprehension: false,
|
||||
}
|
||||
@@ -292,6 +313,9 @@ impl WacWalker {
|
||||
if self.is_task_fn_call(expr) {
|
||||
return true;
|
||||
}
|
||||
if Self::is_sdk_call(expr) {
|
||||
return true;
|
||||
}
|
||||
match expr {
|
||||
Expr::Await(ExprAwait { value, .. }) => self.expr_contains_step(value),
|
||||
Expr::Call(call) => {
|
||||
@@ -307,6 +331,17 @@ impl WacWalker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if expr is a call to a known SDK function (step, sleep, wait_for_approval)
|
||||
fn is_sdk_call(expr: &Expr) -> bool {
|
||||
if let Expr::Call(call) = expr {
|
||||
if let Expr::Name(ExprName { id, .. }) = call.func.as_ref() {
|
||||
let name = id.as_str();
|
||||
return name == "step" || name == "sleep" || name == "wait_for_approval";
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Walk a list of statements, returning (first_node_id, last_node_id)
|
||||
fn walk_body(&mut self, body: &[Stmt]) -> Option<(String, String)> {
|
||||
let mut first_id: Option<String> = None;
|
||||
@@ -353,13 +388,17 @@ impl WacWalker {
|
||||
}
|
||||
|
||||
fn walk_expr_stmt(&mut self, expr: &Expr) -> Option<(String, String)> {
|
||||
// await task_fn(...)
|
||||
// await task_fn(...) / await step(...) / await sleep(...) / await wait_for_approval(...)
|
||||
if let Expr::Await(ExprAwait { value, .. }) = expr {
|
||||
// await task_fn(...)
|
||||
if let Expr::Call(call) = value.as_ref() {
|
||||
if self.is_task_fn_call(&Expr::Call(call.clone())) {
|
||||
return self.emit_step(call, expr);
|
||||
}
|
||||
// Check for SDK-level calls: step(), sleep(), wait_for_approval()
|
||||
if let Some(result) = self.try_emit_sdk_call(call, expr) {
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
// await asyncio.gather(task_fn(...), task_fn(...), ...)
|
||||
if Self::is_asyncio_gather_call(value) {
|
||||
@@ -378,17 +417,69 @@ impl WacWalker {
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to emit a node for SDK-level calls: step(), sleep(), wait_for_approval()
|
||||
fn try_emit_sdk_call(&mut self, call: &ExprCall, expr: &Expr) -> Option<(String, String)> {
|
||||
let callee_name = match call.func.as_ref() {
|
||||
Expr::Name(ExprName { id, .. }) => Some(id.as_str()),
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
let line = self.line_of_expr(expr);
|
||||
|
||||
match callee_name {
|
||||
"step" => {
|
||||
// step("name", fn) — extract the name from the first string argument
|
||||
let name = call
|
||||
.args
|
||||
.first()
|
||||
.and_then(|arg| {
|
||||
if let Expr::Constant(c) = arg {
|
||||
if let rustpython_parser::ast::Constant::Str(s) = &c.value {
|
||||
return Some(s.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or_else(|| "step".to_string());
|
||||
let id = self.next_id();
|
||||
let node_id = self.add_node(DagNode {
|
||||
id: id.clone(),
|
||||
node_type: DagNodeType::InlineStep { name: name.clone() },
|
||||
label: name,
|
||||
line,
|
||||
});
|
||||
Some((node_id.clone(), node_id))
|
||||
}
|
||||
"sleep" => {
|
||||
let seconds = call
|
||||
.args
|
||||
.first()
|
||||
.map(|arg| Self::expr_to_source(arg))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
let id = self.next_id();
|
||||
let node_id = self.add_node(DagNode {
|
||||
id: id.clone(),
|
||||
node_type: DagNodeType::Sleep { seconds: seconds.clone() },
|
||||
label: format!("sleep({seconds})"),
|
||||
line,
|
||||
});
|
||||
Some((node_id.clone(), node_id))
|
||||
}
|
||||
"wait_for_approval" => {
|
||||
let id = self.next_id();
|
||||
let node_id = self.add_node(DagNode {
|
||||
id: id.clone(),
|
||||
node_type: DagNodeType::WaitForApproval,
|
||||
label: "wait_for_approval".to_string(),
|
||||
line,
|
||||
});
|
||||
Some((node_id.clone(), node_id))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_step(&mut self, call: &ExprCall, expr: &Expr) -> Option<(String, String)> {
|
||||
if self.in_try {
|
||||
self.errors
|
||||
.push(validation::error_step_in_try(self.line_of_expr(expr)));
|
||||
return None;
|
||||
}
|
||||
if self.in_while {
|
||||
self.errors
|
||||
.push(validation::error_step_in_while(self.line_of_expr(expr)));
|
||||
return None;
|
||||
}
|
||||
if self.in_nested_func {
|
||||
self.errors.push(validation::error_step_in_nested_function(
|
||||
self.line_of_expr(expr),
|
||||
@@ -416,17 +507,6 @@ impl WacWalker {
|
||||
}
|
||||
|
||||
fn emit_parallel(&mut self, gather_call: &ExprCall, expr: &Expr) -> Option<(String, String)> {
|
||||
if self.in_try {
|
||||
self.errors
|
||||
.push(validation::error_step_in_try(self.line_of_expr(expr)));
|
||||
return None;
|
||||
}
|
||||
if self.in_while {
|
||||
self.errors
|
||||
.push(validation::error_step_in_while(self.line_of_expr(expr)));
|
||||
return None;
|
||||
}
|
||||
|
||||
let line = self.line_of_expr(expr);
|
||||
let start_id = self.next_id();
|
||||
let start_node_id = self.add_node(DagNode {
|
||||
@@ -491,8 +571,6 @@ impl WacWalker {
|
||||
line,
|
||||
});
|
||||
|
||||
let merge_id = format!("{branch_id}_merge");
|
||||
|
||||
let mut last_ids = Vec::new();
|
||||
|
||||
if let Some((true_first, true_last)) = self.walk_body(&if_stmt.body) {
|
||||
@@ -514,7 +592,17 @@ impl WacWalker {
|
||||
if last_ids.len() == 1 {
|
||||
Some((branch_node_id, last_ids.into_iter().next().unwrap()))
|
||||
} else {
|
||||
Some((branch_node_id, merge_id))
|
||||
let merge_id = format!("{branch_id}_merge");
|
||||
let merge_node_id = self.add_node(DagNode {
|
||||
id: merge_id,
|
||||
node_type: DagNodeType::Merge,
|
||||
label: "merge".to_string(),
|
||||
line,
|
||||
});
|
||||
for last in last_ids {
|
||||
self.add_edge(&last, &merge_node_id, None);
|
||||
}
|
||||
Some((branch_node_id, merge_node_id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,11 +640,36 @@ impl WacWalker {
|
||||
}
|
||||
|
||||
fn walk_while(&mut self, while_stmt: &StmtWhile) -> Option<(String, String)> {
|
||||
if self.body_contains_step(&while_stmt.body) {
|
||||
let line = self.line_index.line_of(while_stmt.range.start().to_usize());
|
||||
self.errors.push(validation::error_step_in_while(line));
|
||||
if !self.body_contains_step(&while_stmt.body) {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
|
||||
let line = self.line_index.line_of(while_stmt.range.start().to_usize());
|
||||
let condition = Self::expr_to_source(&while_stmt.test);
|
||||
|
||||
let start_id = self.next_id();
|
||||
let start_node_id = self.add_node(DagNode {
|
||||
id: start_id.clone(),
|
||||
node_type: DagNodeType::LoopStart { iter_source: condition },
|
||||
label: "while".to_string(),
|
||||
line,
|
||||
});
|
||||
|
||||
if let Some((body_first, body_last)) = self.walk_body(&while_stmt.body) {
|
||||
self.add_edge(&start_node_id, &body_first, None);
|
||||
self.add_edge(&body_last, &start_node_id, Some("next".to_string()));
|
||||
}
|
||||
|
||||
let end_id = self.next_id();
|
||||
let end_node_id = self.add_node(DagNode {
|
||||
id: end_id.clone(),
|
||||
node_type: DagNodeType::LoopEnd,
|
||||
label: "end while".to_string(),
|
||||
line,
|
||||
});
|
||||
self.add_edge(&start_node_id, &end_node_id, Some("done".to_string()));
|
||||
|
||||
Some((start_node_id, end_node_id))
|
||||
}
|
||||
|
||||
fn walk_try(&mut self, try_stmt: &StmtTry) -> Option<(String, String)> {
|
||||
@@ -569,11 +682,17 @@ impl WacWalker {
|
||||
}
|
||||
});
|
||||
|
||||
if has_steps {
|
||||
let line = self.line_index.line_of(try_stmt.range.start().to_usize());
|
||||
self.errors.push(validation::error_step_in_try(line));
|
||||
if !has_steps {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
|
||||
let line = self.line_index.line_of(try_stmt.range.start().to_usize());
|
||||
self.emit_try_catch_branch(
|
||||
&try_stmt.body,
|
||||
&try_stmt.handlers,
|
||||
&try_stmt.finalbody,
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
fn walk_try_star(&mut self, try_stmt: &StmtTryStar) -> Option<(String, String)> {
|
||||
@@ -586,11 +705,81 @@ impl WacWalker {
|
||||
}
|
||||
});
|
||||
|
||||
if has_steps {
|
||||
let line = self.line_index.line_of(try_stmt.range.start().to_usize());
|
||||
self.errors.push(validation::error_step_in_try(line));
|
||||
if !has_steps {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
|
||||
let line = self.line_index.line_of(try_stmt.range.start().to_usize());
|
||||
self.emit_try_catch_branch(
|
||||
&try_stmt.body,
|
||||
&try_stmt.handlers,
|
||||
&try_stmt.finalbody,
|
||||
line,
|
||||
)
|
||||
}
|
||||
|
||||
fn emit_try_catch_branch(
|
||||
&mut self,
|
||||
try_body: &[Stmt],
|
||||
handlers: &[rustpython_parser::ast::ExceptHandler],
|
||||
finally_body: &[Stmt],
|
||||
line: usize,
|
||||
) -> Option<(String, String)> {
|
||||
let branch_id = self.next_id();
|
||||
let branch_node_id = self.add_node(DagNode {
|
||||
id: branch_id.clone(),
|
||||
node_type: DagNodeType::Branch { condition_source: "try/except".to_string() },
|
||||
label: "try".to_string(),
|
||||
line,
|
||||
});
|
||||
|
||||
let mut last_ids = Vec::new();
|
||||
|
||||
// Try body
|
||||
if let Some((try_first, try_last)) = self.walk_body(try_body) {
|
||||
self.add_edge(&branch_node_id, &try_first, Some("try".to_string()));
|
||||
last_ids.push(try_last);
|
||||
} else {
|
||||
last_ids.push(branch_node_id.clone());
|
||||
}
|
||||
|
||||
// Except handlers
|
||||
for handler in handlers {
|
||||
match handler {
|
||||
rustpython_parser::ast::ExceptHandler::ExceptHandler(eh) => {
|
||||
if let Some((catch_first, catch_last)) = self.walk_body(&eh.body) {
|
||||
self.add_edge(&branch_node_id, &catch_first, Some("except".to_string()));
|
||||
last_ids.push(catch_last);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finally body — sequential after merge
|
||||
let merge_last = if last_ids.len() == 1 {
|
||||
last_ids.into_iter().next().unwrap()
|
||||
} else {
|
||||
let merge_id = format!("{branch_id}_merge");
|
||||
let merge_node_id = self.add_node(DagNode {
|
||||
id: merge_id,
|
||||
node_type: DagNodeType::Merge,
|
||||
label: "merge".to_string(),
|
||||
line,
|
||||
});
|
||||
for last in last_ids {
|
||||
self.add_edge(&last, &merge_node_id, None);
|
||||
}
|
||||
merge_node_id
|
||||
};
|
||||
|
||||
if !finally_body.is_empty() {
|
||||
if let Some((finally_first, finally_last)) = self.walk_body(finally_body) {
|
||||
self.add_edge(&merge_last, &finally_first, None);
|
||||
return Some((branch_node_id, finally_last));
|
||||
}
|
||||
}
|
||||
|
||||
Some((branch_node_id, merge_last))
|
||||
}
|
||||
|
||||
fn walk_return(&mut self, ret: &StmtReturn) -> Option<(String, String)> {
|
||||
|
||||
@@ -51,22 +51,29 @@ fn extract_var_name(pat: &Pat) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if expr is `task(async fn)` or `task("path", async fn)`.
|
||||
/// Returns Some(optional_path) if it is a task() call.
|
||||
/// Check if expr is `task(async fn)`, `task("path", async fn)`,
|
||||
/// `taskScript("path")`, or `taskFlow("path")`.
|
||||
/// Returns Some(optional_path) if it is a task/taskScript/taskFlow call.
|
||||
fn extract_task_call_info(expr: &Expr) -> Option<Option<String>> {
|
||||
if let Expr::Call(call) = expr {
|
||||
if let Callee::Expr(callee) = &call.callee {
|
||||
if let Expr::Ident(ident) = callee.as_ref() {
|
||||
if ident.sym.as_ref() == "task" {
|
||||
let name = ident.sym.as_ref();
|
||||
if name == "task" {
|
||||
// task("f/path", async fn) or task(async fn)
|
||||
if call.args.len() == 2 {
|
||||
// task("f/path", async fn)
|
||||
let path = extract_string_lit(&call.args[0].expr);
|
||||
return Some(path);
|
||||
} else if call.args.len() == 1 {
|
||||
// task(async fn)
|
||||
return Some(None);
|
||||
}
|
||||
} else if name == "taskScript" || name == "taskFlow" {
|
||||
// taskScript("./helper.ts") or taskFlow("f/my_flow")
|
||||
if let Some(first_arg) = call.args.first() {
|
||||
let path = extract_string_lit(&first_arg.expr);
|
||||
return Some(path);
|
||||
}
|
||||
return Some(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,8 +88,6 @@ struct TsWacWalker {
|
||||
node_counter: usize,
|
||||
cm: Lrc<SourceMap>,
|
||||
task_functions: TaskFunctions,
|
||||
in_try: bool,
|
||||
in_while: bool,
|
||||
in_nested_func: bool,
|
||||
}
|
||||
|
||||
@@ -95,8 +100,6 @@ impl TsWacWalker {
|
||||
node_counter: 0,
|
||||
cm,
|
||||
task_functions,
|
||||
in_try: false,
|
||||
in_while: false,
|
||||
in_nested_func: false,
|
||||
}
|
||||
}
|
||||
@@ -224,6 +227,9 @@ impl TsWacWalker {
|
||||
if self.is_task_call(expr) {
|
||||
return true;
|
||||
}
|
||||
if Self::is_sdk_call(expr) {
|
||||
return true;
|
||||
}
|
||||
match expr {
|
||||
Expr::Await(await_expr) => self.expr_contains_step(&await_expr.arg),
|
||||
Expr::Call(call) => {
|
||||
@@ -237,6 +243,19 @@ impl TsWacWalker {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if expr is a call to a known SDK function (step, sleep, waitForApproval)
|
||||
fn is_sdk_call(expr: &Expr) -> bool {
|
||||
if let Expr::Call(call) = expr {
|
||||
if let Callee::Expr(callee) = &call.callee {
|
||||
if let Expr::Ident(ident) = callee.as_ref() {
|
||||
let name = ident.sym.as_ref();
|
||||
return name == "step" || name == "sleep" || name == "waitForApproval";
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn walk_body(&mut self, stmts: &[Stmt]) -> Option<(String, String)> {
|
||||
let mut first_id: Option<String> = None;
|
||||
let mut prev_id: Option<String> = None;
|
||||
@@ -294,12 +313,16 @@ impl TsWacWalker {
|
||||
}
|
||||
|
||||
fn walk_expr_stmt(&mut self, expr: &Expr) -> Option<(String, String)> {
|
||||
// await task_fn(...)
|
||||
// await task_fn(...) / await step(...) / await sleep(...) / await waitForApproval(...)
|
||||
if let Expr::Await(await_expr) = expr {
|
||||
if let Expr::Call(call) = await_expr.arg.as_ref() {
|
||||
if self.is_task_call(&Expr::Call(call.clone())) {
|
||||
return self.emit_step(call, expr);
|
||||
}
|
||||
// Check for SDK-level calls: step(), sleep(), waitForApproval()
|
||||
if let Some(result) = self.try_emit_sdk_call(call, expr) {
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
// await Promise.all([task_fn(...), ...])
|
||||
if Self::is_promise_all(&await_expr.arg) {
|
||||
@@ -318,17 +341,70 @@ impl TsWacWalker {
|
||||
None
|
||||
}
|
||||
|
||||
/// Try to emit a node for SDK-level calls: step(), sleep(), waitForApproval()
|
||||
fn try_emit_sdk_call(&mut self, call: &CallExpr, expr: &Expr) -> Option<(String, String)> {
|
||||
let callee_name = match &call.callee {
|
||||
Callee::Expr(callee) => match callee.as_ref() {
|
||||
Expr::Ident(ident) => Some(ident.sym.as_ref().to_string()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}?;
|
||||
|
||||
let line = self.span_line(expr.span());
|
||||
|
||||
match callee_name.as_str() {
|
||||
"step" => {
|
||||
// step("name", fn) — extract the name from the first string argument
|
||||
let name = call
|
||||
.args
|
||||
.first()
|
||||
.and_then(|a| extract_string_lit(&a.expr))
|
||||
.unwrap_or_else(|| "step".to_string());
|
||||
let id = self.next_id();
|
||||
let node_id = self.add_node(DagNode {
|
||||
id: id.clone(),
|
||||
node_type: DagNodeType::InlineStep { name: name.clone() },
|
||||
label: name,
|
||||
line,
|
||||
});
|
||||
Some((node_id.clone(), node_id))
|
||||
}
|
||||
"sleep" => {
|
||||
// sleep(N) — extract the duration from the first argument
|
||||
let seconds = call
|
||||
.args
|
||||
.first()
|
||||
.map(|a| {
|
||||
self.cm
|
||||
.span_to_snippet(a.expr.span())
|
||||
.unwrap_or_else(|_| "?".to_string())
|
||||
})
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
let id = self.next_id();
|
||||
let node_id = self.add_node(DagNode {
|
||||
id: id.clone(),
|
||||
node_type: DagNodeType::Sleep { seconds: seconds.clone() },
|
||||
label: format!("sleep({seconds})"),
|
||||
line,
|
||||
});
|
||||
Some((node_id.clone(), node_id))
|
||||
}
|
||||
"waitForApproval" => {
|
||||
let id = self.next_id();
|
||||
let node_id = self.add_node(DagNode {
|
||||
id: id.clone(),
|
||||
node_type: DagNodeType::WaitForApproval,
|
||||
label: "waitForApproval".to_string(),
|
||||
line,
|
||||
});
|
||||
Some((node_id.clone(), node_id))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_step(&mut self, call: &CallExpr, expr: &Expr) -> Option<(String, String)> {
|
||||
if self.in_try {
|
||||
self.errors
|
||||
.push(validation::error_step_in_catch(self.span_line(expr.span())));
|
||||
return None;
|
||||
}
|
||||
if self.in_while {
|
||||
self.errors
|
||||
.push(validation::error_step_in_while(self.span_line(expr.span())));
|
||||
return None;
|
||||
}
|
||||
if self.in_nested_func {
|
||||
self.errors.push(validation::error_step_in_nested_function(
|
||||
self.span_line(expr.span()),
|
||||
@@ -350,17 +426,6 @@ impl TsWacWalker {
|
||||
}
|
||||
|
||||
fn emit_parallel(&mut self, promise_call: &CallExpr, expr: &Expr) -> Option<(String, String)> {
|
||||
if self.in_try {
|
||||
self.errors
|
||||
.push(validation::error_step_in_catch(self.span_line(expr.span())));
|
||||
return None;
|
||||
}
|
||||
if self.in_while {
|
||||
self.errors
|
||||
.push(validation::error_step_in_while(self.span_line(expr.span())));
|
||||
return None;
|
||||
}
|
||||
|
||||
let line = self.span_line(expr.span());
|
||||
let start_id = self.next_id();
|
||||
let start_node_id = self.add_node(DagNode {
|
||||
@@ -457,7 +522,16 @@ impl TsWacWalker {
|
||||
Some((branch_node_id, last_ids.into_iter().next().unwrap()))
|
||||
} else {
|
||||
let merge_id = format!("{branch_id}_merge");
|
||||
Some((branch_node_id, merge_id))
|
||||
let merge_node_id = self.add_node(DagNode {
|
||||
id: merge_id,
|
||||
node_type: DagNodeType::Merge,
|
||||
label: "merge".to_string(),
|
||||
line,
|
||||
});
|
||||
for last in last_ids {
|
||||
self.add_edge(&last, &merge_node_id, None);
|
||||
}
|
||||
Some((branch_node_id, merge_node_id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +547,7 @@ impl TsWacWalker {
|
||||
return None;
|
||||
}
|
||||
let iter_source = self.expr_to_source(&for_in.right);
|
||||
self.walk_loop_body_with_iter(&for_in.body, for_in.span, &iter_source)
|
||||
self.walk_loop_body_with_iter(&for_in.body, for_in.span, &iter_source, "for")
|
||||
}
|
||||
|
||||
fn walk_for_of(&mut self, for_of: &ForOfStmt) -> Option<(String, String)> {
|
||||
@@ -481,7 +555,7 @@ impl TsWacWalker {
|
||||
return None;
|
||||
}
|
||||
let iter_source = self.expr_to_source(&for_of.right);
|
||||
self.walk_loop_body_with_iter(&for_of.body, for_of.span, &iter_source)
|
||||
self.walk_loop_body_with_iter(&for_of.body, for_of.span, &iter_source, "for")
|
||||
}
|
||||
|
||||
fn walk_loop_body(
|
||||
@@ -490,7 +564,7 @@ impl TsWacWalker {
|
||||
span: swc_common::Span,
|
||||
_label: &str,
|
||||
) -> Option<(String, String)> {
|
||||
self.walk_loop_body_with_iter(body, span, "...")
|
||||
self.walk_loop_body_with_iter(body, span, "...", "for")
|
||||
}
|
||||
|
||||
fn walk_loop_body_with_iter(
|
||||
@@ -498,13 +572,14 @@ impl TsWacWalker {
|
||||
body: &Stmt,
|
||||
span: swc_common::Span,
|
||||
iter_source: &str,
|
||||
loop_label: &str,
|
||||
) -> Option<(String, String)> {
|
||||
let line = self.span_line(span);
|
||||
let start_id = self.next_id();
|
||||
let start_node_id = self.add_node(DagNode {
|
||||
id: start_id.clone(),
|
||||
node_type: DagNodeType::LoopStart { iter_source: iter_source.to_string() },
|
||||
label: "for".to_string(),
|
||||
label: loop_label.to_string(),
|
||||
line,
|
||||
});
|
||||
|
||||
@@ -526,12 +601,11 @@ impl TsWacWalker {
|
||||
}
|
||||
|
||||
fn walk_while(&mut self, while_stmt: &WhileStmt) -> Option<(String, String)> {
|
||||
if self.stmt_contains_step(&while_stmt.body) {
|
||||
self.errors.push(validation::error_step_in_while(
|
||||
self.span_line(while_stmt.span),
|
||||
));
|
||||
if !self.stmt_contains_step(&while_stmt.body) {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
let condition = self.expr_to_source(&while_stmt.test);
|
||||
self.walk_loop_body_with_iter(&while_stmt.body, while_stmt.span, &condition, "while")
|
||||
}
|
||||
|
||||
fn walk_try(&mut self, try_stmt: &TryStmt) -> Option<(String, String)> {
|
||||
@@ -545,12 +619,62 @@ impl TsWacWalker {
|
||||
.as_ref()
|
||||
.map_or(false, |f| self.body_contains_step(&f.stmts));
|
||||
|
||||
if has_steps {
|
||||
self.errors.push(validation::error_step_in_catch(
|
||||
self.span_line(try_stmt.span),
|
||||
));
|
||||
if !has_steps {
|
||||
return None;
|
||||
}
|
||||
None
|
||||
|
||||
let line = self.span_line(try_stmt.span);
|
||||
let branch_id = self.next_id();
|
||||
let branch_node_id = self.add_node(DagNode {
|
||||
id: branch_id.clone(),
|
||||
node_type: DagNodeType::Branch { condition_source: "try/catch".to_string() },
|
||||
label: "try".to_string(),
|
||||
line,
|
||||
});
|
||||
|
||||
let mut last_ids = Vec::new();
|
||||
|
||||
// Try body
|
||||
if let Some((try_first, try_last)) = self.walk_body(&try_stmt.block.stmts) {
|
||||
self.add_edge(&branch_node_id, &try_first, Some("try".to_string()));
|
||||
last_ids.push(try_last);
|
||||
} else {
|
||||
last_ids.push(branch_node_id.clone());
|
||||
}
|
||||
|
||||
// Catch body
|
||||
if let Some(handler) = &try_stmt.handler {
|
||||
if let Some((catch_first, catch_last)) = self.walk_body(&handler.body.stmts) {
|
||||
self.add_edge(&branch_node_id, &catch_first, Some("catch".to_string()));
|
||||
last_ids.push(catch_last);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally body — sequential after merge
|
||||
let merge_last = if last_ids.len() == 1 {
|
||||
last_ids.into_iter().next().unwrap()
|
||||
} else {
|
||||
let merge_id = format!("{branch_id}_merge");
|
||||
let merge_node_id = self.add_node(DagNode {
|
||||
id: merge_id,
|
||||
node_type: DagNodeType::Merge,
|
||||
label: "merge".to_string(),
|
||||
line,
|
||||
});
|
||||
for last in last_ids {
|
||||
self.add_edge(&last, &merge_node_id, None);
|
||||
}
|
||||
merge_node_id
|
||||
};
|
||||
|
||||
if let Some(finalizer) = &try_stmt.finalizer {
|
||||
if let Some((finally_first, finally_last)) = self.walk_body(&finalizer.stmts) {
|
||||
self.add_edge(&merge_last, &finally_first, None);
|
||||
return Some((branch_node_id, finally_last));
|
||||
}
|
||||
}
|
||||
|
||||
Some((branch_node_id, merge_last))
|
||||
}
|
||||
|
||||
fn walk_return(&mut self, ret: &ReturnStmt) -> Option<(String, String)> {
|
||||
@@ -632,6 +756,19 @@ pub fn parse_ts_workflow(code: &str) -> Result<WorkflowDag, Vec<CompileError>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// export const main = workflow(async (...) => { ... })
|
||||
if let ModuleItem::ModuleDecl(ModuleDecl::ExportDecl(export)) = item {
|
||||
if let Decl::Var(var_decl) = &export.decl {
|
||||
for decl in &var_decl.decls {
|
||||
if let Some(init) = &decl.init {
|
||||
if let Some(result) = find_workflow_call(init, &cm) {
|
||||
workflow_body = Some(result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (stmts, params) = workflow_body.ok_or_else(|| {
|
||||
|
||||
@@ -12,23 +12,6 @@ impl std::fmt::Display for CompileError {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_step_in_try(line: usize) -> CompileError {
|
||||
CompileError {
|
||||
message:
|
||||
"Task calls inside try/except are not allowed. Steps have built-in error handling."
|
||||
.to_string(),
|
||||
line,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_step_in_while(line: usize) -> CompileError {
|
||||
CompileError {
|
||||
message: "Task calls inside while loops are not allowed. Use for loops instead."
|
||||
.to_string(),
|
||||
line,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_step_in_nested_function(line: usize) -> CompileError {
|
||||
CompileError {
|
||||
message: "Task calls inside nested functions, closures, or lambdas are not allowed."
|
||||
@@ -53,12 +36,3 @@ pub fn error_missing_await(line: usize) -> CompileError {
|
||||
line,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_step_in_catch(line: usize) -> CompileError {
|
||||
CompileError {
|
||||
message:
|
||||
"Task calls inside catch blocks are not allowed. Steps have built-in error handling."
|
||||
.to_string(),
|
||||
line,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ async def my_etl(items: list):
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_step_in_try() {
|
||||
fn test_step_in_try_except() {
|
||||
let code = r#"
|
||||
import asyncio
|
||||
from wmill import workflow, task
|
||||
@@ -155,39 +155,52 @@ from wmill import workflow, task
|
||||
@task
|
||||
async def extract_data(): ...
|
||||
|
||||
@task
|
||||
async def handle_error(): ...
|
||||
|
||||
@workflow
|
||||
async def my_etl():
|
||||
try:
|
||||
await extract_data()
|
||||
except Exception:
|
||||
pass
|
||||
await handle_error()
|
||||
"#;
|
||||
|
||||
let result = parse_python_workflow(code);
|
||||
assert!(result.is_err());
|
||||
let errors = result.unwrap_err();
|
||||
assert!(errors[0].message.contains("try/except"));
|
||||
let dag = parse_python_workflow(code).expect("should parse try/except");
|
||||
// Branch(try/except), extract_data, handle_error, merge = 4
|
||||
assert_eq!(dag.nodes.len(), 4);
|
||||
assert!(matches!(dag.nodes[0].node_type, DagNodeType::Branch { .. }));
|
||||
assert_eq!(dag.nodes[0].label, "try");
|
||||
assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. }));
|
||||
assert!(matches!(dag.nodes[2].node_type, DagNodeType::Step { .. }));
|
||||
assert!(matches!(dag.nodes[3].node_type, DagNodeType::Merge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_step_in_while() {
|
||||
fn test_step_in_while() {
|
||||
let code = r#"
|
||||
import asyncio
|
||||
from wmill import workflow, task
|
||||
|
||||
@task
|
||||
async def extract_data(): ...
|
||||
async def poll_status(): ...
|
||||
|
||||
@workflow
|
||||
async def my_etl():
|
||||
while True:
|
||||
await extract_data()
|
||||
await poll_status()
|
||||
"#;
|
||||
|
||||
let result = parse_python_workflow(code);
|
||||
assert!(result.is_err());
|
||||
let errors = result.unwrap_err();
|
||||
assert!(errors[0].message.contains("while"));
|
||||
let dag = parse_python_workflow(code).expect("should parse while loop");
|
||||
// LoopStart, poll_status, LoopEnd = 3
|
||||
assert_eq!(dag.nodes.len(), 3);
|
||||
assert!(matches!(
|
||||
dag.nodes[0].node_type,
|
||||
DagNodeType::LoopStart { .. }
|
||||
));
|
||||
assert_eq!(dag.nodes[0].label, "while");
|
||||
assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. }));
|
||||
assert!(matches!(dag.nodes[2].node_type, DagNodeType::LoopEnd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -264,3 +277,89 @@ async def my_wf(x: int):
|
||||
_ => panic!("expected Step node"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_script_and_task_flow_py() {
|
||||
let code = r#"
|
||||
from wmill import workflow, task, task_script, task_flow
|
||||
|
||||
helper = task_script("./helper.py")
|
||||
pipeline = task_flow("f/etl/pipeline")
|
||||
|
||||
@task()
|
||||
async def process(x: str) -> str:
|
||||
return f"processed: {x}"
|
||||
|
||||
@workflow
|
||||
async def main(x: str):
|
||||
a = await process(x=x)
|
||||
b = await helper(a=a)
|
||||
c = await pipeline(b=b)
|
||||
return {"a": a, "b": b, "c": c}
|
||||
"#;
|
||||
|
||||
let dag = parse_python_workflow(code).expect("should parse");
|
||||
assert_eq!(dag.nodes.len(), 4); // 3 steps + 1 return
|
||||
|
||||
match &dag.nodes[1].node_type {
|
||||
DagNodeType::Step { name, script } => {
|
||||
assert_eq!(name, "helper");
|
||||
assert_eq!(script, "./helper.py");
|
||||
}
|
||||
_ => panic!("expected Step node for task_script"),
|
||||
}
|
||||
|
||||
match &dag.nodes[2].node_type {
|
||||
DagNodeType::Step { name, script } => {
|
||||
assert_eq!(name, "pipeline");
|
||||
assert_eq!(script, "f/etl/pipeline");
|
||||
}
|
||||
_ => panic!("expected Step node for task_flow"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_template_with_sdk_calls_py() {
|
||||
let code = r#"
|
||||
from wmill import workflow, task, task_script, step, sleep, wait_for_approval, get_resume_urls
|
||||
|
||||
helper = task_script("./helper.py")
|
||||
|
||||
@task()
|
||||
async def process(x: str) -> str:
|
||||
return f"processed: {x}"
|
||||
|
||||
@workflow
|
||||
async def main(x: str):
|
||||
a = await process(x=x)
|
||||
b = await helper(a=a)
|
||||
urls = await step("get_urls", lambda: get_resume_urls())
|
||||
await sleep(1)
|
||||
approval = await wait_for_approval(timeout=3600)
|
||||
return {"processed": a, "helper_result": b, "approval": approval}
|
||||
"#;
|
||||
|
||||
let dag = parse_python_workflow(code).expect("should parse");
|
||||
// process, helper, step("get_urls"), sleep(1), wait_for_approval, return = 6
|
||||
assert_eq!(dag.nodes.len(), 6);
|
||||
|
||||
match &dag.nodes[2].node_type {
|
||||
DagNodeType::InlineStep { name } => {
|
||||
assert_eq!(name, "get_urls");
|
||||
}
|
||||
_ => panic!("expected InlineStep node, got {:?}", dag.nodes[2].node_type),
|
||||
}
|
||||
|
||||
match &dag.nodes[3].node_type {
|
||||
DagNodeType::Sleep { seconds } => {
|
||||
assert_eq!(seconds, "1");
|
||||
}
|
||||
_ => panic!("expected Sleep node, got {:?}", dag.nodes[3].node_type),
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
dag.nodes[4].node_type,
|
||||
DagNodeType::WaitForApproval
|
||||
));
|
||||
assert!(matches!(dag.nodes[5].node_type, DagNodeType::Return));
|
||||
}
|
||||
|
||||
@@ -129,45 +129,56 @@ export default workflow(async (items: string[]) => {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_step_in_try_catch() {
|
||||
fn test_step_in_try_catch() {
|
||||
let code = r#"
|
||||
import { workflow, task } from "windmill-client";
|
||||
|
||||
const extract_data = task(async () => {});
|
||||
const handle_error = task(async (e: any) => {});
|
||||
|
||||
export default workflow(async () => {
|
||||
try {
|
||||
await extract_data();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
await handle_error(e);
|
||||
}
|
||||
});
|
||||
"#;
|
||||
|
||||
let result = parse_ts_workflow(code);
|
||||
assert!(result.is_err());
|
||||
let errors = result.unwrap_err();
|
||||
assert!(errors[0].message.contains("catch"));
|
||||
let dag = parse_ts_workflow(code).expect("should parse try/catch");
|
||||
// Branch(try/catch), extract_data, handle_error, merge = 4
|
||||
assert_eq!(dag.nodes.len(), 4);
|
||||
assert!(matches!(dag.nodes[0].node_type, DagNodeType::Branch { .. }));
|
||||
assert_eq!(dag.nodes[0].label, "try");
|
||||
assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. }));
|
||||
assert!(matches!(dag.nodes[2].node_type, DagNodeType::Step { .. }));
|
||||
assert!(matches!(dag.nodes[3].node_type, DagNodeType::Merge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reject_step_in_while_ts() {
|
||||
fn test_step_in_while_ts() {
|
||||
let code = r#"
|
||||
import { workflow, task } from "windmill-client";
|
||||
|
||||
const extract_data = task(async () => {});
|
||||
const poll_status = task(async () => {});
|
||||
|
||||
export default workflow(async () => {
|
||||
while (true) {
|
||||
await extract_data();
|
||||
await poll_status();
|
||||
}
|
||||
});
|
||||
"#;
|
||||
|
||||
let result = parse_ts_workflow(code);
|
||||
assert!(result.is_err());
|
||||
let errors = result.unwrap_err();
|
||||
assert!(errors[0].message.contains("while"));
|
||||
let dag = parse_ts_workflow(code).expect("should parse while loop");
|
||||
// LoopStart, poll_status, LoopEnd = 3
|
||||
assert_eq!(dag.nodes.len(), 3);
|
||||
assert!(matches!(
|
||||
dag.nodes[0].node_type,
|
||||
DagNodeType::LoopStart { .. }
|
||||
));
|
||||
assert_eq!(dag.nodes[0].label, "while");
|
||||
assert!(matches!(dag.nodes[1].node_type, DagNodeType::Step { .. }));
|
||||
assert!(matches!(dag.nodes[2].node_type, DagNodeType::LoopEnd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -243,3 +254,158 @@ export default workflow(async (x: number) => {
|
||||
_ => panic!("expected Step node"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_task_script_and_task_flow() {
|
||||
let code = r#"
|
||||
import { workflow, task, taskScript, taskFlow } from "windmill-client";
|
||||
|
||||
const helper = taskScript("./helper.ts");
|
||||
const pipeline = taskFlow("f/etl/pipeline");
|
||||
const process = task(async (x: string) => {});
|
||||
|
||||
export default workflow(async (x: string) => {
|
||||
const a = await process(x);
|
||||
const b = await helper({ a });
|
||||
const c = await pipeline({ b });
|
||||
return { a, b, c };
|
||||
});
|
||||
"#;
|
||||
|
||||
let dag = parse_ts_workflow(code).expect("should parse");
|
||||
assert_eq!(dag.nodes.len(), 4); // 3 steps + 1 return
|
||||
|
||||
match &dag.nodes[0].node_type {
|
||||
DagNodeType::Step { name, script } => {
|
||||
assert_eq!(name, "process");
|
||||
assert_eq!(script, "process");
|
||||
}
|
||||
_ => panic!("expected Step node"),
|
||||
}
|
||||
|
||||
match &dag.nodes[1].node_type {
|
||||
DagNodeType::Step { name, script } => {
|
||||
assert_eq!(name, "helper");
|
||||
assert_eq!(script, "./helper.ts");
|
||||
}
|
||||
_ => panic!("expected Step node for taskScript"),
|
||||
}
|
||||
|
||||
match &dag.nodes[2].node_type {
|
||||
DagNodeType::Step { name, script } => {
|
||||
assert_eq!(name, "pipeline");
|
||||
assert_eq!(script, "f/etl/pipeline");
|
||||
}
|
||||
_ => panic!("expected Step node for taskFlow"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_template_with_sdk_calls() {
|
||||
let code = r#"
|
||||
import { task, taskScript, step, sleep, waitForApproval, getResumeUrls, workflow } from "windmill-client";
|
||||
|
||||
const helper = taskScript("./helper.ts");
|
||||
const process = task(async (x: string): Promise<string> => {
|
||||
return `processed: ${x}`;
|
||||
});
|
||||
|
||||
export const main = workflow(async (x: string) => {
|
||||
const a = await process(x);
|
||||
const b = await helper({ a });
|
||||
const urls = await step("get_urls", () => getResumeUrls());
|
||||
await sleep(1);
|
||||
const approval = await waitForApproval({ timeout: 3600 });
|
||||
return { processed: a, helper_result: b, approval };
|
||||
});
|
||||
"#;
|
||||
|
||||
let dag = parse_ts_workflow(code).expect("should parse");
|
||||
// process, helper, step("get_urls"), sleep(1), waitForApproval, return = 6
|
||||
assert_eq!(dag.nodes.len(), 6);
|
||||
assert_eq!(dag.edges.len(), 5);
|
||||
|
||||
assert!(matches!(dag.nodes[0].node_type, DagNodeType::Step { .. }));
|
||||
|
||||
match &dag.nodes[1].node_type {
|
||||
DagNodeType::Step { name, script } => {
|
||||
assert_eq!(name, "helper");
|
||||
assert_eq!(script, "./helper.ts");
|
||||
}
|
||||
_ => panic!("expected Step node"),
|
||||
}
|
||||
|
||||
match &dag.nodes[2].node_type {
|
||||
DagNodeType::InlineStep { name } => {
|
||||
assert_eq!(name, "get_urls");
|
||||
}
|
||||
_ => panic!("expected InlineStep node, got {:?}", dag.nodes[2].node_type),
|
||||
}
|
||||
|
||||
match &dag.nodes[3].node_type {
|
||||
DagNodeType::Sleep { seconds } => {
|
||||
assert_eq!(seconds, "1");
|
||||
}
|
||||
_ => panic!("expected Sleep node, got {:?}", dag.nodes[3].node_type),
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
dag.nodes[4].node_type,
|
||||
DagNodeType::WaitForApproval
|
||||
));
|
||||
assert!(matches!(dag.nodes[5].node_type, DagNodeType::Return));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_complex_mixed_workflow() {
|
||||
let code = r#"
|
||||
import { workflow, task, step, sleep } from "windmill-client";
|
||||
|
||||
const validate = task(async (data: any) => {});
|
||||
const process_csv = task(async (data: any) => {});
|
||||
const process_json = task(async (data: any) => {});
|
||||
const enrich = task(async (item: any) => {});
|
||||
const store = task(async (data: any) => {});
|
||||
|
||||
export default workflow(async (data: any) => {
|
||||
const validated = await validate(data);
|
||||
if (validated.format === "csv") {
|
||||
const parsed = await process_csv(validated);
|
||||
for (const row of parsed.rows) {
|
||||
await enrich(row);
|
||||
}
|
||||
} else {
|
||||
await process_json(validated);
|
||||
}
|
||||
await sleep(5);
|
||||
const ts = await step("timestamp", () => new Date().toISOString());
|
||||
await store(validated);
|
||||
return { done: true };
|
||||
});
|
||||
"#;
|
||||
|
||||
let dag = parse_ts_workflow(code).expect("should parse");
|
||||
|
||||
// validate, Branch, process_csv, LoopStart, enrich, LoopEnd, process_json,
|
||||
// merge, sleep(5), step("timestamp"), store, return = 12
|
||||
assert_eq!(dag.nodes.len(), 12);
|
||||
|
||||
assert!(matches!(dag.nodes[0].node_type, DagNodeType::Step { .. }));
|
||||
assert!(matches!(dag.nodes[1].node_type, DagNodeType::Branch { .. }));
|
||||
assert!(matches!(dag.nodes[2].node_type, DagNodeType::Step { .. })); // process_csv
|
||||
assert!(matches!(
|
||||
dag.nodes[3].node_type,
|
||||
DagNodeType::LoopStart { .. }
|
||||
));
|
||||
assert!(matches!(dag.nodes[4].node_type, DagNodeType::Step { .. })); // enrich
|
||||
assert!(matches!(dag.nodes[5].node_type, DagNodeType::LoopEnd));
|
||||
assert!(matches!(dag.nodes[6].node_type, DagNodeType::Step { .. })); // process_json
|
||||
assert!(matches!(dag.nodes[7].node_type, DagNodeType::Merge));
|
||||
assert!(matches!(dag.nodes[8].node_type, DagNodeType::Sleep { .. }));
|
||||
assert!(matches!(
|
||||
dag.nodes[9].node_type,
|
||||
DagNodeType::InlineStep { .. }
|
||||
)); // timestamp
|
||||
assert!(matches!(dag.nodes[10].node_type, DagNodeType::Step { .. })); // store
|
||||
assert!(matches!(dag.nodes[11].node_type, DagNodeType::Return));
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ csharp-parser = [ "dep:windmill-parser-csharp"]
|
||||
nu-parser = [ "dep:windmill-parser-nu"]
|
||||
java-parser = [ "dep:windmill-parser-java"]
|
||||
ruby-parser = [ "dep:windmill-parser-ruby"]
|
||||
r-parser = [ "dep:windmill-parser-r"]
|
||||
wac-parser = [ "dep:windmill-parser-wac"]
|
||||
asset-parser = [ "dep:windmill-parser-ts-asset", "dep:windmill-parser-py-asset", "dep:windmill-parser-sql-asset"]
|
||||
py-imports-parser = [ "dep:windmill-parser-py-imports"]
|
||||
@@ -58,6 +59,7 @@ windmill-parser-csharp = { workspace = true, optional = true }
|
||||
windmill-parser-nu = { workspace = true, optional = true }
|
||||
windmill-parser-java = { workspace = true, optional = true }
|
||||
windmill-parser-ruby = { workspace = true, optional = true }
|
||||
windmill-parser-r = { workspace = true, optional = true }
|
||||
windmill-parser-wac = { workspace = true, optional = true }
|
||||
windmill-parser-ts-asset = { workspace = true, optional = true }
|
||||
windmill-parser-py-asset = { workspace = true, optional = true }
|
||||
|
||||
@@ -55,6 +55,11 @@ const targets = [
|
||||
desc: "Ruby",
|
||||
features: "ruby-parser",
|
||||
env: "tree-sitter",
|
||||
}, {
|
||||
ident: "r",
|
||||
desc: "R",
|
||||
features: "r-parser",
|
||||
env: "tree-sitter",
|
||||
},
|
||||
{
|
||||
ident: "wac",
|
||||
|
||||
@@ -39,3 +39,6 @@ popd
|
||||
|
||||
pushd "pkg-py-imports" && npm publish ${args}
|
||||
popd
|
||||
|
||||
pushd "pkg-wac" && npm publish ${args}
|
||||
popd
|
||||
|
||||
@@ -198,6 +198,12 @@ pub fn parse_ruby(code: &str) -> String {
|
||||
wrap_sig(windmill_parser_ruby::parse_ruby_signature(code))
|
||||
}
|
||||
|
||||
#[cfg(feature = "r-parser")]
|
||||
#[wasm_bindgen]
|
||||
pub fn parse_r(code: &str) -> String {
|
||||
wrap_sig(windmill_parser_r::parse_r_signature(code))
|
||||
}
|
||||
|
||||
#[cfg(feature = "asset-parser")]
|
||||
#[wasm_bindgen]
|
||||
pub fn parse_assets_sql(code: &str) -> String {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
void *memcpy(void *dest, const void *src, unsigned long n);
|
||||
void *memmove(void *dest, const void *src, unsigned long n);
|
||||
void *memset(void *s, int c, unsigned long n);
|
||||
|
||||
@@ -51,7 +51,7 @@ use windmill_common::{
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
|
||||
NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
|
||||
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING,
|
||||
RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING,
|
||||
SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
|
||||
@@ -95,20 +95,21 @@ use windmill_worker::{
|
||||
BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, CSHARP_CACHE_DIR, DENO_CACHE_DIR, DENO_CACHE_DIR_DEPS,
|
||||
DENO_CACHE_DIR_NPM, GO_BIN_CACHE_DIR, GO_CACHE_DIR, JAVA_CACHE_DIR, NU_CACHE_DIR,
|
||||
POWERSHELL_CACHE_DIR, PY310_CACHE_DIR, PY311_CACHE_DIR, PY312_CACHE_DIR, PY313_CACHE_DIR,
|
||||
RUBY_CACHE_DIR, RUST_CACHE_DIR, TAR_JAVA_CACHE_DIR, UV_CACHE_DIR,
|
||||
RUBY_CACHE_DIR, RUST_CACHE_DIR, R_CACHE_DIR, TAR_JAVA_CACHE_DIR, UV_CACHE_DIR,
|
||||
};
|
||||
|
||||
use crate::monitor::{
|
||||
initial_load, load_keep_job_dir, load_metrics_debug_enabled, load_require_preexisting_user,
|
||||
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, monitor_db,
|
||||
reload_app_workspaced_route_setting, reload_audit_log_retention_days_setting,
|
||||
reload_base_url_setting, reload_bunfig_install_scopes_setting,
|
||||
reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting,
|
||||
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
|
||||
reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting,
|
||||
reload_hub_base_url_setting, reload_instance_events_webhook_setting,
|
||||
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
|
||||
reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
|
||||
initial_load, load_keep_job_dir, load_metrics_debug_enabled, load_preview_tags_override,
|
||||
load_require_preexisting_user, load_tag_per_workspace_enabled,
|
||||
load_tag_per_workspace_workspaces, monitor_db, reload_app_workspaced_route_setting,
|
||||
reload_audit_log_retention_days_setting, reload_base_url_setting,
|
||||
reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting,
|
||||
reload_critical_alerts_on_token_expiry_setting, reload_critical_error_channels_setting,
|
||||
reload_extra_pip_index_url_setting, reload_http_route_workspaced_route_setting,
|
||||
reload_hub_api_secret_setting, reload_hub_base_url_setting,
|
||||
reload_instance_events_webhook_setting, reload_job_default_timeout_setting,
|
||||
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
|
||||
reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
|
||||
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
|
||||
reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration,
|
||||
};
|
||||
@@ -1742,6 +1743,11 @@ async fn process_notify_event(
|
||||
);
|
||||
}
|
||||
}
|
||||
PREVIEW_TAGS_OVERRIDE_SETTING => {
|
||||
if let Err(e) = load_preview_tags_override(db).await {
|
||||
tracing::error!("Error loading preview tags override: {e:#}");
|
||||
}
|
||||
}
|
||||
SMTP_SETTING => {
|
||||
reload_smtp_config(db).await;
|
||||
}
|
||||
@@ -1906,6 +1912,21 @@ async fn process_notify_event(
|
||||
RESTART_COORDINATION_SETTING => {
|
||||
// Internal coordination key for staggered restarts, no action needed
|
||||
}
|
||||
"plain_emails_telemetry" => {
|
||||
let enabled = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = 'plain_emails_telemetry'"
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
tracing::info!(
|
||||
"Plain emails telemetry setting changed: enabled={}",
|
||||
enabled
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("Unrecognized Global Setting Change Payload: {:?}", payload);
|
||||
}
|
||||
@@ -1990,6 +2011,7 @@ pub async fn run_workers(
|
||||
&*POWERSHELL_CACHE_DIR,
|
||||
&*JAVA_CACHE_DIR,
|
||||
&*RUBY_CACHE_DIR,
|
||||
&*R_CACHE_DIR,
|
||||
&*TAR_JAVA_CACHE_DIR, // for related places search: ADD_NEW_LANG
|
||||
] {
|
||||
DirBuilder::new()
|
||||
|
||||
@@ -62,7 +62,7 @@ use windmill_common::{
|
||||
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
|
||||
NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
|
||||
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
|
||||
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
|
||||
UV_INDEX_STRATEGY_SETTING,
|
||||
@@ -79,8 +79,8 @@ use windmill_common::{
|
||||
load_periodic_bash_script_interval_from_env, load_whitelist_env_vars_from_env,
|
||||
load_worker_config, reload_custom_tags_setting, store_pull_query,
|
||||
store_suspended_pull_query, Connection, WorkerConfig, DEFAULT_TAGS_PER_WORKSPACE,
|
||||
DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR,
|
||||
WORKER_CONFIG, WORKER_GROUP,
|
||||
DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, PREVIEW_TAGS_OVERRIDE, SCRIPT_TOKEN_EXPIRY,
|
||||
SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG, WORKER_GROUP,
|
||||
},
|
||||
KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE,
|
||||
CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB,
|
||||
@@ -235,6 +235,10 @@ pub async fn initial_load(
|
||||
if let Err(e) = load_tag_per_workspace_workspaces(db).await {
|
||||
tracing::error!("Error loading default tag per workpsace workspaces: {e:#}");
|
||||
}
|
||||
|
||||
if let Err(e) = load_preview_tags_override(db).await {
|
||||
tracing::error!("Error loading preview tags override: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
if server_mode {
|
||||
@@ -499,6 +503,16 @@ pub async fn load_tag_per_workspace_workspaces(db: &DB) -> error::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_preview_tags_override(db: &DB) -> error::Result<()> {
|
||||
let value = load_value_from_global_settings(db, PREVIEW_TAGS_OVERRIDE_SETTING).await;
|
||||
|
||||
match value {
|
||||
Ok(Some(serde_json::Value::Bool(t))) => PREVIEW_TAGS_OVERRIDE.store(t, Ordering::Relaxed),
|
||||
_ => (),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::Result<()> {
|
||||
if let Ok(Some(serde_json::Value::Bool(t))) =
|
||||
load_value_from_global_settings_with_conn(conn, CRITICAL_ALERT_MUTE_UI_SETTING, true).await
|
||||
|
||||
@@ -84,7 +84,7 @@ fi
|
||||
|
||||
if [ "$REVERT" == "YES" ]; then
|
||||
backend_dirpath="${root_dirpath}/backend/"
|
||||
for ce_file in $(find "${root_dirpath}/backend" -name "*_ee.rs"); do
|
||||
for ce_file in $(find "${root_dirpath}/backend" \( -name "*_ee.rs" -o -name "ee.rs" \)); do
|
||||
if [ -L "${ce_file}" ]; then
|
||||
rm "${ce_file}"
|
||||
echo "Deleted symlink '${ce_file}'"
|
||||
|
||||
@@ -26,7 +26,7 @@ native_trigger_service: nextcloud
|
||||
request_type: sync, async, sync_sse
|
||||
runnable_type: ScriptHash, ScriptPath, FlowPath
|
||||
script_kind: script, trigger, failure, command, approval, preprocessor
|
||||
script_lang: python3, deno, go, bash, postgresql, nativets, bun, mysql, bigquery, snowflake, graphql, powershell, mssql, php, bunnative, rust, ansible, csharp, oracledb, nu, java, duckdb, ruby
|
||||
script_lang: python3, deno, go, bash, postgresql, nativets, bun, mysql, bigquery, snowflake, graphql, powershell, mssql, php, bunnative, rust, ansible, csharp, oracledb, nu, java, duckdb, ruby, rlang
|
||||
trigger_kind: webhook, http, websocket, kafka, email, nats, postgres, sqs, mqtt, gcp, default_email, nextcloud
|
||||
trigger_mode: enabled, disabled, suspended
|
||||
workspace_key_kind: cloud
|
||||
|
||||
20
backend/tests/fixtures/typechecked_python.sql
vendored
20
backend/tests/fixtures/typechecked_python.sql
vendored
@@ -1,20 +0,0 @@
|
||||
INSERT INTO public.script(workspace_id, created_by, content, schema, summary, description, path, hash, language, lock) VALUES (
|
||||
'test-workspace',
|
||||
'test-user',
|
||||
'
|
||||
import inspect
|
||||
import sys
|
||||
|
||||
def greet(name: str) -> str:
|
||||
# Verify that __file__ is set on this module (same check typeguard does)
|
||||
mod = sys.modules[__name__]
|
||||
source_file = inspect.getfile(mod)
|
||||
return f"Hello, {name}! from {source_file}"
|
||||
|
||||
def main():
|
||||
return greet("World")
|
||||
',
|
||||
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
|
||||
'',
|
||||
'',
|
||||
'f/system/typechecked_helper', 12349, 'python3', '');
|
||||
@@ -923,43 +923,3 @@ async def main(item: str, qty: int, email: str):
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base", "typechecked_python"))]
|
||||
async fn test_typechecked_decorator_python(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let content = r#"
|
||||
from f.system.typechecked_helper import greet
|
||||
|
||||
def main():
|
||||
return greet("World")
|
||||
"#
|
||||
.to_owned();
|
||||
|
||||
let job = JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: Some("f/system/test_typechecked".to_string()),
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
modules: None,
|
||||
});
|
||||
|
||||
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
let result_str = result.as_str().unwrap();
|
||||
assert!(result_str.starts_with("Hello, World! from "), "unexpected result: {result_str}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1081,6 +1081,115 @@ echo "$result"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "rlang")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_r_job(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let content = r#"
|
||||
main <- function(msg) {
|
||||
return(paste("hello", msg))
|
||||
}
|
||||
"#
|
||||
.to_owned();
|
||||
|
||||
let result = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Rlang,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("msg", json!("world"))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, json!("hello world"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "rlang")]
|
||||
#[sqlx::test(fixtures("base", "wmill_cli_test"))]
|
||||
async fn test_r_get_variable(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let content = r#"
|
||||
main <- function() {
|
||||
return(get_variable("u/test-user/test_var"))
|
||||
}
|
||||
"#
|
||||
.to_owned();
|
||||
|
||||
let result = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Rlang,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, json!("hello from variable"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "rlang")]
|
||||
#[sqlx::test(fixtures("base", "wmill_cli_test"))]
|
||||
async fn test_r_get_resource(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let content = r#"
|
||||
main <- function() {
|
||||
return(get_resource("u/test-user/test_res"))
|
||||
}
|
||||
"#
|
||||
.to_owned();
|
||||
|
||||
let result = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content,
|
||||
path: None,
|
||||
lock: None,
|
||||
language: ScriptLang::Rlang,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
|
||||
.into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await
|
||||
.json_result()
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, json!({"host": "localhost", "port": 5432}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "nu")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_nu_job(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
@@ -442,9 +442,22 @@ pub fn check_route_access(
|
||||
// Find the domain and kind for this route
|
||||
let (required_domain, required_kind, route_suffix) = extract_domain_from_route(route_path)?;
|
||||
|
||||
// Backward compatibility: MCP handlers expect unusual scope actions: all, favorites, hub.
|
||||
// MCP scopes (mcp:all, mcp:favorites, mcp:hub:*, etc.) use a custom format
|
||||
// that doesn't fit the standard domain:action model. Verify the token has at
|
||||
// least one mcp: scope; MCP handlers do their own fine-grained checking.
|
||||
if required_domain == ScopeDomain::Mcp {
|
||||
return Ok(());
|
||||
let is_scoped_token = token_scopes
|
||||
.iter()
|
||||
.any(|s| !s.starts_with("if_jobs:filter_tags:"));
|
||||
if !is_scoped_token {
|
||||
return Ok(());
|
||||
}
|
||||
if token_scopes.iter().any(|s| s.starts_with("mcp:")) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(Error::NotAuthorized(
|
||||
"Access denied. Required scope: mcp:*".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// tracing::error!("Checking route access {:?} {:?} {:?} {:?}", required_action, required_domain, required_kind, route_suffix);
|
||||
@@ -931,4 +944,50 @@ mod tests {
|
||||
ScopeDefinition::new("scripts", "read", None, Some(vec!["u/*".to_string()]));
|
||||
assert!(scope_specific_path.includes(&required_broad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_scope_bypass_blocked_without_mcp_scope() {
|
||||
// A token with only jobs:read should NOT be able to access MCP endpoints
|
||||
let scopes = vec!["jobs:read".to_string()];
|
||||
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_err());
|
||||
assert!(
|
||||
check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "POST").is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_scope_allowed_with_mcp_scope() {
|
||||
// A token with mcp:all should access MCP endpoints
|
||||
let scopes = vec!["mcp:all".to_string()];
|
||||
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
|
||||
|
||||
// mcp:favorites should also work
|
||||
let scopes = vec!["mcp:favorites".to_string()];
|
||||
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "POST").is_ok());
|
||||
|
||||
// mcp:scripts:path should also work
|
||||
let scopes = vec!["mcp:scripts:u/admin/script1".to_string()];
|
||||
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_scope_filter_tags_only_treated_as_unrestricted() {
|
||||
// Token with only filter_tags is not considered scoped — should be allowed
|
||||
let scopes = vec!["if_jobs:filter_tags:tag1".to_string()];
|
||||
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_scope_mixed_scopes_without_mcp() {
|
||||
// Token with multiple non-MCP scopes should be denied
|
||||
let scopes = vec!["jobs:read".to_string(), "scripts:write".to_string()];
|
||||
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_scope_mixed_scopes_with_mcp() {
|
||||
// Token with MCP scope + other scopes should be allowed for MCP
|
||||
let scopes = vec!["jobs:read".to_string(), "mcp:all".to_string()];
|
||||
assert!(check_route_access(&scopes, "/api/w/test_workspace/mcp/something", "GET").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,9 +851,21 @@ async fn add_user_igroup(
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
{
|
||||
use windmill_api_workspaces::workspaces_ee::auto_add_user;
|
||||
use windmill_common::users::compute_highest_workspace_role;
|
||||
|
||||
// Find all instance groups this user belongs to (includes the newly added group)
|
||||
let user_igroups: Vec<String> = sqlx::query_scalar!(
|
||||
"SELECT igroup FROM email_to_igroup WHERE email = $1",
|
||||
&email
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let workspaces = sqlx::query!(
|
||||
r#"
|
||||
SELECT workspace_id, auto_invite->'instance_groups_roles' as instance_groups_roles
|
||||
SELECT workspace_id,
|
||||
auto_invite->'instance_groups_roles' as instance_groups_roles,
|
||||
auto_invite->'instance_groups' as instance_groups_json
|
||||
FROM workspace_settings
|
||||
WHERE auto_invite->'instance_groups' ? $1
|
||||
"#,
|
||||
@@ -861,34 +873,53 @@ async fn add_user_igroup(
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for ws in workspaces {
|
||||
let role = ws
|
||||
let roles: std::collections::HashMap<String, String> = ws
|
||||
.instance_groups_roles
|
||||
.and_then(|r| r.get(&name).and_then(|v| v.as_str().map(String::from)))
|
||||
.unwrap_or_else(|| "developer".to_string());
|
||||
let (is_admin, is_operator) = match role.as_str() {
|
||||
"admin" => (true, false),
|
||||
"operator" => (false, true),
|
||||
_ => (false, false),
|
||||
};
|
||||
.and_then(|r| serde_json::from_value(r).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let ws_configured_groups: Vec<String> = ws
|
||||
.instance_groups_json
|
||||
.and_then(|ig| serde_json::from_value(ig).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let (best_group, is_admin, is_operator) =
|
||||
compute_highest_workspace_role(&user_igroups, &ws_configured_groups, &roles);
|
||||
|
||||
let instance_group_source = serde_json::json!({
|
||||
"source": "instance_group",
|
||||
"group": &best_group
|
||||
});
|
||||
|
||||
// auto_add_user creates the user if they don't exist (ON CONFLICT DO NOTHING).
|
||||
// The operator flag here doesn't matter for the final state — the UPDATE below
|
||||
// always sets the correct is_admin/operator based on the highest-precedence role.
|
||||
auto_add_user(
|
||||
&email,
|
||||
&ws.workspace_id,
|
||||
&is_operator,
|
||||
&false,
|
||||
&mut tx,
|
||||
&authed,
|
||||
Some(serde_json::json!({"source": "instance_group", "group": &name})),
|
||||
Some(instance_group_source.clone()),
|
||||
)
|
||||
.await?;
|
||||
if is_admin {
|
||||
sqlx::query!(
|
||||
"UPDATE usr SET is_admin = true WHERE workspace_id = $1 AND email = $2",
|
||||
&ws.workspace_id,
|
||||
&email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Set the correct role based on highest precedence across all groups.
|
||||
// For new users, auto_add_user already stored added_via with source=instance_group,
|
||||
// so this UPDATE will match. For existing instance_group users, it upgrades/corrects
|
||||
// the role. Manually-added users (added_via is NULL or non-instance_group) are not affected.
|
||||
sqlx::query!(
|
||||
"UPDATE usr SET is_admin = $1, operator = $2, added_via = $3 WHERE workspace_id = $4 AND email = $5 AND added_via->>'source' = 'instance_group'",
|
||||
is_admin,
|
||||
is_operator,
|
||||
&instance_group_source,
|
||||
&ws.workspace_id,
|
||||
&email
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ use windmill_common::{
|
||||
jobs::JobKind,
|
||||
scripts::to_i64,
|
||||
utils::{not_found_if_none, paginate, Pagination},
|
||||
worker::CLOUD_HOSTED,
|
||||
};
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
@@ -134,11 +135,20 @@ async fn get_input_history(
|
||||
Query(g): Query<GetInputHistory>,
|
||||
) -> JsonResult<Vec<Input>> {
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
let per_page = if *CLOUD_HOSTED {
|
||||
per_page.min(100)
|
||||
} else {
|
||||
per_page
|
||||
};
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let args_query = if let Some(args) = &g.args {
|
||||
sql_builder::bind::Bind::bind(&"and v2_job.args @> ?", &args.replace("'", "''"))
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
|
||||
sql_builder::bind::Bind::bind(&"and v2_job.args @> ?", &v.to_string())
|
||||
} else {
|
||||
"AND FALSE".to_string()
|
||||
}
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ default = []
|
||||
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 = []
|
||||
mcp = ["windmill-test-utils/mcp", "dep:rmcp"]
|
||||
run_inline = ["dep:windmill-worker", "windmill-test-utils/run_inline", "windmill-test-utils/duckdb"]
|
||||
|
||||
[dependencies]
|
||||
@@ -41,3 +41,4 @@ aws-credential-types = { workspace = true, optional = true }
|
||||
aws-sdk-sqs = { workspace = true, optional = true }
|
||||
base64 = { workspace = true, optional = true }
|
||||
axum.workspace = true
|
||||
rmcp = { workspace = true, optional = true }
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
#[cfg(feature = "mcp")]
|
||||
use uuid::Uuid;
|
||||
|
||||
use windmill_test_utils::*;
|
||||
|
||||
@@ -518,3 +520,182 @@ async fn test_mcp_tools(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_endpoint_tools_list(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let resp = authed(client().get(format!(
|
||||
"http://localhost:{port}/api/mcp/w/test-workspace/list_tools"
|
||||
)))
|
||||
.send()
|
||||
.await?;
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
let tools: Vec<serde_json::Value> = resp.json().await?;
|
||||
|
||||
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
|
||||
|
||||
assert!(
|
||||
tool_names.contains(&"getJob"),
|
||||
"getJob not found in MCP endpoint tools: {tool_names:?}"
|
||||
);
|
||||
assert!(
|
||||
tool_names.contains(&"getJobLogs"),
|
||||
"getJobLogs not found in MCP endpoint tools: {tool_names:?}"
|
||||
);
|
||||
|
||||
// Verify getJob has the expected path and method
|
||||
let get_job_tool = tools.iter().find(|t| t["name"] == "getJob").unwrap();
|
||||
assert_eq!(get_job_tool["path"], "/w/{workspace}/jobs_u/get/{id}");
|
||||
assert_eq!(get_job_tool["method"], "GET");
|
||||
|
||||
// Verify getJobLogs has the expected path and method
|
||||
let get_job_logs_tool = tools.iter().find(|t| t["name"] == "getJobLogs").unwrap();
|
||||
assert_eq!(
|
||||
get_job_logs_tool["path"],
|
||||
"/w/{workspace}/jobs_u/get_logs/{id}"
|
||||
);
|
||||
assert_eq!(get_job_logs_tool["method"], "GET");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
async fn insert_completed_job_with_logs(db: &Pool<Postgres>) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
|
||||
VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status)
|
||||
VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO job_logs (job_id, workspace_id, logs, log_offset)
|
||||
VALUES ($1, 'test-workspace', 'hello world test log', 0)",
|
||||
)
|
||||
.bind(id)
|
||||
.execute(db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
#[cfg(feature = "mcp")]
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_mcp_client_get_job_and_logs(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
use rmcp::model::{
|
||||
CallToolRequestParams, ClientCapabilities, ClientInfo, Implementation,
|
||||
InitializeRequestParams,
|
||||
};
|
||||
use rmcp::service::{RoleClient, RunningService};
|
||||
use rmcp::transport::streamable_http_client::{
|
||||
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
|
||||
};
|
||||
use rmcp::ServiceExt;
|
||||
|
||||
initialize_tracing().await;
|
||||
set_jwt_secret().await;
|
||||
let server = ApiServer::start_mcp(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
let job_id = insert_completed_job_with_logs(&db).await;
|
||||
|
||||
// Create a token with MCP scopes
|
||||
sqlx::query(
|
||||
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
|
||||
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Connect as MCP client
|
||||
let config = StreamableHttpClientTransportConfig::with_uri(format!(
|
||||
"http://localhost:{port}/api/mcp/w/test-workspace/mcp"
|
||||
))
|
||||
.auth_header("MCP_TOKEN");
|
||||
let transport = StreamableHttpClientTransport::from_config(config);
|
||||
|
||||
let client_info = ClientInfo {
|
||||
protocol_version: Default::default(),
|
||||
capabilities: ClientCapabilities::default(),
|
||||
client_info: Implementation {
|
||||
name: "test-client".to_string(),
|
||||
title: None,
|
||||
version: "0.0.1".to_string(),
|
||||
description: None,
|
||||
website_url: None,
|
||||
icons: None,
|
||||
},
|
||||
meta: None,
|
||||
};
|
||||
|
||||
let client: RunningService<RoleClient, InitializeRequestParams> =
|
||||
client_info.serve(transport).await?;
|
||||
|
||||
// --- Test getJob ---
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams {
|
||||
name: "getJob".into(),
|
||||
arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
task: None,
|
||||
meta: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let text = result
|
||||
.content
|
||||
.first()
|
||||
.and_then(|c| c.raw.as_text())
|
||||
.expect("getJob should return text content");
|
||||
let job: serde_json::Value = serde_json::from_str(&text.text)?;
|
||||
assert_eq!(job["id"], job_id.to_string());
|
||||
assert_eq!(job["workspace_id"], "test-workspace");
|
||||
assert_eq!(job["created_by"], "test-user");
|
||||
assert_eq!(job["job_kind"], "script");
|
||||
assert!(
|
||||
job["success"].as_bool().unwrap_or(false),
|
||||
"job should be successful: {job}"
|
||||
);
|
||||
|
||||
// --- Test getJobLogs ---
|
||||
let result = client
|
||||
.call_tool(CallToolRequestParams {
|
||||
name: "getJobLogs".into(),
|
||||
arguments: Some(serde_json::from_value(json!({ "id": job_id.to_string() }))?),
|
||||
task: None,
|
||||
meta: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let text = result
|
||||
.content
|
||||
.first()
|
||||
.and_then(|c| c.raw.as_text())
|
||||
.expect("getJobLogs should return text content");
|
||||
// The logs endpoint returns text/plain, which gets wrapped as a JSON string by call_endpoint
|
||||
let logs: String = serde_json::from_str(&text.text)?;
|
||||
assert!(
|
||||
logs.contains("hello world test log"),
|
||||
"expected logs to contain test log, got: {logs}"
|
||||
);
|
||||
|
||||
client.cancel().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -215,8 +215,8 @@ export async function main() {
|
||||
"scenario 1: secret value leaked in logs\nLogs:\n{logs1}"
|
||||
);
|
||||
assert!(
|
||||
logs1.contains("The secret value is: alp*****"),
|
||||
"scenario 1: expected masked output with first 3 chars\nLogs:\n{logs1}"
|
||||
logs1.contains("The secret value is: alp*****k2m"),
|
||||
"scenario 1: expected masked output with first 3 + last 3 chars\nLogs:\n{logs1}"
|
||||
);
|
||||
assert!(
|
||||
logs1.contains("[windmill] secret value was masked for security reasons, use string transformations to display full value"),
|
||||
@@ -277,11 +277,11 @@ export async function main() {
|
||||
"scenario 3: secret2 leaked\nLogs:\n{logs3}"
|
||||
);
|
||||
assert!(
|
||||
logs3.contains("secret1=alp*****"),
|
||||
logs3.contains("secret1=alp*****k2m"),
|
||||
"scenario 3: secret1 not masked\nLogs:\n{logs3}"
|
||||
);
|
||||
assert!(
|
||||
logs3.contains("secret2=bet*****"),
|
||||
logs3.contains("secret2=bet*****n3p"),
|
||||
"scenario 3: secret2 not masked\nLogs:\n{logs3}"
|
||||
);
|
||||
|
||||
@@ -309,7 +309,7 @@ export async function main() {
|
||||
"scenario 4: secret leaked mid-string\nLogs:\n{logs4}"
|
||||
);
|
||||
assert!(
|
||||
logs4.contains("token=alp*****&user=bob&format=json"),
|
||||
logs4.contains("token=alp*****k2m&user=bob&format=json"),
|
||||
"scenario 4: mid-string masking failed\nLogs:\n{logs4}"
|
||||
);
|
||||
|
||||
@@ -338,7 +338,7 @@ export async function main() {
|
||||
!logs5.contains(secret2),
|
||||
"scenario 5: secret leaked\nLogs:\n{logs5}"
|
||||
);
|
||||
let mask_count = logs5.matches("bet*****").count();
|
||||
let mask_count = logs5.matches("bet*****n3p").count();
|
||||
assert!(
|
||||
mask_count >= 3,
|
||||
"scenario 5: expected >= 3 masked occurrences, found {mask_count}\nLogs:\n{logs5}"
|
||||
@@ -374,7 +374,7 @@ export async function main() {
|
||||
"scenario 6: encrypted password leaked\nLogs:\n{logs6}"
|
||||
);
|
||||
assert!(
|
||||
logs6.contains("password is: enc*****"),
|
||||
logs6.contains("password is: enc*****q5r"),
|
||||
"scenario 6: encrypted password not masked\nLogs:\n{logs6}"
|
||||
);
|
||||
|
||||
@@ -403,7 +403,7 @@ export async function main() {
|
||||
"scenario 7: resource secret leaked\nLogs:\n{logs7}"
|
||||
);
|
||||
assert!(
|
||||
logs7.contains("db password: res*****"),
|
||||
logs7.contains("db password: res*****7t2"),
|
||||
"scenario 7: resource secret not masked\nLogs:\n{logs7}"
|
||||
);
|
||||
// Non-secret field should remain visible
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
//! Query builders for filtering job lists (queue and completed).
|
||||
|
||||
use serde_json;
|
||||
use sql_builder::prelude::*;
|
||||
use sql_builder::SqlBuilder;
|
||||
use windmill_common::utils::{escape_ilike_pattern, paginate_without_limits, Pagination};
|
||||
@@ -200,7 +201,11 @@ pub fn filter_list_queue_query(
|
||||
}
|
||||
|
||||
if let Some(args) = &lq.args {
|
||||
sqlb.and_where("args @> ?".bind(&args.replace("'", "''")));
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
|
||||
sqlb.and_where("args @> ?".bind(&v.to_string()));
|
||||
} else {
|
||||
sqlb.and_where("FALSE");
|
||||
}
|
||||
}
|
||||
|
||||
if lq.scheduled_for_before_now.is_some_and(|x| x) {
|
||||
@@ -499,11 +504,19 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
|
||||
if let Some(args) = &lq.args {
|
||||
sqlb.and_where("args @> ?".bind(&args.replace("'", "''")));
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
|
||||
sqlb.and_where("args @> ?".bind(&v.to_string()));
|
||||
} else {
|
||||
sqlb.and_where("FALSE");
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(result) = &lq.result {
|
||||
sqlb.and_where("result @> ?".bind(&result.replace("'", "''")));
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(result) {
|
||||
sqlb.and_where("result @> ?".bind(&v.to_string()));
|
||||
} else {
|
||||
sqlb.and_where("FALSE");
|
||||
}
|
||||
}
|
||||
|
||||
if lq.is_not_schedule.unwrap_or(false) {
|
||||
|
||||
@@ -658,7 +658,11 @@ async fn list_schedule(
|
||||
sqlb.and_where_eq("is_flow", "?".bind(&is_flow));
|
||||
}
|
||||
if let Some(args) = &lsq.args {
|
||||
sqlb.and_where("args @> ?".bind(&args.replace("'", "''")));
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(args) {
|
||||
sqlb.and_where("args @> ?".bind(&v.to_string()));
|
||||
} else {
|
||||
sqlb.and_where("FALSE");
|
||||
}
|
||||
}
|
||||
if let Some(path_start) = &lsq.path_start {
|
||||
sqlb.and_where_like_left("path", path_start);
|
||||
|
||||
@@ -820,6 +820,7 @@ async fn create_script_internal<'c>(
|
||||
|| ns.language == ScriptLang::Php
|
||||
|| ns.language == ScriptLang::Java
|
||||
|| ns.language == ScriptLang::Ruby
|
||||
|| ns.language == ScriptLang::Rlang
|
||||
// for related places search: ADD_NEW_LANG
|
||||
) {
|
||||
Some(String::new())
|
||||
|
||||
@@ -21,6 +21,7 @@ windmill-api-auth.workspace = true
|
||||
windmill-audit.workspace = true
|
||||
windmill-git-sync.workspace = true
|
||||
|
||||
dashmap.workspace = true
|
||||
argon2.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -12,6 +12,7 @@ use sqlx::{Postgres, Transaction};
|
||||
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
@@ -60,6 +61,43 @@ use windmill_git_sync::handle_deployment_metadata;
|
||||
|
||||
pub const COOKIE_PATH: &str = "/";
|
||||
|
||||
const TOKEN_CREATE_LIMIT_PER_MINUTE: i32 = 10;
|
||||
|
||||
struct TokenRateLimitEntry {
|
||||
count: i32,
|
||||
minute_bucket: i64,
|
||||
}
|
||||
|
||||
static TOKEN_CREATE_RATE_LIMIT: LazyLock<dashmap::DashMap<String, TokenRateLimitEntry>> =
|
||||
LazyLock::new(dashmap::DashMap::new);
|
||||
|
||||
fn check_token_create_rate_limit(username: &str) -> Result<()> {
|
||||
if !*CLOUD_HOSTED {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_minute = chrono::Utc::now().timestamp() / 60;
|
||||
|
||||
let mut entry = TOKEN_CREATE_RATE_LIMIT
|
||||
.entry(username.to_string())
|
||||
.or_insert(TokenRateLimitEntry { count: 0, minute_bucket: current_minute });
|
||||
|
||||
if entry.minute_bucket != current_minute {
|
||||
entry.count = 0;
|
||||
entry.minute_bucket = current_minute;
|
||||
}
|
||||
|
||||
if entry.count >= TOKEN_CREATE_LIMIT_PER_MINUTE {
|
||||
return Err(Error::Generic(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Too many token creation requests. Please try again later.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_users))
|
||||
@@ -1746,6 +1784,7 @@ async fn set_login_type(
|
||||
|
||||
#[allow(unreachable_code, unused_variables)]
|
||||
async fn login(
|
||||
headers: axum::http::HeaderMap,
|
||||
cookies: Cookies,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(argon2): Extension<Arc<Argon2<'_>>>,
|
||||
@@ -1756,8 +1795,10 @@ async fn login(
|
||||
return Ok("no_auth".to_string());
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let email = email.to_lowercase();
|
||||
windmill_common::login_rate_limit::check_and_increment_login_attempt(&headers, &email)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
let audit_author = AuditAuthor {
|
||||
email: email.clone(),
|
||||
username: email.clone(),
|
||||
@@ -1789,6 +1830,7 @@ async fn login(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
windmill_common::login_rate_limit::record_login_failure(&email);
|
||||
Err(Error::BadRequest("Invalid login".to_string()))
|
||||
} else {
|
||||
let token = create_session_token(&email, super_admin, &mut tx, cookies).await?;
|
||||
@@ -1825,6 +1867,7 @@ async fn login(
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
windmill_common::login_rate_limit::record_login_failure(&email);
|
||||
Err(Error::BadRequest("Invalid login".to_string()))
|
||||
}
|
||||
}
|
||||
@@ -1970,6 +2013,8 @@ async fn create_token(
|
||||
authed: ApiAuthed,
|
||||
Json(token_config): Json<NewToken>,
|
||||
) -> Result<(StatusCode, String)> {
|
||||
check_token_create_rate_limit(&authed.username)?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?;
|
||||
|
||||
@@ -1134,6 +1134,12 @@ async fn edit_webhook(
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
|
||||
if *CLOUD_HOSTED {
|
||||
return Err(Error::BadRequest(
|
||||
"Workspace webhooks are not available on cloud-hosted instances".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
if let Some(webhook) = &ew.webhook {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.668.2
|
||||
version: 1.672.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -10598,6 +10598,7 @@ paths:
|
||||
get:
|
||||
summary: get job
|
||||
operationId: getJob
|
||||
x-mcp-tool: true
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
@@ -10639,7 +10640,8 @@ paths:
|
||||
/w/{workspace}/jobs_u/get_logs/{id}:
|
||||
get:
|
||||
summary: get job logs
|
||||
operationId: getJob logs
|
||||
operationId: getJobLogs
|
||||
x-mcp-tool: true
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
@@ -20703,6 +20705,7 @@ components:
|
||||
nu,
|
||||
java,
|
||||
ruby,
|
||||
rlang,
|
||||
duckdb,
|
||||
bunnative,
|
||||
# for related places search: ADD_NEW_LANG
|
||||
@@ -21814,6 +21817,13 @@ components:
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
filter_logic:
|
||||
type: string
|
||||
enum:
|
||||
- and
|
||||
- or
|
||||
default: and
|
||||
description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match."
|
||||
initial_messages:
|
||||
type: array
|
||||
nullable: true
|
||||
@@ -21875,6 +21885,13 @@ components:
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
filter_logic:
|
||||
type: string
|
||||
enum:
|
||||
- and
|
||||
- or
|
||||
default: and
|
||||
description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match."
|
||||
initial_messages:
|
||||
type: array
|
||||
nullable: true
|
||||
@@ -21943,6 +21960,13 @@ components:
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
filter_logic:
|
||||
type: string
|
||||
enum:
|
||||
- and
|
||||
- or
|
||||
default: and
|
||||
description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match."
|
||||
initial_messages:
|
||||
type: array
|
||||
nullable: true
|
||||
@@ -22819,6 +22843,13 @@ components:
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
filter_logic:
|
||||
type: string
|
||||
enum:
|
||||
- and
|
||||
- or
|
||||
default: and
|
||||
description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match."
|
||||
auto_offset_reset:
|
||||
type: string
|
||||
enum:
|
||||
@@ -22890,6 +22921,13 @@ components:
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
filter_logic:
|
||||
type: string
|
||||
enum:
|
||||
- and
|
||||
- or
|
||||
default: and
|
||||
description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match."
|
||||
auto_offset_reset:
|
||||
type: string
|
||||
enum:
|
||||
@@ -22953,6 +22991,13 @@ components:
|
||||
required:
|
||||
- key
|
||||
- value
|
||||
filter_logic:
|
||||
type: string
|
||||
enum:
|
||||
- and
|
||||
- or
|
||||
default: and
|
||||
description: "Logic to apply when evaluating filters. 'and' requires all filters to match, 'or' requires any filter to match."
|
||||
auto_offset_reset:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
@@ -79,7 +79,7 @@ use windmill_common::{jwt, oauth2::HmacSha256, variables::get_workspace_key};
|
||||
#[cfg(feature = "parquet")]
|
||||
use windmill_types::s3::{S3Object, S3Permission};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
pub fn workspaced_service(raw_app_body_limit: usize) -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_apps))
|
||||
.route("/list_search", get(list_search_apps))
|
||||
@@ -95,10 +95,16 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get_data/v/{*id}", get(get_raw_app_data))
|
||||
.route("/exists/{*path}", get(exists_app))
|
||||
.route("/update/{*path}", post(update_app))
|
||||
.route("/update_raw/{*path}", post(update_app_raw))
|
||||
.route(
|
||||
"/update_raw/{*path}",
|
||||
post(update_app_raw).layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)),
|
||||
)
|
||||
.route("/delete/{*path}", delete(delete_app))
|
||||
.route("/create", post(create_app))
|
||||
.route("/create_raw", post(create_app_raw))
|
||||
.route(
|
||||
"/create_raw",
|
||||
post(create_app_raw).layer(axum::extract::DefaultBodyLimit::max(raw_app_body_limit)),
|
||||
)
|
||||
.route("/history/p/{*path}", get(get_app_history))
|
||||
.route("/get_latest_version/{*path}", get(get_latest_version))
|
||||
.route(
|
||||
@@ -1010,18 +1016,20 @@ macro_rules! process_app_multipart {
|
||||
let mut saved_app = None;
|
||||
let mut uploaded_js = false;
|
||||
|
||||
let request_size_limit_mb = *crate::REQUEST_SIZE_LIMIT.read().await / (1024 * 1024);
|
||||
let raw_app_limit_mb = request_size_limit_mb * 5;
|
||||
let mut multipart = $multipart;
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|e| Error::BadRequest(format!("failed to read multipart field: {e}")))?
|
||||
.map_err(|e| Error::BadRequest(format!("failed to read multipart field: {e}. Could be due to the request size limit for raw app bundles which is {raw_app_limit_mb}MB (adjustable in instance settings)")))?
|
||||
{
|
||||
let name = field
|
||||
.name()
|
||||
.ok_or_else(|| Error::BadRequest("multipart field missing name".to_string()))?
|
||||
.to_string();
|
||||
let data = field.bytes().await.map_err(|e| {
|
||||
Error::BadRequest(format!("failed to read multipart stream: {e}"))
|
||||
Error::BadRequest(format!("failed to read multipart stream: {e}. Could be due to the request size limit for raw app bundles which is {raw_app_limit_mb}MB (adjustable in instance settings)"))
|
||||
})?;
|
||||
if name == "app" {
|
||||
let app = serde_json::from_slice(&data).map_err(to_anyhow)?;
|
||||
|
||||
@@ -12,8 +12,6 @@ use serde::{Deserialize, Serialize};
|
||||
use windmill_common::error::JsonResult;
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use crate::health::get_pool_stats;
|
||||
use crate::health::PoolStats;
|
||||
use crate::utils::require_super_admin;
|
||||
|
||||
pub fn global_service() -> Router {
|
||||
@@ -81,8 +79,10 @@ pub struct LargeResultRow {
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ConnectionPoolInfo {
|
||||
pub pool: PoolStats,
|
||||
pub pg_max_connections: i64,
|
||||
pub pg_total_connections: i64,
|
||||
pub pg_active_connections: i64,
|
||||
pub pg_idle_connections: i64,
|
||||
pub status: HealthLevel,
|
||||
pub message: String,
|
||||
}
|
||||
@@ -307,16 +307,30 @@ async fn fetch_large_results(
|
||||
}
|
||||
|
||||
async fn fetch_connection_pool(db: &DB) -> windmill_common::error::Result<ConnectionPoolInfo> {
|
||||
let pool = get_pool_stats(db);
|
||||
let max_row = sqlx::query_scalar!(
|
||||
r#"SELECT setting::bigint as "max!" FROM pg_settings WHERE name = 'max_connections'"#
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
let active_row =
|
||||
sqlx::query!("SELECT COUNT(*) as cnt FROM pg_stat_activity WHERE state = 'active'")
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
let stats_row = sqlx::query!(
|
||||
r#"SELECT
|
||||
COUNT(*) as "total!",
|
||||
COUNT(*) FILTER (WHERE state = 'active') as "active!",
|
||||
COUNT(*) FILTER (WHERE state = 'idle') as "idle!"
|
||||
FROM pg_stat_activity
|
||||
WHERE backend_type = 'client backend'"#
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
let pg_active = active_row.cnt.unwrap_or(0);
|
||||
let utilization = if pool.max_connections > 0 {
|
||||
pool.size as f64 / pool.max_connections as f64
|
||||
let pg_max = max_row;
|
||||
let pg_total = stats_row.total;
|
||||
let pg_active = stats_row.active;
|
||||
let pg_idle = stats_row.idle;
|
||||
|
||||
let utilization = if pg_max > 0 {
|
||||
pg_total as f64 / pg_max as f64
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -325,50 +339,75 @@ async fn fetch_connection_pool(db: &DB) -> windmill_common::error::Result<Connec
|
||||
(
|
||||
HealthLevel::Green,
|
||||
format!(
|
||||
"Pool utilization: {:.0}% ({}/{})",
|
||||
"Connection utilization: {:.0}% ({}/{})",
|
||||
utilization * 100.0,
|
||||
pool.size,
|
||||
pool.max_connections
|
||||
pg_total,
|
||||
pg_max
|
||||
),
|
||||
)
|
||||
} else if utilization < 0.95 {
|
||||
(
|
||||
HealthLevel::Yellow,
|
||||
format!(
|
||||
"Pool utilization is high: {:.0}% ({}/{}). Consider increasing max_connections.",
|
||||
"Connection utilization is high: {:.0}% ({}/{}). Consider increasing max_connections.",
|
||||
utilization * 100.0,
|
||||
pool.size,
|
||||
pool.max_connections
|
||||
pg_total,
|
||||
pg_max
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
HealthLevel::Red,
|
||||
format!(
|
||||
"Pool near exhaustion: {:.0}% ({}/{}). Increase max_connections urgently.",
|
||||
"Connections near exhaustion: {:.0}% ({}/{}). Increase max_connections urgently.",
|
||||
utilization * 100.0,
|
||||
pool.size,
|
||||
pool.max_connections
|
||||
pg_total,
|
||||
pg_max
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
Ok(ConnectionPoolInfo { pool, pg_active_connections: pg_active, status, message })
|
||||
Ok(ConnectionPoolInfo {
|
||||
pg_max_connections: pg_max,
|
||||
pg_total_connections: pg_total,
|
||||
pg_active_connections: pg_active,
|
||||
pg_idle_connections: pg_idle,
|
||||
status,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
async fn fetch_table_maintenance(
|
||||
db: &DB,
|
||||
) -> windmill_common::error::Result<Vec<TableMaintenanceInfo>> {
|
||||
// Aggregate partitioned tables (e.g. audit_YYYYMMDD -> audit_partitioned)
|
||||
// while keeping non-partitioned tables as-is
|
||||
let rows = sqlx::query!(
|
||||
r#"SELECT
|
||||
schemaname || '.' || relname as "table_name!",
|
||||
COALESCE(n_live_tup, 0) as "live_tuples!",
|
||||
COALESCE(n_dead_tup, 0) as "dead_tuples!",
|
||||
last_autovacuum as "last_autovacuum",
|
||||
last_autoanalyze as "last_autoanalyze"
|
||||
FROM pg_stat_user_tables
|
||||
ORDER BY n_dead_tup DESC
|
||||
LIMIT 15"#
|
||||
table_name as "table_name!",
|
||||
SUM(live_tuples)::bigint as "live_tuples!",
|
||||
SUM(dead_tuples)::bigint as "dead_tuples!",
|
||||
MAX(last_autovacuum) as "last_autovacuum",
|
||||
MAX(last_autoanalyze) as "last_autoanalyze"
|
||||
FROM (
|
||||
SELECT
|
||||
CASE
|
||||
WHEN i.inhparent IS NOT NULL THEN schemaname || '.' || p.relname
|
||||
ELSE schemaname || '.' || s.relname
|
||||
END as table_name,
|
||||
COALESCE(n_live_tup, 0) as live_tuples,
|
||||
COALESCE(n_dead_tup, 0) as dead_tuples,
|
||||
last_autovacuum,
|
||||
last_autoanalyze
|
||||
FROM pg_stat_user_tables s
|
||||
LEFT JOIN pg_class c ON c.relname = s.relname AND c.relnamespace = (
|
||||
SELECT oid FROM pg_namespace WHERE nspname = s.schemaname
|
||||
)
|
||||
LEFT JOIN pg_inherits i ON i.inhrelid = c.oid
|
||||
LEFT JOIN pg_class p ON p.oid = i.inhparent
|
||||
) sub
|
||||
GROUP BY table_name
|
||||
ORDER BY SUM(dead_tuples) DESC"#
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
@@ -2179,13 +2179,25 @@ async fn list_jobs(
|
||||
pub async fn resume_suspended_flow_as_owner(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((_w_id, flow_id)): Path<(String, Uuid)>,
|
||||
Path((w_id, flow_id)): Path<(String, Uuid)>,
|
||||
QueryOrBody(value): QueryOrBody<serde_json::Value>,
|
||||
) -> error::Result<StatusCode> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?;
|
||||
|
||||
// Verify the job belongs to this workspace
|
||||
let job_workspace: Option<String> =
|
||||
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if job_workspace.as_deref() != Some(w_id.as_str()) {
|
||||
return Err(Error::NotFound(
|
||||
"Job not found in this workspace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let flow_path = flow.script_path.as_deref().unwrap_or_else(|| "");
|
||||
require_owner_of_path(&authed, flow_path)?;
|
||||
check_scopes(&authed, || format!("jobs:run:flows:{}", flow_path))?;
|
||||
@@ -2439,6 +2451,10 @@ struct ApprovalInfo {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
default_args: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
enums: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
approval_conditions: Option<ApprovalConditions>,
|
||||
can_approve: bool,
|
||||
user_auth_required: bool,
|
||||
@@ -2493,91 +2509,113 @@ async fn get_approval_info(
|
||||
let is_wac = row.workflow_as_code_status.is_some();
|
||||
|
||||
// Extract approval info based on WAC vs classic flow
|
||||
let (form_schema, description, approval_conditions, hide_cancel) = if is_wac {
|
||||
let approval_meta = row
|
||||
.workflow_as_code_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("_approval"));
|
||||
let form = approval_meta.and_then(|m| m.get("form").cloned());
|
||||
let ac = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("approval_conditions"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
|
||||
(form, None, ac, None)
|
||||
} else {
|
||||
let fs = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
|
||||
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
|
||||
let (form_schema, description, default_args, enums, approval_conditions, hide_cancel) =
|
||||
if is_wac {
|
||||
let approval_meta = row
|
||||
.workflow_as_code_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("_approval"));
|
||||
let form = approval_meta.and_then(|m| m.get("form").cloned());
|
||||
let default_args = approval_meta.and_then(|m| m.get("default_args").cloned());
|
||||
let enums = approval_meta.and_then(|m| m.get("enums").cloned());
|
||||
let description = approval_meta.and_then(|m| m.get("description").cloned());
|
||||
let ac = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("approval_conditions"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
|
||||
(form, description, default_args, enums, ac, None)
|
||||
} else {
|
||||
let fs = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
|
||||
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
|
||||
|
||||
// For classic flows, form/description come from the flow definition and step result
|
||||
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
|
||||
// For classic flows, form/description come from the flow definition and step result
|
||||
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
|
||||
|
||||
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
|
||||
// Try raw_flow on the job first, fall back to flow_version for deployed flows.
|
||||
let raw_flow: Option<FlowValue> = {
|
||||
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if let Some(v) = from_job {
|
||||
serde_json::from_value(v).ok()
|
||||
} else {
|
||||
// Deployed flow: fetch from flow_version using runnable_id
|
||||
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
|
||||
// Try raw_flow on the job first, fall back to flow_version for deployed flows,
|
||||
// then flow_node for graph-based branch/loop sub-flows.
|
||||
let raw_flow: Option<FlowValue> = {
|
||||
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
from_version.and_then(|v| serde_json::from_value(v).ok())
|
||||
}
|
||||
|
||||
if let Some(v) = from_job {
|
||||
serde_json::from_value(v).ok()
|
||||
} else {
|
||||
// Deployed flow: fetch from flow_version using runnable_id
|
||||
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
if let Some(v) = from_version {
|
||||
serde_json::from_value(v).ok()
|
||||
} else {
|
||||
// FlowNode sub-flow (graph-based branch/loop): raw_flow is not stored
|
||||
// in v2_job for newer versions, fetch from flow_node table
|
||||
let from_node: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT fn.flow FROM v2_job j \
|
||||
JOIN flow_node fn ON fn.id = j.runnable_id \
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
from_node.and_then(|v| serde_json::from_value(v).ok())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let suspend_module = raw_flow
|
||||
.as_ref()
|
||||
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
|
||||
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
|
||||
|
||||
let form = suspend_settings
|
||||
.and_then(|s| s.resume_form.as_ref())
|
||||
.map(|rf| serde_json::json!(rf));
|
||||
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
|
||||
|
||||
// Fetch description, default_args, and enums from the step's completed job result
|
||||
let step_job_id = fs
|
||||
.as_ref()
|
||||
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
|
||||
.and_then(|m| m.job());
|
||||
let (desc, default_args, enums) = if let Some(sjid) = step_job_id {
|
||||
let result: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(sjid)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
|
||||
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
|
||||
let enums = result.as_ref().and_then(|r| r.get("enums").cloned());
|
||||
(desc, da, enums)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
|
||||
(form, desc, default_args, enums, ac, hc)
|
||||
};
|
||||
|
||||
let suspend_module = raw_flow
|
||||
.as_ref()
|
||||
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
|
||||
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
|
||||
|
||||
let form = suspend_settings
|
||||
.and_then(|s| s.resume_form.as_ref())
|
||||
.map(|rf| serde_json::json!(rf));
|
||||
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
|
||||
|
||||
// Fetch description and default_args from the step's completed job result
|
||||
let step_job_id = fs
|
||||
.as_ref()
|
||||
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
|
||||
.and_then(|m| m.job());
|
||||
let (desc, _default_args) = if let Some(sjid) = step_job_id {
|
||||
let result: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(sjid)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
|
||||
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
|
||||
(desc, da)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
(form, desc, ac, hc)
|
||||
};
|
||||
|
||||
let user_auth_required = approval_conditions
|
||||
.as_ref()
|
||||
.map(|ac| ac.user_auth_required)
|
||||
@@ -2628,6 +2666,8 @@ async fn get_approval_info(
|
||||
flow_id: row.id,
|
||||
form_schema,
|
||||
description,
|
||||
default_args,
|
||||
enums,
|
||||
approval_conditions,
|
||||
can_approve,
|
||||
user_auth_required,
|
||||
|
||||
@@ -379,6 +379,8 @@ pub async fn run_server(
|
||||
REQUEST_SIZE_LIMIT.read().await.clone(),
|
||||
));
|
||||
|
||||
let request_size_limit = REQUEST_SIZE_LIMIT.read().await.clone();
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_methods([http::Method::GET, http::Method::POST, http::Method::DELETE])
|
||||
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
|
||||
@@ -475,17 +477,14 @@ pub async fn run_server(
|
||||
let (mcp_router, mcp_cancellation_token) =
|
||||
setup_mcp_server(db.clone(), user_db, _base_internal_url.clone()).await?;
|
||||
// Workspace-scoped MCP router
|
||||
// Use `layer` instead of `route_layer` because the MCP router only has
|
||||
// a fallback_service (no explicit routes), and axum 0.8 panics on
|
||||
// route_layer with no routes.
|
||||
let workspaced_mcp_router = mcp_router
|
||||
.clone()
|
||||
.layer(from_extractor::<ApiAuthed>())
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.layer(axum::middleware::from_fn(add_www_authenticate_header))
|
||||
.layer(axum::middleware::from_fn(extract_and_store_workspace_id));
|
||||
// Gateway MCP router — resolves workspace from token
|
||||
let gateway_mcp_router = mcp_router
|
||||
.layer(from_extractor::<ApiAuthed>())
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.layer(axum::middleware::from_fn(
|
||||
add_www_authenticate_header_gateway,
|
||||
))
|
||||
@@ -536,7 +535,7 @@ pub async fn run_server(
|
||||
Router::new()
|
||||
// Reordered alphabetically
|
||||
.nest("/acls", granular_acls::workspaced_service())
|
||||
.nest("/apps", apps::workspaced_service())
|
||||
.nest("/apps", apps::workspaced_service(request_size_limit * 5))
|
||||
.nest("/assets", windmill_api_assets::workspaced_service())
|
||||
.nest("/audit", audit::workspaced_service())
|
||||
.nest("/capture", capture::workspaced_service())
|
||||
|
||||
@@ -221,6 +221,22 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
"type": "string",
|
||||
"description": "filter variables by path prefix"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "exact path match filter"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "pattern match filter for description field (case-insensitive)"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "pattern match filter for non-secret variable values (case-insensitive)"
|
||||
},
|
||||
"broad_filter": {
|
||||
"type": "string",
|
||||
"description": "broad search across multiple fields (case-insensitive substring match)"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
"description": "which page to return (start at 1, default 1)"
|
||||
@@ -405,6 +421,22 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
"path_start": {
|
||||
"type": "string",
|
||||
"description": "filter resources by path prefix"
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "exact path match filter"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "pattern match filter for description field (case-insensitive)"
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "JSONB subset match filter using base64 encoded JSON"
|
||||
},
|
||||
"broad_filter": {
|
||||
"type": "string",
|
||||
"description": "broad search across multiple fields (case-insensitive substring match)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -451,7 +483,7 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"created_by": {
|
||||
"type": "string",
|
||||
"description": "mask to filter exact matching user creator"
|
||||
"description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')"
|
||||
},
|
||||
"path_start": {
|
||||
"type": "string",
|
||||
@@ -562,7 +594,6 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
"required": [
|
||||
"path",
|
||||
"summary",
|
||||
"description",
|
||||
"content",
|
||||
"language"
|
||||
]
|
||||
@@ -708,7 +739,7 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"created_by": {
|
||||
"type": "string",
|
||||
"description": "mask to filter exact matching user creator"
|
||||
"description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')"
|
||||
},
|
||||
"path_start": {
|
||||
"type": "string",
|
||||
@@ -1078,6 +1109,37 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"lock": {
|
||||
"type": "string"
|
||||
},
|
||||
"flow_path": {
|
||||
"type": "string"
|
||||
},
|
||||
"modules": {
|
||||
"type": "object",
|
||||
"nullable": true,
|
||||
"description": "Additional script modules keyed by relative file path",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"description": "An additional module file associated with a script",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The source code content of this module"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, duckdb, bunnative"
|
||||
},
|
||||
"lock": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Lock file content for this module's dependencies"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content",
|
||||
"language"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1106,7 +1168,7 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"created_by": {
|
||||
"type": "string",
|
||||
"description": "mask to filter exact matching user creator"
|
||||
"description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')"
|
||||
},
|
||||
"parent_job": {
|
||||
"type": "string",
|
||||
@@ -1115,15 +1177,15 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"worker": {
|
||||
"type": "string",
|
||||
"description": "worker this job was ran on"
|
||||
"description": "filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2')"
|
||||
},
|
||||
"script_path_exact": {
|
||||
"type": "string",
|
||||
"description": "mask to filter exact matching path"
|
||||
"description": "filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2')"
|
||||
},
|
||||
"script_path_start": {
|
||||
"type": "string",
|
||||
"description": "mask to filter matching starting path"
|
||||
"description": "filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2')"
|
||||
},
|
||||
"schedule_path": {
|
||||
"type": "string",
|
||||
@@ -1131,11 +1193,11 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"trigger_path": {
|
||||
"type": "string",
|
||||
"description": "mask to filter by trigger path"
|
||||
"description": "filter by trigger path. Supports comma-separated list (e.g. 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2')"
|
||||
},
|
||||
"trigger_kind": {
|
||||
"description": "trigger kind (schedule, http, websocket...). Possible values: webhook, default_email, email, schedule, http, websocket, postgres, kafka, nats, mqtt, sqs, gcp",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')"
|
||||
},
|
||||
"script_hash": {
|
||||
"type": "string",
|
||||
@@ -1161,7 +1223,7 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"job_kinds": {
|
||||
"type": "string",
|
||||
"description": "filter on job kind (values 'preview', 'script', 'dependencies', 'flow') separated by,"
|
||||
"description": "filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies')"
|
||||
},
|
||||
"suspended": {
|
||||
"type": "boolean",
|
||||
@@ -1185,7 +1247,7 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "filter on jobs with a given tag/worker group"
|
||||
"description": "filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem')"
|
||||
},
|
||||
"page": {
|
||||
"type": "integer",
|
||||
@@ -1223,15 +1285,15 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
"properties": {
|
||||
"created_by": {
|
||||
"type": "string",
|
||||
"description": "mask to filter exact matching user creator"
|
||||
"description": "filter by exact matching user creator. Supports comma-separated list (e.g. 'alice,bob') and negation by prefixing all values with '!' (e.g. '!alice,!bob')"
|
||||
},
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "mask to filter exact matching job's label (job labels are completed jobs with as a result an object containing a string in the array at key 'wm_labels')"
|
||||
"description": "filter by exact matching job label. Supports comma-separated list (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release')"
|
||||
},
|
||||
"worker": {
|
||||
"type": "string",
|
||||
"description": "worker this job was ran on"
|
||||
"description": "filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2')"
|
||||
},
|
||||
"parent_job": {
|
||||
"type": "string",
|
||||
@@ -1240,11 +1302,11 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"script_path_exact": {
|
||||
"type": "string",
|
||||
"description": "mask to filter exact matching path"
|
||||
"description": "filter by exact matching script path. Supports comma-separated list (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2')"
|
||||
},
|
||||
"script_path_start": {
|
||||
"type": "string",
|
||||
"description": "mask to filter matching starting path"
|
||||
"description": "filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2')"
|
||||
},
|
||||
"schedule_path": {
|
||||
"type": "string",
|
||||
@@ -1304,7 +1366,7 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"job_kinds": {
|
||||
"type": "string",
|
||||
"description": "filter on job kind (values 'preview', 'script', 'dependencies', 'flow') separated by,"
|
||||
"description": "filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies')"
|
||||
},
|
||||
"suspended": {
|
||||
"type": "boolean",
|
||||
@@ -1316,7 +1378,7 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "filter on jobs with a given tag/worker group"
|
||||
"description": "filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem')"
|
||||
},
|
||||
"result": {
|
||||
"type": "string",
|
||||
@@ -1331,8 +1393,8 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
"description": "number of items to return for a given page (default 30, max 100)"
|
||||
},
|
||||
"trigger_kind": {
|
||||
"description": "trigger kind (schedule, http, websocket...). Possible values: webhook, default_email, email, schedule, http, websocket, postgres, kafka, nats, mqtt, sqs, gcp",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "filter by trigger kind. Supports comma-separated list (e.g. 'schedule,webhook') and negation by prefixing all values with '!' (e.g. '!schedule,!webhook')"
|
||||
},
|
||||
"is_skipped": {
|
||||
"type": "boolean",
|
||||
@@ -1357,6 +1419,77 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
"is_not_schedule": {
|
||||
"type": "boolean",
|
||||
"description": "is not a scheduled job"
|
||||
},
|
||||
"broad_filter": {
|
||||
"type": "string",
|
||||
"description": "broad search across multiple fields (case-insensitive substring match on path, tag, schedule path, trigger kind, label)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
})),
|
||||
body_schema: None,
|
||||
path_field_renames: None,
|
||||
query_field_renames: None,
|
||||
body_field_renames: None,
|
||||
},
|
||||
EndpointTool {
|
||||
name: Cow::Borrowed("getJob"),
|
||||
description: Cow::Borrowed("get job"),
|
||||
instructions: Cow::Borrowed(""),
|
||||
path: Cow::Borrowed("/w/{workspace}/jobs_u/get/{id}"),
|
||||
method: Cow::Borrowed("GET"),
|
||||
path_params_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
]
|
||||
})),
|
||||
query_params_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"no_logs": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"no_code": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
})),
|
||||
body_schema: None,
|
||||
path_field_renames: None,
|
||||
query_field_renames: None,
|
||||
body_field_renames: None,
|
||||
},
|
||||
EndpointTool {
|
||||
name: Cow::Borrowed("getJobLogs"),
|
||||
description: Cow::Borrowed("get job logs"),
|
||||
instructions: Cow::Borrowed(""),
|
||||
path: Cow::Borrowed("/w/{workspace}/jobs_u/get_logs/{id}"),
|
||||
method: Cow::Borrowed("GET"),
|
||||
path_params_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
]
|
||||
})),
|
||||
query_params_schema: Some(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"remove_ansi_warnings": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
@@ -1411,14 +1544,17 @@ You should get the schema of the script or flow before creating the schedule to
|
||||
},
|
||||
"on_failure": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Path to a script or flow to run when the scheduled job fails"
|
||||
},
|
||||
"on_failure_times": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Number of consecutive failures before the on_failure handler is triggered (default 1)"
|
||||
},
|
||||
"on_failure_exact": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "If true, trigger on_failure handler only on exactly N failures, not on every failure after N"
|
||||
},
|
||||
"on_failure_extra_args": {
|
||||
@@ -1428,10 +1564,12 @@ You should get the schema of the script or flow before creating the schedule to
|
||||
},
|
||||
"on_recovery": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Path to a script or flow to run when the schedule recovers after failures"
|
||||
},
|
||||
"on_recovery_times": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Number of consecutive successes before the on_recovery handler is triggered (default 1)"
|
||||
},
|
||||
"on_recovery_extra_args": {
|
||||
@@ -1441,6 +1579,7 @@ You should get the schema of the script or flow before creating the schedule to
|
||||
},
|
||||
"on_success": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Path to a script or flow to run after each successful execution"
|
||||
},
|
||||
"on_success_extra_args": {
|
||||
@@ -1516,28 +1655,42 @@ You should get the schema of the script or flow before creating the schedule to
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Short summary describing the purpose of this schedule"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Detailed description of what this schedule does"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Worker tag to route jobs to specific worker groups"
|
||||
},
|
||||
"paused_until": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 datetime until which the schedule is paused. Schedule resumes automatically after this time"
|
||||
},
|
||||
"cron_version": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Cron parser version. Use 'v2' for extended syntax with additional features"
|
||||
},
|
||||
"dynamic_skip": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)"
|
||||
},
|
||||
"permissioned_as": {
|
||||
"type": "string",
|
||||
"description": "The user or group this schedule runs as. Used during deployment to preserve the original schedule owner."
|
||||
},
|
||||
"preserve_permissioned_as": {
|
||||
"type": "boolean",
|
||||
"description": "When true and the caller is a member of the 'wm_deployers' group, preserves the original permissioned_as value instead of overwriting it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1592,14 +1745,17 @@ You should get the schema of the script or flow before updating the schedule to
|
||||
},
|
||||
"on_failure": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Path to a script or flow to run when the scheduled job fails"
|
||||
},
|
||||
"on_failure_times": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Number of consecutive failures before the on_failure handler is triggered (default 1)"
|
||||
},
|
||||
"on_failure_exact": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "If true, trigger on_failure handler only on exactly N failures, not on every failure after N"
|
||||
},
|
||||
"on_failure_extra_args": {
|
||||
@@ -1609,10 +1765,12 @@ You should get the schema of the script or flow before updating the schedule to
|
||||
},
|
||||
"on_recovery": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Path to a script or flow to run when the schedule recovers after failures"
|
||||
},
|
||||
"on_recovery_times": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "Number of consecutive successes before the on_recovery handler is triggered (default 1)"
|
||||
},
|
||||
"on_recovery_extra_args": {
|
||||
@@ -1622,6 +1780,7 @@ You should get the schema of the script or flow before updating the schedule to
|
||||
},
|
||||
"on_success": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Path to a script or flow to run after each successful execution"
|
||||
},
|
||||
"on_success_extra_args": {
|
||||
@@ -1697,28 +1856,44 @@ You should get the schema of the script or flow before updating the schedule to
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Short summary describing the purpose of this schedule"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Detailed description of what this schedule does"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Worker tag to route jobs to specific worker groups"
|
||||
},
|
||||
"paused_until": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date-time",
|
||||
"description": "ISO 8601 datetime until which the schedule is paused. Schedule resumes automatically after this time"
|
||||
},
|
||||
"cron_version": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Cron parser version. Use 'v2' for extended syntax with additional features"
|
||||
},
|
||||
"dynamic_skip": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Path to a script that validates scheduled datetimes. Receives scheduled_for datetime and returns boolean to skip (true) or run (false)"
|
||||
},
|
||||
"permissioned_as": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "The user or group this schedule runs as (e.g., 'u/admin' or 'g/mygroup'). Only admins and wm_deployers can set this via preserve_permissioned_as."
|
||||
},
|
||||
"preserve_permissioned_as": {
|
||||
"type": "boolean",
|
||||
"nullable": true,
|
||||
"description": "If true and user is admin/wm_deployers, preserve the provided permissioned_as instead of using the deploying user's identity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1801,7 +1976,7 @@ You should get the schema of the script or flow before updating the schedule to
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "filter by path"
|
||||
"description": "filter by path (script path)"
|
||||
},
|
||||
"is_flow": {
|
||||
"type": "boolean",
|
||||
@@ -1810,6 +1985,22 @@ You should get the schema of the script or flow before updating the schedule to
|
||||
"path_start": {
|
||||
"type": "string",
|
||||
"description": "filter schedules by path prefix"
|
||||
},
|
||||
"schedule_path": {
|
||||
"type": "string",
|
||||
"description": "exact match on the schedule's path"
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "pattern match filter for description field (case-insensitive)"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "pattern match filter for summary field (case-insensitive)"
|
||||
},
|
||||
"broad_filter": {
|
||||
"type": "string",
|
||||
"description": "broad search across multiple fields (case-insensitive substring match)"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
@@ -546,7 +546,7 @@ pub async fn setup_mcp_server(
|
||||
let service =
|
||||
StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config);
|
||||
|
||||
let router = Router::new().fallback_service(service);
|
||||
let router = Router::new().route_service("/", service);
|
||||
Ok((router, cancellation_token))
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,9 @@ async fn get_log_file(
|
||||
|
||||
require_devops_role(&db, &email).await?;
|
||||
let path = path.to_path();
|
||||
if path.contains("..") {
|
||||
return Err(Error::BadRequest("Invalid path".to_string()));
|
||||
}
|
||||
#[cfg(feature = "parquet")]
|
||||
let s3_client = windmill_object_store::get_object_store().await;
|
||||
#[cfg(feature = "parquet")]
|
||||
|
||||
@@ -484,6 +484,7 @@ pub(crate) async fn tarball_workspace(
|
||||
ScriptLang::OracleDB => "odb.sql",
|
||||
ScriptLang::Java => "java",
|
||||
ScriptLang::Ruby => "rb",
|
||||
ScriptLang::Rlang => "r",
|
||||
// for related places search: ADD_NEW_LANG
|
||||
};
|
||||
archive
|
||||
|
||||
@@ -117,6 +117,7 @@ pin-project-lite.workspace = true
|
||||
futures.workspace = true
|
||||
tempfile.workspace = true
|
||||
globset.workspace = true
|
||||
dashmap.workspace = true
|
||||
|
||||
opentelemetry-semantic-conventions = { workspace = true, optional = true }
|
||||
opentelemetry-otlp = { workspace = true, optional = true }
|
||||
|
||||
@@ -1231,3 +1231,75 @@ const _: () = {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn flow_data_extras_preserves_notes_and_groups() {
|
||||
let raw = serde_json::value::to_raw_value(&json!({
|
||||
"modules": [],
|
||||
"notes": [{"id": "n1", "text": "hello", "color": "blue", "type": "group",
|
||||
"contained_node_ids": ["a", "b"], "locked": false}],
|
||||
"groups": [{"start_id": "a", "end_id": "b", "summary": "grp", "color": "green"}]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let data = FlowData::from_raw(raw).unwrap();
|
||||
|
||||
// FlowValue ignores notes/groups
|
||||
assert!(data.value().modules.is_empty());
|
||||
|
||||
// But extras() recovers them from the raw JSON
|
||||
let extras = data.extras().expect("extras should parse");
|
||||
let notes: serde_json::Value =
|
||||
serde_json::from_str(extras.notes.expect("notes present").get()).unwrap();
|
||||
assert_eq!(notes.as_array().unwrap().len(), 1);
|
||||
assert_eq!(notes[0]["id"], "n1");
|
||||
assert_eq!(notes[0]["color"], "blue");
|
||||
|
||||
let groups: serde_json::Value =
|
||||
serde_json::from_str(extras.groups.expect("groups present").get()).unwrap();
|
||||
assert_eq!(groups.as_array().unwrap().len(), 1);
|
||||
assert_eq!(groups[0]["start_id"], "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_data_extras_returns_none_when_missing() {
|
||||
let raw = serde_json::value::to_raw_value(&json!({"modules": []})).unwrap();
|
||||
let data = FlowData::from_raw(raw).unwrap();
|
||||
|
||||
let extras = data
|
||||
.extras()
|
||||
.expect("extras should parse even without notes/groups");
|
||||
assert!(extras.notes.is_none());
|
||||
assert!(extras.groups.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flow_data_extras_lost_after_flow_value_roundtrip() {
|
||||
// Demonstrates the bug: serializing through FlowValue drops notes/groups.
|
||||
// This is the root cause of #8641.
|
||||
let raw = serde_json::value::to_raw_value(&json!({
|
||||
"modules": [],
|
||||
"notes": [{"id": "n1", "text": "t", "color": "blue", "type": "free"}]
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let data = FlowData::from_raw(raw).unwrap();
|
||||
|
||||
// Re-serialize through FlowValue (what RunFlowDependenciesRequest does)
|
||||
let stripped = serde_json::to_string(data.value()).unwrap();
|
||||
let stripped_raw = RawValue::from_string(stripped).unwrap();
|
||||
let data2 = FlowData::from_raw(stripped_raw).unwrap();
|
||||
|
||||
// Notes are gone after the FlowValue round-trip
|
||||
let extras = data2.extras().expect("extras should parse");
|
||||
assert!(
|
||||
extras.notes.is_none(),
|
||||
"notes lost after FlowValue round-trip"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
pub use windmill_types::flows::*;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use sqlx::types::Json;
|
||||
use sqlx::types::JsonRawValue;
|
||||
@@ -15,10 +17,89 @@ use sqlx::types::JsonRawValue;
|
||||
use crate::{
|
||||
cache::{self, FlowExtras},
|
||||
db::DB,
|
||||
error::Error,
|
||||
error::{to_anyhow, Error},
|
||||
utils::{http_get_from_hub, StripPath},
|
||||
worker::{to_raw_value, Connection},
|
||||
DEFAULT_HUB_BASE_URL, HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION,
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct HubFlow {
|
||||
pub value: FlowValue,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HubFlowResponse {
|
||||
flow: HubFlow,
|
||||
}
|
||||
|
||||
fn extract_hub_flow_id_from_path(path: &str) -> Result<i32, Error> {
|
||||
let hub_flow_path = path.strip_prefix("hub/flows/").ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
"expected hub flow path to start with hub/flows/ (got {path})"
|
||||
))
|
||||
})?;
|
||||
|
||||
let flow_id = hub_flow_path
|
||||
.split('/')
|
||||
.next()
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!(
|
||||
"expected hub flow path to include a numeric id after hub/flows/ (got {path})"
|
||||
))
|
||||
})?;
|
||||
|
||||
let flow_id = flow_id.parse::<i32>().map_err(|_| {
|
||||
Error::BadRequest(format!(
|
||||
"expected hub flow path to include a numeric id after hub/flows/ (got {path})"
|
||||
))
|
||||
})?;
|
||||
|
||||
if flow_id <= 0 {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"expected hub flow path to include a positive numeric id after hub/flows/ (got {path})"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(flow_id)
|
||||
}
|
||||
|
||||
pub async fn get_full_hub_flow_by_path(
|
||||
path: StripPath,
|
||||
http_client: &reqwest::Client,
|
||||
db: Option<&DB>,
|
||||
) -> crate::error::Result<HubFlow> {
|
||||
let path = path.to_path();
|
||||
let flow_id = extract_hub_flow_id_from_path(&path)?;
|
||||
let hub_base_url = HUB_BASE_URL.read().await.clone();
|
||||
let hub_url = format!("{hub_base_url}/flows/{flow_id}/json");
|
||||
|
||||
let response = match http_get_from_hub(http_client, &hub_url, false, None, db)
|
||||
.await?
|
||||
.error_for_status()
|
||||
.map_err(to_anyhow)
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) if hub_base_url != DEFAULT_HUB_BASE_URL && flow_id < PRIVATE_HUB_MIN_VERSION =>
|
||||
{
|
||||
tracing::info!("Not found on private hub, fallback to default hub for hub flow {path}");
|
||||
let fallback_url = format!("{DEFAULT_HUB_BASE_URL}/flows/{flow_id}/json");
|
||||
http_get_from_hub(http_client, &fallback_url, false, None, db)
|
||||
.await?
|
||||
.error_for_status()
|
||||
.map_err(to_anyhow)?
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
Ok(response
|
||||
.json::<HubFlowResponse>()
|
||||
.await
|
||||
.context(format!("Decoding hub response for flow at path {path}"))?
|
||||
.flow)
|
||||
}
|
||||
|
||||
/// Serialize-only wrapper that combines resolved FlowValue with display-only extras.
|
||||
/// flatten + RawValue is fine for serialization (only deserialization breaks).
|
||||
#[derive(Serialize)]
|
||||
@@ -228,4 +309,36 @@ mod tests {
|
||||
assert!(!output.contains("notes"));
|
||||
assert!(!output.contains("groups"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_hub_flow_id_accepts_id_only_paths() {
|
||||
assert_eq!(extract_hub_flow_id_from_path("hub/flows/76").unwrap(), 76);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_hub_flow_id_accepts_id_and_slug_paths() {
|
||||
assert_eq!(
|
||||
extract_hub_flow_id_from_path("hub/flows/76/send-message-to-company-ai-assistant")
|
||||
.unwrap(),
|
||||
76
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_hub_flow_id_rejects_non_numeric_ids() {
|
||||
let err = extract_hub_flow_id_from_path("hub/flows/send_message").unwrap_err();
|
||||
assert!(matches!(err, Error::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_hub_flow_id_rejects_missing_ids() {
|
||||
let err = extract_hub_flow_id_from_path("hub/flows/").unwrap_err();
|
||||
assert!(matches!(err, Error::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_hub_flow_id_rejects_zero_ids() {
|
||||
let err = extract_hub_flow_id_from_path("hub/flows/0").unwrap_err();
|
||||
assert!(matches!(err, Error::BadRequest(_)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub const CUSTOM_TAGS_SETTING: &str = "custom_tags";
|
||||
pub const DEFAULT_TAGS_PER_WORKSPACE_SETTING: &str = "default_tags_per_workspace";
|
||||
pub const DEFAULT_TAGS_WORKSPACES_SETTING: &str = "default_tags_workspaces";
|
||||
pub const PREVIEW_TAGS_OVERRIDE_SETTING: &str = "preview_tags_override";
|
||||
pub const BASE_URL_SETTING: &str = "base_url";
|
||||
pub const WS_BASE_URL_SETTING: &str = "ws_base_url";
|
||||
pub const OAUTH_SETTING: &str = "oauths";
|
||||
@@ -99,6 +100,7 @@ pub const ENV_SETTINGS: &[&str] = &[
|
||||
"BUNDLE_PATH",
|
||||
"GEM_PATH",
|
||||
"RUBY_CONCURRENT_DOWNLOADS",
|
||||
"RSCRIPT_PATH",
|
||||
// for related places search: ADD_NEW_LANG
|
||||
"GOPRIVATE",
|
||||
"GOPROXY",
|
||||
|
||||
@@ -243,6 +243,8 @@ pub struct GlobalSettings {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_tags_per_workspace: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub preview_tags_override: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_hub: Option<bool>,
|
||||
|
||||
// String settings
|
||||
@@ -595,6 +597,7 @@ pub enum ScriptLang {
|
||||
Nu,
|
||||
Java,
|
||||
Ruby,
|
||||
Rlang,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::{
|
||||
client::AuthedClient,
|
||||
db::{AuthedRef, UserDbWithAuthed, DB},
|
||||
error::{self, to_anyhow, Error},
|
||||
flows::get_full_hub_flow_by_path,
|
||||
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
|
||||
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
|
||||
users::username_to_permissioned_as,
|
||||
@@ -154,15 +155,31 @@ pub async fn get_payload_tag_from_prefixed_path(
|
||||
.await?
|
||||
} else if path.starts_with("flow/") {
|
||||
let path = path.strip_prefix("flow/").unwrap().to_string();
|
||||
let FlowVersionInfo { dedicated_worker, tag, version, .. } =
|
||||
get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?;
|
||||
(
|
||||
JobPayload::Flow { path, dedicated_worker, apply_preprocessor: false, version },
|
||||
tag,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
if path.starts_with("hub/flows/") {
|
||||
let hub_flow =
|
||||
get_full_hub_flow_by_path(StripPath(path.clone()), &HTTP_CLIENT, Some(db)).await?;
|
||||
(
|
||||
JobPayload::RawFlow {
|
||||
value: hub_flow.value,
|
||||
path: Some(path),
|
||||
restarted_from: None,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let FlowVersionInfo { dedicated_worker, tag, version, .. } =
|
||||
get_latest_flow_version_info_for_path(None, &db, w_id, &path, true).await?;
|
||||
(
|
||||
JobPayload::Flow { path, dedicated_worker, apply_preprocessor: false, version },
|
||||
tag,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"path must start with script/ or flow/ (got {})",
|
||||
|
||||
@@ -69,6 +69,7 @@ pub mod git_sync_ee;
|
||||
pub mod git_sync_oss;
|
||||
pub mod jobs;
|
||||
pub mod jwt;
|
||||
pub mod login_rate_limit;
|
||||
pub mod more_serde;
|
||||
pub mod oauth2;
|
||||
#[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
|
||||
|
||||
206
backend/windmill-common/src/login_rate_limit.rs
Normal file
206
backend/windmill-common/src/login_rate_limit.rs
Normal file
@@ -0,0 +1,206 @@
|
||||
use chrono::Utc;
|
||||
use dashmap::DashMap;
|
||||
use hyper::StatusCode;
|
||||
use std::sync::atomic::{AtomicI32, AtomicI64, AtomicU64, Ordering};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::worker::CLOUD_HOSTED;
|
||||
|
||||
const DEFAULT_PER_IP_LIMIT: i32 = 120;
|
||||
const DEFAULT_PER_ACCOUNT_LIMIT: i32 = 30;
|
||||
const DEFAULT_GLOBAL_LIMIT: i32 = 10000;
|
||||
const EVICTION_INTERVAL: u64 = 256;
|
||||
|
||||
struct RateLimitEntry {
|
||||
count: i32,
|
||||
minute_bucket: i64,
|
||||
}
|
||||
|
||||
static IP_RATE_LIMIT: LazyLock<DashMap<String, RateLimitEntry>> = LazyLock::new(DashMap::new);
|
||||
static ACCOUNT_RATE_LIMIT: LazyLock<DashMap<String, RateLimitEntry>> = LazyLock::new(DashMap::new);
|
||||
|
||||
static GLOBAL_COUNT: AtomicI32 = AtomicI32::new(0);
|
||||
static GLOBAL_MINUTE: AtomicI64 = AtomicI64::new(0);
|
||||
|
||||
static EVICTION_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
static PER_IP_LIMIT: LazyLock<i32> = LazyLock::new(|| {
|
||||
std::env::var("LOGIN_RATE_LIMIT_PER_IP")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_PER_IP_LIMIT)
|
||||
});
|
||||
|
||||
static PER_IP_LIMIT_EXPLICIT: LazyLock<bool> = LazyLock::new(|| {
|
||||
std::env::var("LOGIN_RATE_LIMIT_PER_IP")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.is_some()
|
||||
});
|
||||
|
||||
static PER_ACCOUNT_LIMIT: LazyLock<i32> = LazyLock::new(|| {
|
||||
std::env::var("LOGIN_RATE_LIMIT_PER_ACCOUNT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_PER_ACCOUNT_LIMIT)
|
||||
});
|
||||
|
||||
static PER_ACCOUNT_LIMIT_EXPLICIT: LazyLock<bool> = LazyLock::new(|| {
|
||||
std::env::var("LOGIN_RATE_LIMIT_PER_ACCOUNT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<i32>().ok())
|
||||
.is_some()
|
||||
});
|
||||
|
||||
static GLOBAL_LIMIT: LazyLock<i32> = LazyLock::new(|| {
|
||||
std::env::var("LOGIN_RATE_LIMIT_GLOBAL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(DEFAULT_GLOBAL_LIMIT)
|
||||
});
|
||||
|
||||
/// Extract client IP from proxy headers. Only meaningful when behind a trusted
|
||||
/// reverse proxy (e.g. CLOUD_HOSTED). Returns `None` if no proxy header is present.
|
||||
pub fn extract_client_ip(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
if let Some(real_ip) = headers.get("x-real-ip") {
|
||||
if let Ok(ip) = real_ip.to_str() {
|
||||
let trimmed = ip.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(forwarded_for) = headers.get("x-forwarded-for") {
|
||||
if let Ok(ips) = forwarded_for.to_str() {
|
||||
if let Some(first_ip) = ips.split(',').next() {
|
||||
let trimmed = first_ip.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn maybe_evict(maps: &[&DashMap<String, RateLimitEntry>], current_minute: i64) {
|
||||
let count = EVICTION_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
if count % EVICTION_INTERVAL == 0 {
|
||||
for map in maps {
|
||||
map.retain(|_, v| v.minute_bucket >= current_minute - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomically check the rate limit and increment the counter. Follows the
|
||||
/// `public_app_rate_limit.rs` pattern — the DashMap entry lock is held across
|
||||
/// both the check and the increment, preventing TOCTOU races.
|
||||
fn check_and_increment(
|
||||
map: &DashMap<String, RateLimitEntry>,
|
||||
key: &str,
|
||||
limit: i32,
|
||||
current_minute: i64,
|
||||
) -> Result<()> {
|
||||
let mut entry = map
|
||||
.entry(key.to_string())
|
||||
.or_insert(RateLimitEntry { count: 0, minute_bucket: current_minute });
|
||||
|
||||
if entry.minute_bucket != current_minute {
|
||||
entry.count = 0;
|
||||
entry.minute_bucket = current_minute;
|
||||
}
|
||||
|
||||
if entry.count >= limit {
|
||||
return Err(Error::Generic(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Too many login attempts. Please try again later.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
entry.count += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_failure(map: &DashMap<String, RateLimitEntry>, key: &str) {
|
||||
let current_minute = Utc::now().timestamp() / 60;
|
||||
|
||||
let mut entry = map
|
||||
.entry(key.to_string())
|
||||
.or_insert(RateLimitEntry { count: 0, minute_bucket: current_minute });
|
||||
|
||||
if entry.minute_bucket != current_minute {
|
||||
entry.count = 1;
|
||||
entry.minute_bucket = current_minute;
|
||||
} else {
|
||||
entry.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Called BEFORE authentication. Checks and increments global + per-IP counters.
|
||||
/// The global counter counts all login attempts (not just failures), so it acts as
|
||||
/// a general throttle on login traffic per server instance.
|
||||
/// Per-IP is only active on CLOUD_HOSTED or when LOGIN_RATE_LIMIT_PER_IP is explicitly set.
|
||||
pub fn check_and_increment_login_attempt(
|
||||
headers: &axum::http::HeaderMap,
|
||||
email: &str,
|
||||
) -> Result<()> {
|
||||
let current_minute = Utc::now().timestamp() / 60;
|
||||
maybe_evict(&[&IP_RATE_LIMIT, &ACCOUNT_RATE_LIMIT], current_minute);
|
||||
|
||||
// Global limit: always on, uses atomics (single key, no need for DashMap)
|
||||
check_and_increment_global(current_minute)?;
|
||||
|
||||
// Per-IP limit: CLOUD_HOSTED or explicit opt-in
|
||||
if *CLOUD_HOSTED || *PER_IP_LIMIT_EXPLICIT {
|
||||
if let Some(ip) = extract_client_ip(headers) {
|
||||
check_and_increment(&IP_RATE_LIMIT, &ip, *PER_IP_LIMIT, current_minute)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-account check (read-only, does not increment — failures are recorded separately)
|
||||
if *CLOUD_HOSTED || *PER_ACCOUNT_LIMIT_EXPLICIT {
|
||||
let entry = ACCOUNT_RATE_LIMIT.get(email);
|
||||
if let Some(entry) = entry {
|
||||
if entry.minute_bucket == current_minute && entry.count >= *PER_ACCOUNT_LIMIT {
|
||||
return Err(Error::Generic(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Too many login attempts. Please try again later.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_and_increment_global(current_minute: i64) -> Result<()> {
|
||||
let stored_minute = GLOBAL_MINUTE.load(Ordering::Relaxed);
|
||||
if stored_minute != current_minute {
|
||||
// Minute rolled over — reset. Race here is benign: worst case two threads
|
||||
// both reset, and we lose a few counts at the boundary.
|
||||
GLOBAL_MINUTE.store(current_minute, Ordering::Relaxed);
|
||||
GLOBAL_COUNT.store(1, Ordering::Relaxed);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let count = GLOBAL_COUNT.fetch_add(1, Ordering::Relaxed);
|
||||
if count >= *GLOBAL_LIMIT {
|
||||
return Err(Error::Generic(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"Too many login attempts. Please try again later.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Called AFTER authentication failure. Records per-account failure.
|
||||
/// Per-account is only active on CLOUD_HOSTED or when LOGIN_RATE_LIMIT_PER_ACCOUNT is explicitly set.
|
||||
pub fn record_login_failure(email: &str) {
|
||||
if *CLOUD_HOSTED || *PER_ACCOUNT_LIMIT_EXPLICIT {
|
||||
record_failure(&ACCOUNT_RATE_LIMIT, email);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,14 @@ fn deserialize_string_from_null<'de, D>(deserializer: D) -> Result<String, D::Er
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(deserializer).map(|v| v.unwrap_or_default())
|
||||
// DuckDB may return booleans for fields that other databases return as strings
|
||||
let v = serde_json::Value::deserialize(deserializer)?;
|
||||
match v {
|
||||
serde_json::Value::Null => Ok(String::new()),
|
||||
serde_json::Value::String(s) => Ok(s),
|
||||
serde_json::Value::Bool(b) => Ok(b.to_string()),
|
||||
other => Ok(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_column_identity_from_null<'de, D>(
|
||||
@@ -49,15 +56,21 @@ where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
// MySQL returns uppercase "YES"/"NO" while the enum expects title case.
|
||||
let v = Option::<String>::deserialize(deserializer)?;
|
||||
match v.as_deref() {
|
||||
None => Ok(ColumnIdentity::default()),
|
||||
Some(s) => match s.to_lowercase().as_str() {
|
||||
// DuckDB returns a boolean false instead of a string.
|
||||
let v = serde_json::Value::deserialize(deserializer)?;
|
||||
match v {
|
||||
serde_json::Value::Null => Ok(ColumnIdentity::default()),
|
||||
serde_json::Value::Bool(_) => Ok(ColumnIdentity::No),
|
||||
serde_json::Value::String(s) => match s.to_lowercase().as_str() {
|
||||
"no" => Ok(ColumnIdentity::No),
|
||||
"yes" | "always" => Ok(ColumnIdentity::Always),
|
||||
"by default" => Ok(ColumnIdentity::ByDefault),
|
||||
_ => Ok(ColumnIdentity::No),
|
||||
},
|
||||
_ => Err(serde::de::Error::custom(format!(
|
||||
"expected string, bool, or null for isidentity, got {}",
|
||||
v
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2369,7 +2382,7 @@ fn make_load_table_metadata_query(
|
||||
COLUMN_DEFAULT as DefaultValue,
|
||||
false as IsPrimaryKey,
|
||||
false as IsIdentity,
|
||||
IS_NULLABLE as IsNullable,
|
||||
CASE WHEN IS_NULLABLE = true THEN 'YES' ELSE 'NO' END as IsNullable,
|
||||
false as IsEnum,
|
||||
TABLE_NAME as table_name
|
||||
FROM information_schema.columns c
|
||||
|
||||
@@ -88,8 +88,16 @@ pub fn snapshot(job_id: &Uuid) -> Option<MaskSnapshot> {
|
||||
let replacements: Vec<String> = sorted
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let prefix: String = s.chars().take(3).collect();
|
||||
format!("{}*****", prefix)
|
||||
let char_count = s.chars().count();
|
||||
if char_count > 20 {
|
||||
let prefix: String = s.chars().take(3).collect();
|
||||
let suffix: String = s.chars().skip(char_count - 3).collect();
|
||||
format!("{}*****{}", prefix, suffix)
|
||||
} else {
|
||||
let first: String = s.chars().take(1).collect();
|
||||
let last: String = s.chars().skip(char_count - 1).collect();
|
||||
format!("{}*****{}", first, last)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -81,6 +81,46 @@ pub async fn get_email_from_permissioned_as(
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the highest-precedence workspace role for a user across all their instance groups.
|
||||
///
|
||||
/// Precedence: admin (3) > developer (2) > operator (1).
|
||||
/// Returns `(best_group_name, is_admin, is_operator)`.
|
||||
pub fn compute_highest_workspace_role(
|
||||
user_igroups: &[String],
|
||||
ws_configured_groups: &[String],
|
||||
ws_roles: &std::collections::HashMap<String, String>,
|
||||
) -> (String, bool, bool) {
|
||||
let mut best_group = String::new();
|
||||
let mut best_precedence = 0u8;
|
||||
|
||||
for group in user_igroups {
|
||||
if !ws_configured_groups.contains(group) {
|
||||
continue;
|
||||
}
|
||||
let default_role = "developer".to_string();
|
||||
let role = ws_roles.get(group).unwrap_or(&default_role);
|
||||
let precedence = match role.as_str() {
|
||||
"admin" => 3u8,
|
||||
"operator" => 1,
|
||||
_ => 2,
|
||||
};
|
||||
if precedence > best_precedence {
|
||||
best_precedence = precedence;
|
||||
best_group = group.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let default_role = "developer".to_string();
|
||||
let best_role_str = ws_roles.get(&best_group).unwrap_or(&default_role);
|
||||
let (is_admin, is_operator) = match best_role_str.as_str() {
|
||||
"admin" => (true, false),
|
||||
"operator" => (false, true),
|
||||
_ => (false, false),
|
||||
};
|
||||
|
||||
(best_group, is_admin, is_operator)
|
||||
}
|
||||
|
||||
pub fn truncate_token(token: &str) -> String {
|
||||
if token.len() > 10 {
|
||||
let mut s = token[..10].to_owned();
|
||||
@@ -105,4 +145,63 @@ mod tests {
|
||||
assert_eq!(username_to_permissioned_as("group-all"), "g/all");
|
||||
assert_eq!(username_to_permissioned_as("group-my-team"), "g/my-team");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_highest_workspace_role_admin_wins() {
|
||||
let user_groups = vec!["ops".to_string(), "admins".to_string()];
|
||||
let ws_groups = vec!["ops".to_string(), "admins".to_string()];
|
||||
let mut roles = std::collections::HashMap::new();
|
||||
roles.insert("ops".to_string(), "operator".to_string());
|
||||
roles.insert("admins".to_string(), "admin".to_string());
|
||||
|
||||
let (group, is_admin, is_operator) =
|
||||
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
|
||||
assert_eq!(group, "admins");
|
||||
assert!(is_admin);
|
||||
assert!(!is_operator);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_highest_workspace_role_developer_over_operator() {
|
||||
let user_groups = vec!["devs".to_string(), "ops".to_string()];
|
||||
let ws_groups = vec!["devs".to_string(), "ops".to_string()];
|
||||
let mut roles = std::collections::HashMap::new();
|
||||
roles.insert("devs".to_string(), "developer".to_string());
|
||||
roles.insert("ops".to_string(), "operator".to_string());
|
||||
|
||||
let (group, is_admin, is_operator) =
|
||||
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
|
||||
assert_eq!(group, "devs");
|
||||
assert!(!is_admin);
|
||||
assert!(!is_operator);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_highest_workspace_role_skips_unconfigured_groups() {
|
||||
let user_groups = vec!["admins".to_string(), "other".to_string()];
|
||||
let ws_groups = vec!["ops".to_string()]; // admins not configured for this workspace
|
||||
let mut roles = std::collections::HashMap::new();
|
||||
roles.insert("admins".to_string(), "admin".to_string());
|
||||
roles.insert("ops".to_string(), "operator".to_string());
|
||||
|
||||
let (group, is_admin, is_operator) =
|
||||
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
|
||||
// No user groups match ws_configured_groups, so best_group stays empty
|
||||
assert_eq!(group, "");
|
||||
assert!(!is_admin);
|
||||
assert!(!is_operator);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_highest_workspace_role_defaults_to_developer() {
|
||||
let user_groups = vec!["team".to_string()];
|
||||
let ws_groups = vec!["team".to_string()];
|
||||
let roles = std::collections::HashMap::new(); // no role configured → developer
|
||||
|
||||
let (group, is_admin, is_operator) =
|
||||
compute_highest_workspace_role(&user_groups, &ws_groups, &roles);
|
||||
assert_eq!(group, "team");
|
||||
assert!(!is_admin);
|
||||
assert!(!is_operator);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,9 @@ impl WebhookShared {
|
||||
}
|
||||
|
||||
pub fn send_message(&self, workspace_id: String, message: WebhookMessage) {
|
||||
if *crate::worker::CLOUD_HOSTED {
|
||||
return;
|
||||
}
|
||||
let _ = self.channel.send(WebhookPayload::WorkspaceEvent(
|
||||
workspace_id.clone(),
|
||||
message,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user