mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-13 16:05:00 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4013aa9098 |
@@ -5,7 +5,7 @@ alwaysApply: false
|
||||
---
|
||||
# Svelte 5 Best Practices
|
||||
|
||||
This guide outlines best practices for developing with Svelte 5, incorporating the new Runes API and other modern Svelte features. These rules MUST NOT be applied on svelte 4 files unless explicitly asked to do so.
|
||||
This guide outlines best practices for developing with Svelte 5, incorporating the new Runes API and other modern Svelte features. They should be applied on every new files created, but not on existing svelte 4 files unless specifically asked to.
|
||||
|
||||
## Reactivity with Runes
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Windmill Overview
|
||||
|
||||
Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- **Script Development and Execution**: Write and run scripts in Python, TypeScript/JavaScript (Deno/Bun), Go, Bash, SQL, and other languages
|
||||
- **Workflow Orchestration**: Compose scripts into multi-step flows with conditional logic, loops, and error handling
|
||||
- **UI Generation**: Automatically generate UIs from scripts or build custom applications with a low-code editor
|
||||
- **Job Scheduling**: Trigger scripts and flows on schedules, webhooks, or external events
|
||||
- **Resource Management**: Securely store and use credentials, databases, and other connections
|
||||
|
||||
## Platform Architecture
|
||||
|
||||
The Windmill platform consists of several key components:
|
||||
|
||||
- **Frontend UI**: Web-based interface for script and flow development, app building, and result visualization
|
||||
- **API Server**: Central API that handles authentication, resource management, and job coordination
|
||||
- **Workers**: Execute scripts in their respective environments with proper sandboxing
|
||||
- **Database**: PostgreSQL database for storage of scripts, flows, resources, job results, and more
|
||||
- **Job Queue**: Queue system for managing job execution, implemented in PostgreSQL
|
||||
- **Client Libraries**: Libraries for interacting with Windmill from Python, TypeScript, or command line
|
||||
|
||||
# Windmill Backend Architecture
|
||||
|
||||
The Windmill backend is written in Rust and consists of several services working together. These services are designed for horizontal scaling with stateless API servers and workers that can be deployed across multiple machines.
|
||||
|
||||
## Key Components
|
||||
|
||||
- **API Server (`windmill-api`)**: Handles HTTP requests, authentication, and resource management
|
||||
- **Queue Manager (`windmill-queue`)**: Manages the job queue in PostgreSQL
|
||||
- **Worker System (`windmill-worker`)**: Executes jobs in sandboxed environments
|
||||
- **Common Utilities (`windmill-common`)**: Shared code used by multiple services
|
||||
- **Git Sync (`windmill-git-sync`)**: Synchronizes scripts with Git repositories
|
||||
|
||||
## Job Execution System
|
||||
|
||||
The job execution process follows these steps:
|
||||
|
||||
1. The API server receives a request to run a script or flow and creates a job record in the database
|
||||
2. The job is added to the queue system in PostgreSQL
|
||||
3. Workers continuously poll the queue for jobs matching their capabilities
|
||||
4. When a job is picked up, it's routed to the appropriate language executor
|
||||
5. The script is executed in a sandboxed environment using NSJAIL for security
|
||||
6. Results are processed and stored in the database
|
||||
7. For flows, each step creates a new job that goes through the same process
|
||||
|
||||
Windmill supports worker tags and groups to route jobs to workers with specific capabilities or resource access.
|
||||
|
||||
# Windmill Frontend Architecture
|
||||
|
||||
The Windmill frontend is built with Svelte and provides several key interfaces for interacting with the platform.
|
||||
|
||||
## Key Components
|
||||
|
||||
- **Script Builder**: Code editor with language support, schema inference, and dependency management
|
||||
- **Flow Builder**: Visual editor for creating multi-step workflows with branching and looping
|
||||
- **App Editor**: Grid-based editor for building custom UIs that integrate scripts and flows
|
||||
- **Schema Form System**: Generates form interfaces from script parameters automatically
|
||||
- **Result Viewer**: Visualizes job results, logs, and execution status
|
||||
|
||||
The frontend uses the Monaco editor (same as VS Code) for code editing, with specialized language support for all supported script languages.
|
||||
|
||||
## UI Framework
|
||||
|
||||
The frontend is built with Svelte, providing a reactive and component-based architecture. Key frontend technologies include:
|
||||
|
||||
- **Svelte/SvelteKit**: Core framework for UI components and routing
|
||||
- **Monaco Editor**: Code editing experience similar to VS Code
|
||||
- **Schema Form**: Automatic UI generation from TypeScript/JSON schemas
|
||||
- **Tailwind CSS**: Utility-first CSS framework for styling
|
||||
@@ -5,42 +5,12 @@ on:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
auto-fix-review:
|
||||
if: github.event.review.state == 'changes_requested' && contains(github.event.pull_request.title, '[Aider PR]')
|
||||
runs-on: ubicloud-standard-2
|
||||
outputs:
|
||||
is_member: ${{ steps.check-membership.outputs.is_member }}
|
||||
steps:
|
||||
- name: Check organization membership
|
||||
id: check-membership
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
REVIEWER: ${{ github.event.review.user.login }}
|
||||
ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
run: |
|
||||
ORG="windmill-labs"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $ORG_ACCESS_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/orgs/$ORG/members/$REVIEWER")
|
||||
|
||||
if [ "$STATUS" -eq 204 ]; then
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
check-and-prepare:
|
||||
needs: check-membership
|
||||
if: github.event.review.state == 'changes_requested' && contains(github.event.pull_request.title, '[Aider PR]') && needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubicloud-standard-2
|
||||
runs-on: ubicloud-standard-8
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
outputs:
|
||||
prompt_content: ${{ steps.prepare_prompt.outputs.prompt_content }}
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
@@ -49,45 +19,152 @@ jobs:
|
||||
WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Acknowledge Request
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git User
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Checkout PR Branch
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
echo "Commenting on PR #${{ github.event.pull_request.number }} to acknowledge the /aider command."
|
||||
gh pr comment ${{ github.event.pull_request.number }} --body "🤖 Aider is starting to work on your request. Please be patient, this might take a few minutes." --repo $GITHUB_REPOSITORY
|
||||
echo "PR review trigger: Checking out PR branch..."
|
||||
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||
PR_HEAD_REF=$(gh pr view $PR_NUMBER --json headRefName -q .headRefName --repo $GITHUB_REPOSITORY)
|
||||
if [[ -z "$PR_HEAD_REF" || "$PR_HEAD_REF" == "null" ]]; then
|
||||
echo "::error::Could not determine PR head branch for PR #$PR_NUMBER via gh CLI."
|
||||
exit 1
|
||||
fi
|
||||
echo "Checking out PR head branch: $PR_HEAD_REF for PR #$PR_NUMBER"
|
||||
git fetch origin "refs/heads/${PR_HEAD_REF}:refs/remotes/origin/${PR_HEAD_REF}" --no-tags
|
||||
git checkout "$PR_HEAD_REF"
|
||||
echo "Successfully checked out branch $(git rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
- name: Prepare prompt for Aider
|
||||
id: prepare_prompt
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Aider and Dependencies
|
||||
run: |
|
||||
python -m pip install aider-install; aider-install
|
||||
pip install -U google-generativeai
|
||||
sudo apt-get update && sudo apt-get install -y jq
|
||||
|
||||
- name: Generate Prompt from Review
|
||||
id: generate_prompt
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
REVIEW_BODY: ${{ github.event.review.body }}
|
||||
run: |
|
||||
REVIEW_BODY_ESCAPED="${REVIEW_BODY//\\/\\\\}"
|
||||
REVIEW_BODY_ESCAPED="${REVIEW_BODY_ESCAPED//\"/\\\"}"
|
||||
mkdir -p .github/aider
|
||||
PROMPT_FILE_PATH=".github/aider/review-prompt.txt"
|
||||
|
||||
# Get PR review body
|
||||
REVIEW_BODY="${{ github.event.review.body }}"
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
|
||||
# Get PR description for context NOT USED FOR NOW
|
||||
# PR_DETAILS=$(gh pr view $PR_NUMBER --json title,body --repo $GITHUB_REPOSITORY)
|
||||
# PR_TITLE=$(echo "$PR_DETAILS" | jq -r .title)
|
||||
# PR_BODY=$(echo "$PR_DETAILS" | jq -r .body)
|
||||
|
||||
# Get all PR review comments
|
||||
REVIEW_COMMENTS=$(gh pr view $PR_NUMBER --json reviews -q '.reviews[] | select(.state == "CHANGES_REQUESTED") | .body' --repo $GITHUB_REPOSITORY)
|
||||
REVIEW_BODY_Q=$(printf '%q' "$REVIEW_BODY")
|
||||
|
||||
# Update query to get review comments from all review types, not just "CHANGES_REQUESTED"
|
||||
ALL_REVIEW_COMMENTS=$(gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments)
|
||||
|
||||
FORMATTED_COMMENTS=$(jq -r '[.[] | {diff_hunk: .diff_hunk, path: .path, body: .body}]' <<< "$ALL_REVIEW_COMMENTS")
|
||||
/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments \
|
||||
| jq '[.[] | {diff_hunk: .diff_hunk, path: .path, body: .body}]')
|
||||
|
||||
BASE_PROMPT="Fix the following issues in the PR based on the review feedback. The review body is prepended with REVIEW. The review comments are prepended with REVIEW_COMMENTS. The review body and comments are separated by a blank line."
|
||||
printf "%s\nREVIEW:\n%s\nREVIEW_COMMENTS:\n%s" \
|
||||
"$BASE_PROMPT" "$REVIEW_BODY_Q" "$ALL_REVIEW_COMMENTS" > "$PROMPT_FILE_PATH"
|
||||
echo "PROMPT_FILE_PATH=$PROMPT_FILE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
COMPLETE_PROMPT="${BASE_PROMPT}"$'\n'"REVIEW:"$'\n'"${REVIEW_BODY_ESCAPED}"$'\n'"REVIEW_COMMENTS:"$'\n'"${FORMATTED_COMMENTS}"
|
||||
- name: Run Aider with review prompt
|
||||
run: |
|
||||
aider \
|
||||
--read .cursor/rules/rust-best-practices.mdc \
|
||||
--read .cursor/rules/svelte5-best-practices.mdc \
|
||||
--model gemini/gemini-2.5-pro-preview-05-06 \
|
||||
--message-file .github/aider/review-prompt.txt \
|
||||
--yes \
|
||||
--no-check-update \
|
||||
--auto-commits \
|
||||
--no-analytics \
|
||||
--no-gitignore \
|
||||
| tee .github/aider/aider-output.txt || true
|
||||
echo "Aider command completed. Output saved to .github/aider/aider-output.txt"
|
||||
# Check if there are any changes to commit
|
||||
if [[ -z "$(git status --porcelain)" ]]; then
|
||||
echo "No changes detected after running Aider."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "prompt_content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$COMPLETE_PROMPT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
- name: Clean up prompt file
|
||||
if: always()
|
||||
run: rm -f .github/aider/review-prompt.txt
|
||||
|
||||
run-aider:
|
||||
needs: [check-membership, check-and-prepare]
|
||||
if: github.event.review.state == 'changes_requested' && contains(github.event.pull_request.title, '[Aider PR]') && needs.check-membership.outputs.is_member == 'true'
|
||||
uses: ./.github/workflows/aider-common.yml
|
||||
with:
|
||||
needs_processing: false
|
||||
base_prompt: ${{ needs.check-and-prepare.outputs.prompt_content }}
|
||||
secrets: inherit
|
||||
- name: Commit and Push Changes
|
||||
id: commit_and_push
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
CURRENT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "Attempting to push changes to PR branch $CURRENT_BRANCH_NAME for PR #${{ github.event.pull_request.number }}"
|
||||
|
||||
# Pull latest changes to avoid rejection due to non-fast-forward
|
||||
git pull origin $CURRENT_BRANCH_NAME
|
||||
|
||||
if git push origin $CURRENT_BRANCH_NAME; then
|
||||
echo "Push to $CURRENT_BRANCH_NAME successful."
|
||||
echo "CHANGES_APPLIED=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "::warning::Push to PR branch $CURRENT_BRANCH_NAME failed."
|
||||
echo "CHANGES_APPLIED=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Comment on PR
|
||||
if: success()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUM: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# Create comment body in a temporary file to avoid command line length limits
|
||||
if [[ "${{ steps.commit_and_push.outputs.CHANGES_APPLIED }}" == "true" ]]; then
|
||||
cat > /tmp/pr-comment.md << EOL
|
||||
🤖 I've automatically addressed the feedback based on the review.
|
||||
|
||||
## Aider Output
|
||||
\`\`\`
|
||||
$(cat .github/aider/aider-output.txt || echo 'No output available')
|
||||
\`\`\`
|
||||
|
||||
Please review the changes and let me know if further adjustments are needed.
|
||||
EOL
|
||||
else
|
||||
cat > /tmp/pr-comment.md << EOL
|
||||
🤖 I attempted to address the review feedback, but no modifications were made.
|
||||
|
||||
## Aider Output
|
||||
\`\`\`
|
||||
$(cat .github/aider/aider-output.txt || echo 'No output available')
|
||||
\`\`\`
|
||||
|
||||
Please review the output and provide additional guidance if needed.
|
||||
EOL
|
||||
fi
|
||||
|
||||
# Use the file for comment body
|
||||
gh pr comment $PR_NUM --body-file /tmp/pr-comment.md
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
name: Aider Common Steps
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
issue_title:
|
||||
description: "Title of the issue or PR"
|
||||
required: false
|
||||
type: string
|
||||
issue_body:
|
||||
description: "Body of the issue or PR"
|
||||
required: false
|
||||
type: string
|
||||
instruction:
|
||||
description: "Instruction for Aider"
|
||||
required: false
|
||||
type: string
|
||||
issue_id:
|
||||
description: "ID of the issue or PR"
|
||||
required: false
|
||||
type: string
|
||||
needs_processing:
|
||||
description: "Whether the issue needs to be processed by the external API"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
base_prompt:
|
||||
description: "Base prompt for Aider"
|
||||
required: false
|
||||
type: string
|
||||
default: "Try to fix the following issue based on the instruction given by the user. The issue is prepended with the word ISSUE. The instruction is prepended with the word INSTRUCTION. The issue and instruction are separated by a blank line."
|
||||
probe_prompt:
|
||||
description: "Prompt for probe-chat"
|
||||
required: false
|
||||
type: string
|
||||
default: 'I''m giving you a request that needs to be implemented. Your role is ONLY to give me the files that are relevant to the request and nothing else. The request is prepended with the word REQUEST. REQUEST: $FINAL_PROMPT. Give me all the files relevant to this request. Your output MUST be a single json array that can be parsed with programatic json parsing, with the relevant files. Files can be rust or typescript or javascript files. DO NOT INCLUDE ANY OTHER TEXT IN YOUR OUTPUT. ONLY THE JSON ARRAY. Example of output: ["file1.py", "file2.py"]'
|
||||
outputs:
|
||||
files_to_edit:
|
||||
description: "Files identified by probe-chat for editing"
|
||||
value: ${{ jobs.common-steps.outputs.files_to_edit }}
|
||||
final_prompt:
|
||||
description: "Final prompt for Aider"
|
||||
value: ${{ jobs.common-steps.outputs.final_prompt }}
|
||||
pr_branch_name:
|
||||
description: "Name of the branch used for PR"
|
||||
value: ${{ jobs.common-steps.outputs.pr_branch_name }}
|
||||
changes_applied_message:
|
||||
description: "Message indicating changes were applied"
|
||||
value: ${{ jobs.common-steps.outputs.changes_applied_message }}
|
||||
changes_applied:
|
||||
description: "Boolean indicating if changes were successfully applied"
|
||||
value: ${{ jobs.common-steps.outputs.changes_applied }}
|
||||
|
||||
jobs:
|
||||
common-steps:
|
||||
runs-on: ubicloud-standard-8
|
||||
outputs:
|
||||
files_to_edit: ${{ steps.probe_files.outputs.files_to_edit }}
|
||||
final_prompt: ${{ steps.create_prompt.outputs.final_prompt }}
|
||||
pr_branch_name: ${{ steps.commit_and_push.outputs.PR_BRANCH_NAME }}
|
||||
changes_applied_message: ${{ steps.commit_and_push.outputs.CHANGES_APPLIED_MESSAGE }}
|
||||
changes_applied: ${{ steps.commit_and_push.outputs.CHANGES_APPLIED }}
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }}
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout PR Branch
|
||||
id: checkout_pr
|
||||
if: (github.event_name == 'issue_comment' && github.event.issue.pull_request) || (github.event_name == 'pull_request_review')
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
echo "Issue comment trigger: Checking out PR branch..."
|
||||
PR_NUMBER=""
|
||||
if [ -n "${{ github.event.issue.number }}" ]; then
|
||||
PR_NUMBER="${{ github.event.issue.number }}"
|
||||
elif [ -n "${{ github.event.pull_request.number }}" ]; then
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
else
|
||||
echo "::error::Could not determine PR number."
|
||||
exit 1
|
||||
fi
|
||||
PR_HEAD_REF=$(gh pr view $PR_NUMBER --json headRefName -q .headRefName --repo $GITHUB_REPOSITORY)
|
||||
if [[ -z "$PR_HEAD_REF" || "$PR_HEAD_REF" == "null" ]]; then
|
||||
echo "::error::Could not determine PR head branch for PR #$PR_NUMBER via gh CLI."
|
||||
exit 1
|
||||
fi
|
||||
echo "Checking out PR head branch: $PR_HEAD_REF for PR #$PR_NUMBER"
|
||||
git fetch origin "refs/heads/${PR_HEAD_REF}:refs/remotes/origin/${PR_HEAD_REF}" --no-tags
|
||||
git checkout "$PR_HEAD_REF"
|
||||
echo "Successfully checked out branch $(git rev-parse --abbrev-ref HEAD)"
|
||||
echo "PR_BRANCH=$PR_HEAD_REF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Configure Git User
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Cache Python dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt', '**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Cache Aider installation
|
||||
id: cache-aider
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.local/bin/aider
|
||||
key: ${{ runner.os }}-aider-install-${{ hashFiles('**/requirements.txt', '**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-aider-install-
|
||||
|
||||
- name: Install Aider and Dependencies
|
||||
run: |
|
||||
if [ -f ~/.local/bin/aider ] && [ -x ~/.local/bin/aider ]; then
|
||||
echo "Using cached Aider installation"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
else
|
||||
echo "Installing Aider..."
|
||||
python -m pip install aider-install; aider-install
|
||||
fi
|
||||
pip install -U google-generativeai
|
||||
sudo apt-get update && sudo apt-get install -y jq
|
||||
echo "$HOME/.local/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Create Prompt for Aider
|
||||
id: create_prompt
|
||||
shell: bash
|
||||
env:
|
||||
BASE_PROMPT_ENV: ${{ inputs.base_prompt }}
|
||||
ISSUE_TITLE_ENV: ${{ inputs.issue_title }}
|
||||
ISSUE_BODY_ENV: ${{ inputs.issue_body }}
|
||||
INSTRUCTION_ENV: ${{ inputs.instruction }}
|
||||
NEEDS_PROCESSING_ENV: ${{ inputs.needs_processing }}
|
||||
WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }}
|
||||
run: |
|
||||
set -e
|
||||
FINAL_PROMPT_CONTENT=""
|
||||
|
||||
if [[ "$ISSUE_TITLE_ENV" != "" && "$ISSUE_BODY_ENV" != "" ]]; then
|
||||
echo "Processing issue with title: $ISSUE_TITLE_ENV"
|
||||
if [[ "$NEEDS_PROCESSING_ENV" == "true" ]]; then
|
||||
echo "Needs processing is true. Calling Windmill API..."
|
||||
JSON_PAYLOAD=$(jq -n \
|
||||
--arg title "$ISSUE_TITLE_ENV" \
|
||||
--arg body "$ISSUE_BODY_ENV" \
|
||||
'{"body":{"issue_title":$title,"issue_body":$body}}')
|
||||
|
||||
echo "Windmill JSON Payload: $JSON_PAYLOAD"
|
||||
|
||||
API_RESULT_FILE=$(mktemp)
|
||||
HTTP_CODE=$(curl -s -o "$API_RESULT_FILE" -w "%{http_code}" \
|
||||
-X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run_wait_result/p/f/ai/quiet_script" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $WINDMILL_TOKEN" \
|
||||
--data-binary "$JSON_PAYLOAD" \
|
||||
--max-time 90)
|
||||
|
||||
BODY_CONTENT=$(cat "$API_RESULT_FILE")
|
||||
rm -f "$API_RESULT_FILE" # Clean up temp file
|
||||
|
||||
echo "Windmill API HTTP Code: $HTTP_CODE"
|
||||
if [[ "$HTTP_CODE" -eq 200 ]]; then
|
||||
PROCESSED_ISSUE_PROMPT=$(echo "$BODY_CONTENT" | jq -r '.effective_body // empty')
|
||||
if [[ -z "$PROCESSED_ISSUE_PROMPT" || "$PROCESSED_ISSUE_PROMPT" == "null" ]]; then
|
||||
echo "::warning::Windmill API returned 200 but effective_body was empty or null."
|
||||
EFFECTIVE_ISSUE_CONTENT_FOR_PROMPT="$ISSUE_BODY_ENV"
|
||||
else
|
||||
echo "Successfully processed issue via Windmill API."
|
||||
EFFECTIVE_ISSUE_CONTENT_FOR_PROMPT="$PROCESSED_ISSUE_PROMPT"
|
||||
fi
|
||||
FINAL_PROMPT_CONTENT=$(printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \
|
||||
"$BASE_PROMPT_ENV" "$EFFECTIVE_ISSUE_CONTENT_FOR_PROMPT" "$INSTRUCTION_ENV")
|
||||
else
|
||||
echo "::error::Windmill API call failed (HTTP $HTTP_CODE). Using raw issue content for prompt."
|
||||
FINAL_PROMPT_CONTENT=$(printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \
|
||||
"$BASE_PROMPT_ENV" "$ISSUE_BODY_ENV" "$INSTRUCTION_ENV")
|
||||
fi
|
||||
else
|
||||
echo "Needs processing is false. Using raw issue content for prompt."
|
||||
FINAL_PROMPT_CONTENT=$(printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \
|
||||
"$BASE_PROMPT_ENV" "$ISSUE_BODY_ENV" "$INSTRUCTION_ENV")
|
||||
fi
|
||||
else
|
||||
echo "No issue title or body given. Using base prompt."
|
||||
FINAL_PROMPT_CONTENT="$BASE_PROMPT_ENV"
|
||||
fi
|
||||
|
||||
echo "Final prompt: $FINAL_PROMPT_CONTENT"
|
||||
echo "final_prompt<<EOF_AIDER_PROMPT" >> "$GITHUB_OUTPUT"
|
||||
echo "$FINAL_PROMPT_CONTENT" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF_AIDER_PROMPT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Probe Chat for Relevant Files
|
||||
id: probe_files
|
||||
shell: bash
|
||||
env:
|
||||
FINAL_PROMPT: ${{ steps.create_prompt.outputs.final_prompt }}
|
||||
run: |
|
||||
echo "Running probe-chat to find relevant files..."
|
||||
|
||||
# escape the final prompt
|
||||
printf -v MESSAGE_FOR_PROBE 'I'\''m giving you a request that needs to be implemented. Your role is ONLY to give me the files that are relevant to the request and nothing else. The request is prepended with the word REQUEST.\nREQUEST: %s. Give me all the files relevant to this request. Your output MUST be a single json array that can be parsed with programatic json parsing, with the relevant files. Files can be rust or typescript or javascript files. DO NOT INCLUDE ANY OTHER TEXT IN YOUR OUTPUT. ONLY THE JSON ARRAY. Example of output: ["file1.py", "file2.py"]' "$FINAL_PROMPT"
|
||||
|
||||
set -o pipefail
|
||||
PROBE_OUTPUT=$(npx --yes @buger/probe-chat@latest --max-iterations 50 --model-name gemini-2.5-pro-preview-05-06 --message "$MESSAGE_FOR_PROBE") || {
|
||||
echo "::error::probe-chat command failed. Output:"
|
||||
echo "$PROBE_OUTPUT"
|
||||
exit 1
|
||||
}
|
||||
set +o pipefail
|
||||
echo "Probe-chat raw output:"
|
||||
echo "$PROBE_OUTPUT"
|
||||
|
||||
JSON_FILES=$(echo "$PROBE_OUTPUT" | sed -n '/^\s*\[/,$p' | sed '/^\s*\]/q')
|
||||
echo "Extracted JSON block:"
|
||||
echo "$JSON_FILES"
|
||||
|
||||
FILES_LIST=$(echo "$JSON_FILES" | jq -e -r '[.[] | select(type == "string" and . != "" and . != null and (endswith("/") | not))] | join(" ")' || echo "")
|
||||
|
||||
if [[ -z "$FILES_LIST" ]]; then
|
||||
echo "::warning::probe-chat did not identify any relevant files."
|
||||
fi
|
||||
|
||||
echo "Formatted files list for aider: $FILES_LIST"
|
||||
echo "files_to_edit=$FILES_LIST" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Aider tags
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: .aider.tags.cache.v4
|
||||
key: ${{ runner.os }}-aider-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-aider-
|
||||
|
||||
- name: Run Aider
|
||||
id: run_aider
|
||||
shell: bash
|
||||
env:
|
||||
FILES_TO_EDIT: ${{ steps.probe_files.outputs.files_to_edit }}
|
||||
FINAL_PROMPT: ${{ steps.create_prompt.outputs.final_prompt }}
|
||||
run: |
|
||||
|
||||
echo "$FINAL_PROMPT" > .aider_final_prompt.txt
|
||||
echo "FILES_TO_EDIT: $FILES_TO_EDIT"
|
||||
|
||||
aider \
|
||||
--read .cursor/rules/rust-best-practices.mdc \
|
||||
--read .cursor/rules/svelte5-best-practices.mdc \
|
||||
--read .cursor/rules/windmill-overview.mdc \
|
||||
$FILES_TO_EDIT \
|
||||
--model gemini/gemini-2.5-pro-preview-05-06 \
|
||||
--message-file .aider_final_prompt.txt \
|
||||
--yes \
|
||||
--no-check-update \
|
||||
--auto-commits \
|
||||
--no-analytics \
|
||||
--no-gitignore \
|
||||
| tee .aider_output.txt || true
|
||||
|
||||
echo "Aider command completed. Output saved to .aider_output.txt"
|
||||
|
||||
- name: Cache Node.js dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json', '**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Commit and Push Changes
|
||||
id: commit_and_push
|
||||
env:
|
||||
ISSUE_ID: ${{ inputs.issue_id }}
|
||||
run: |
|
||||
if [[ "$ISSUE_ID" != "" ]]; then
|
||||
BRANCH_NAME="aider-fix-issue-${ISSUE_ID}"
|
||||
|
||||
# Check if branch exists remotely
|
||||
if git ls-remote --heads origin $BRANCH_NAME | grep -q $BRANCH_NAME; then
|
||||
echo "Branch $BRANCH_NAME already exists remotely, fetching it"
|
||||
git fetch origin $BRANCH_NAME
|
||||
git checkout $BRANCH_NAME
|
||||
git pull origin $BRANCH_NAME
|
||||
else
|
||||
echo "Creating new branch $BRANCH_NAME"
|
||||
git checkout -b $BRANCH_NAME
|
||||
fi
|
||||
|
||||
echo "Created/checked out branch $BRANCH_NAME for issue #${ISSUE_ID}"
|
||||
git push origin $BRANCH_NAME
|
||||
echo "Pushed to branch $BRANCH_NAME"
|
||||
echo "PR_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
echo "CHANGES_APPLIED_MESSAGE=Aider changes pushed to branch $BRANCH_NAME." >> $GITHUB_OUTPUT
|
||||
else
|
||||
# We're in a pull_request_review event
|
||||
PR_NUMBER="${{ github.event.pull_request.number }}"
|
||||
PR_HEAD_REF="${{ github.event.pull_request.head.ref }}"
|
||||
|
||||
echo "Handling pull_request_review for PR #$PR_NUMBER on branch $PR_HEAD_REF"
|
||||
|
||||
# Ensure we're on the correct branch
|
||||
git config pull.rebase true
|
||||
git fetch origin $PR_HEAD_REF
|
||||
git checkout $PR_HEAD_REF
|
||||
git pull origin $PR_HEAD_REF
|
||||
|
||||
echo "Attempting to push changes to PR branch $PR_HEAD_REF for PR #$PR_NUMBER"
|
||||
if git push origin $PR_HEAD_REF; then
|
||||
echo "Push to $PR_HEAD_REF successful (or no new changes to push)."
|
||||
echo "CHANGES_APPLIED_MESSAGE=Aider changes (if any) pushed to PR branch $PR_HEAD_REF." >> $GITHUB_OUTPUT
|
||||
echo "PR_BRANCH_NAME=$PR_HEAD_REF" >> $GITHUB_OUTPUT
|
||||
echo "CHANGES_APPLIED=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "::warning::Push to PR branch $PR_HEAD_REF failed."
|
||||
echo "CHANGES_APPLIED_MESSAGE=Aider ran, but failed to push changes to PR branch $PR_HEAD_REF." >> $GITHUB_OUTPUT
|
||||
echo "CHANGES_APPLIED=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always() && (github.event_name == 'issue_comment' || github.event_name == 'repository_dispatch') && !github.event.issue.pull_request && steps.commit_and_push.outputs.PR_BRANCH_NAME != ''
|
||||
id: create_pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_BRANCH: ${{ steps.commit_and_push.outputs.PR_BRANCH_NAME }}
|
||||
ISSUE_NUM: ${{ inputs.issue_id }}
|
||||
ISSUE_TITLE: ${{ inputs.issue_title }}
|
||||
run: |
|
||||
# Debug: Check latest commit and branch status
|
||||
echo "Checking latest commit on branch $PR_BRANCH"
|
||||
git log -1 --pretty=format:"%h - %an, %ar : %s"
|
||||
echo "Changes not yet committed:"
|
||||
git status --porcelain
|
||||
# Check if there are any changes to commit
|
||||
if [[ -n $(git status --porcelain) ]]; then
|
||||
echo "Found uncommitted changes, committing them"
|
||||
git add .
|
||||
git commit -m "Aider changes for issue #${ISSUE_NUM}"
|
||||
git push origin $PR_BRANCH
|
||||
fi
|
||||
|
||||
# Create PR description in a temporary file to avoid command line length limits and ensure it stays under 40k chars
|
||||
cat > /tmp/pr-description.md << EOL | head -c 40000
|
||||
This PR was created automatically by Aider to fix issue #${ISSUE_NUM}.
|
||||
|
||||
## Aider Output
|
||||
\`\`\`
|
||||
$(cat .aider_output.txt || echo "No output available")
|
||||
\`\`\`
|
||||
EOL
|
||||
|
||||
# Create PR using the file for the body content, handle errors gracefully
|
||||
set +e # Don't exit on error
|
||||
gh pr create \
|
||||
--title "[Aider PR] Fix: ${ISSUE_TITLE}" \
|
||||
--body-file /tmp/pr-description.md \
|
||||
--head "$PR_BRANCH" \
|
||||
--base main
|
||||
PR_CREATE_EXIT_CODE=$?
|
||||
set -e # Re-enable exit on error
|
||||
|
||||
if [ $PR_CREATE_EXIT_CODE -eq 0 ]; then
|
||||
echo "PR created successfully"
|
||||
PR_URL=$(gh pr view $PR_BRANCH --json url --jq .url)
|
||||
echo "PR_URL=$PR_URL" >> $GITHUB_OUTPUT
|
||||
echo "PR_CREATED=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Warning: Failed to create PR. Exit code: $PR_CREATE_EXIT_CODE"
|
||||
echo "PR_CREATED=false" >> $GITHUB_OUTPUT
|
||||
# Continue workflow despite PR creation failure
|
||||
fi
|
||||
|
||||
- name: Comment on PR with Aider Output
|
||||
if: always() && github.event_name == 'pull_request_review' && steps.commit_and_push.outputs.CHANGES_APPLIED != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUM: ${{ github.event.pull_request.number }}
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
# Create comment body in a temporary file to avoid command line length limits
|
||||
if [[ "${{ steps.commit_and_push.outputs.CHANGES_APPLIED }}" == "true" ]]; then
|
||||
if [[ "$JOB_STATUS" == "success" ]]; then
|
||||
STATUS_PREFIX="🤖 I've automatically addressed the feedback based on the review."
|
||||
else
|
||||
STATUS_PREFIX="⚠️ I attempted to address the feedback, but encountered some issues."
|
||||
fi
|
||||
else
|
||||
if [[ "$JOB_STATUS" == "success" ]]; then
|
||||
STATUS_PREFIX="🤖 I attempted to address the review feedback, but no modifications were made."
|
||||
else
|
||||
STATUS_PREFIX="⚠️ I encountered issues while attempting to address the feedback, and no modifications were made."
|
||||
fi
|
||||
fi
|
||||
|
||||
cat > /tmp/pr-comment.md << EOL
|
||||
${STATUS_PREFIX}
|
||||
|
||||
## Aider Output
|
||||
\`\`\`
|
||||
$(cat .aider_output.txt || echo 'No output available')
|
||||
\`\`\`
|
||||
|
||||
Please review the output and provide additional guidance if needed.
|
||||
EOL
|
||||
|
||||
# Use the file for comment body
|
||||
gh pr comment $PR_NUM --body-file /tmp/pr-comment.md
|
||||
|
||||
- name: Comment on issue/PR to let the user know Aider has finished working on the request
|
||||
if: always() && github.event_name == 'issue_comment'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
PR_CREATED: ${{ steps.create_pr.outputs.PR_CREATED }}
|
||||
run: |
|
||||
echo "Commenting on issue/PR #${{ github.event.issue.number }} to let the user know Aider has finished working on the request."
|
||||
|
||||
if [[ "$JOB_STATUS" == "success" ]]; then
|
||||
if [[ "$PR_CREATED" == "true" ]]; then
|
||||
COMMENT_BODY="🤖 Aider has finished working on your request. A PR has been created."
|
||||
else
|
||||
COMMENT_BODY="🤖 Aider has finished working on your request, but was unable to create a PR."
|
||||
fi
|
||||
else
|
||||
COMMENT_BODY="⚠️ Aider encountered issues while working on your request. Please check the workflow logs for details."
|
||||
fi
|
||||
|
||||
gh issue comment ${{ github.event.issue.number }} --body "$COMMENT_BODY" --repo $GITHUB_REPOSITORY
|
||||
|
||||
- name: Comment on linear issue to let the user know Aider has finished working on the request
|
||||
if: always() && github.event_name == 'repository_dispatch'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
PR_CREATED: ${{ steps.create_pr.outputs.PR_CREATED }}
|
||||
run: |
|
||||
echo "Commenting on linear issue #${{ github.event.client_payload.issue_id }} to let the user know Aider has finished working on the request."
|
||||
|
||||
if [[ "$JOB_STATUS" == "success" ]]; then
|
||||
if [[ "$PR_CREATED" == "true" ]]; then
|
||||
COMMENT_BODY="🤖 Aider has finished working on your request. A PR has been created."
|
||||
else
|
||||
COMMENT_BODY="🤖 Aider has finished working on your request, but was unable to create a PR."
|
||||
fi
|
||||
else
|
||||
COMMENT_BODY="⚠️ Aider encountered issues while working on your request. Please check the workflow logs for details."
|
||||
fi
|
||||
|
||||
curl -X POST \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://api.linear.app/graphql" \
|
||||
-d "{\"query\":\"mutation { commentCreate(input: { issueId: \\\"${{ github.event.client_payload.issue_id }}\\\", body: \\\"${COMMENT_BODY}\\\" }) { success } }\"}"
|
||||
+294
-102
@@ -5,40 +5,12 @@ on:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
runs-on: ubicloud-standard-2
|
||||
auto-fix:
|
||||
runs-on: ubicloud-standard-8
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body, '/aider') &&
|
||||
!contains(github.event.comment.user.login, '[bot]')
|
||||
outputs:
|
||||
is_member: ${{ steps.check-membership.outputs.is_member }}
|
||||
steps:
|
||||
- name: Check organization membership
|
||||
id: check-membership
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
run: |
|
||||
ORG="windmill-labs"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $ORG_ACCESS_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/orgs/$ORG/members/$COMMENTER")
|
||||
|
||||
if [ "$STATUS" -eq 204 ]; then
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
check-and-prepare:
|
||||
needs: check-membership
|
||||
runs-on: ubicloud-standard-2
|
||||
if: needs.check-membership.outputs.is_member == 'true'
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
@@ -49,102 +21,322 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }}
|
||||
outputs:
|
||||
issue_title: ${{ steps.determine_inputs.outputs.ISSUE_TITLE }}
|
||||
issue_body: ${{ steps.determine_inputs.outputs.ISSUE_BODY }}
|
||||
comment_content: ${{ steps.determine_inputs.outputs.COMMENT_CONTENT }}
|
||||
pr_branch: ${{ steps.checkout_pr.outputs.PR_BRANCH }}
|
||||
|
||||
steps:
|
||||
- name: Acknowledge Request
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Configure Git User
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Checkout PR Branch
|
||||
if: github.event_name == 'issue_comment' && github.event.issue.pull_request
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
echo "Commenting on issue/PR #${{ github.event.issue.number }} to acknowledge the /aider command."
|
||||
gh issue comment ${{ github.event.issue.number }} --body "🤖 Aider is starting to work on your request. I'll update you here once I have a PR ready. Please be patient, this might take a few minutes." --repo $GITHUB_REPOSITORY
|
||||
echo "Issue comment trigger: Checking out PR branch..."
|
||||
PR_NUMBER=${{ github.event.issue.number }}
|
||||
PR_HEAD_REF=$(gh pr view $PR_NUMBER --json headRefName -q .headRefName --repo $GITHUB_REPOSITORY)
|
||||
if [[ -z "$PR_HEAD_REF" || "$PR_HEAD_REF" == "null" ]]; then
|
||||
echo "::error::Could not determine PR head branch for PR #$PR_NUMBER via gh CLI."
|
||||
exit 1
|
||||
fi
|
||||
echo "Checking out PR head branch: $PR_HEAD_REF for PR #$PR_NUMBER"
|
||||
git fetch origin "refs/heads/${PR_HEAD_REF}:refs/remotes/origin/${PR_HEAD_REF}" --no-tags
|
||||
git checkout "$PR_HEAD_REF"
|
||||
echo "Successfully checked out branch $(git rev-parse --abbrev-ref HEAD)"
|
||||
|
||||
- name: Determine inputs for Aider
|
||||
id: determine_inputs
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Aider and Dependencies
|
||||
run: |
|
||||
python -m pip install aider-install; aider-install
|
||||
pip install -U google-generativeai
|
||||
sudo apt-get update && sudo apt-get install -y jq
|
||||
|
||||
- name: Determine Prompt for Aider
|
||||
id: determine_prompt
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
echo "Determining inputs for Aider..."
|
||||
ISSUE_TITLE_VAL=""
|
||||
ISSUE_BODY_VAL=""
|
||||
PROMPT_FILE_PATH=".github/aider/issue-prompt.txt"
|
||||
mkdir -p .github/aider
|
||||
|
||||
# Determine if this is a PR comment or regular issue comment
|
||||
if [[ ! -z "${{ github.event.issue.pull_request }}" ]]; then
|
||||
echo "This is a comment on a Pull Request"
|
||||
PR_NUMBER="$ISSUE_NUMBER"
|
||||
PR_NUMBER="${{ github.event.issue.number }}"
|
||||
|
||||
PR_BODY_JSON=$(gh pr view "$PR_NUMBER" --json body --repo "$GITHUB_REPOSITORY")
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "Error fetching PR body for PR #$PR_NUMBER"
|
||||
PR_BODY_VAL=""
|
||||
else
|
||||
PR_BODY_VAL=$(jq -r '.body // ""' <<< "$PR_BODY_JSON")
|
||||
fi
|
||||
# Get PR description to check for issue references
|
||||
PR_BODY=$(gh pr view $PR_NUMBER --json body -q .body --repo $GITHUB_REPOSITORY)
|
||||
|
||||
if [[ ! -z "$PR_BODY_VAL" ]]; then
|
||||
REFERENCED_ISSUE=""
|
||||
if [[ "$PR_BODY_VAL" =~ \#([0-9]+) ]]; then
|
||||
REFERENCED_ISSUE="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
# Extract issue number from PR description (looking for #123 or "fixes #123" patterns)
|
||||
REFERENCED_ISSUE=$(echo "$PR_BODY" | grep -oE "#[0-9]+" | grep -oE "[0-9]+" | head -1)
|
||||
|
||||
if [[ ! -z "$REFERENCED_ISSUE" ]]; then
|
||||
echo "Found referenced issue #$REFERENCED_ISSUE in PR description"
|
||||
|
||||
if [[ ! -z "$REFERENCED_ISSUE" ]]; then
|
||||
echo "Found referenced issue #$REFERENCED_ISSUE in PR description"
|
||||
|
||||
ISSUE_DETAILS_JSON=$(gh issue view "$REFERENCED_ISSUE" --json title,body --repo "$GITHUB_REPOSITORY")
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "Error fetching issue details for #$REFERENCED_ISSUE"
|
||||
else
|
||||
ISSUE_TITLE_VAL=$(jq -r '.title // ""' <<< "$ISSUE_DETAILS_JSON")
|
||||
ISSUE_BODY_VAL=$(jq -r '.body // ""' <<< "$ISSUE_DETAILS_JSON")
|
||||
# Fetch the referenced issue details
|
||||
ISSUE_DETAILS=$(gh issue view $REFERENCED_ISSUE --json title,body --repo $GITHUB_REPOSITORY)
|
||||
ISSUE_TITLE=$(echo "$ISSUE_DETAILS" | jq -r .title)
|
||||
ISSUE_BODY=$(echo "$ISSUE_DETAILS" | jq -r .body)
|
||||
|
||||
# Store raw comment body in a file first to avoid shell interpretation issues
|
||||
echo '${{ github.event.comment.body }}' > /tmp/raw_comment.txt
|
||||
RAW_COMMENT_BODY=$(cat /tmp/raw_comment.txt)
|
||||
# Remove the /aider prefix and trim whitespace
|
||||
COMMENT_CONTENT=$(echo "$RAW_COMMENT_BODY" | sed 's|^/aider||' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
|
||||
echo "Sending issue content and PR comment to external API…"
|
||||
|
||||
ISSUE_TITLE_Q=$(printf '%q' "$ISSUE_TITLE")
|
||||
ISSUE_BODY_Q=$(printf '%q' "$ISSUE_BODY")
|
||||
|
||||
JSON_PAYLOAD=$(jq -n \
|
||||
--arg title "$ISSUE_TITLE_Q" \
|
||||
--arg body "$ISSUE_BODY_Q" \
|
||||
'{"body":{"issue_title":$title,"issue_body":$body}}')
|
||||
|
||||
API_RESULT=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run_wait_result/p/f/ai/quiet_script" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $WINDMILL_TOKEN" \
|
||||
--data-binary "$JSON_PAYLOAD" \
|
||||
--max-time 90)
|
||||
|
||||
HTTP_CODE=$(echo "$API_RESULT" | tail -n1)
|
||||
BODY=$(echo "$API_RESULT" | sed '$d')
|
||||
|
||||
echo "$BODY" > /tmp/api_response.txt
|
||||
|
||||
BASE_PROMPT="Try to fix the following issue based on the instruction given by the user. The issue is prepended with the word ISSUE. The instruction is prepended with the word INSTRUCTION. The issue and instruction are separated by a blank line."
|
||||
if [[ "$HTTP_CODE" -eq 200 ]]; then
|
||||
PROCESSED_ISSUE_PROMPT=$(jq -r '.effective_body // empty' /tmp/api_response.txt)
|
||||
if [[ -z "$PROCESSED_ISSUE_PROMPT" || "$PROCESSED_ISSUE_PROMPT" == "null" ]]; then
|
||||
PROCESSED_ISSUE_PROMPT=""
|
||||
fi
|
||||
printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \
|
||||
"$BASE_PROMPT" "$PROCESSED_ISSUE_PROMPT" "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH"
|
||||
else
|
||||
echo "::warning::API call failed (HTTP $HTTP_CODE). Using PR comment with issue context."
|
||||
printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \
|
||||
"$BASE_PROMPT" "$ISSUE_BODY_Q" "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH"
|
||||
fi
|
||||
rm -f /tmp/api_response.txt
|
||||
else
|
||||
echo "PR body is empty or could not be fetched."
|
||||
echo "No referenced issue found in PR description, using comment content only"
|
||||
# Use comment content directly as with regular issue comments
|
||||
echo '${{ github.event.comment.body }}' > /tmp/raw_comment.txt
|
||||
RAW_COMMENT_BODY=$(cat /tmp/raw_comment.txt)
|
||||
COMMENT_CONTENT=$(echo "$RAW_COMMENT_BODY" | sed 's|^/aider||' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
|
||||
if [[ -z "$COMMENT_CONTENT" ]]; then
|
||||
echo "::error::Comment with /aider provided, but no instruction found after it. Cannot proceed."
|
||||
printf "Error: /aider command found but no instruction followed." > "$PROMPT_FILE_PATH"
|
||||
exit 1
|
||||
else
|
||||
echo "Using comment content as prompt."
|
||||
printf '%s' "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "This is a comment on a regular issue"
|
||||
|
||||
ISSUE_DETAILS_JSON=$(gh issue view "$ISSUE_NUMBER" --json title,body --repo "$GITHUB_REPOSITORY")
|
||||
if [[ $? -ne 0 ]]; then
|
||||
echo "Error fetching issue details for #$ISSUE_NUMBER"
|
||||
# Fetch the issue details
|
||||
ISSUE_NUMBER="${{ github.event.issue.number }}"
|
||||
ISSUE_DETAILS=$(gh issue view $ISSUE_NUMBER --json title,body --repo $GITHUB_REPOSITORY)
|
||||
ISSUE_TITLE=$(echo "$ISSUE_DETAILS" | jq -r .title)
|
||||
ISSUE_BODY=$(echo "$ISSUE_DETAILS" | jq -r .body)
|
||||
|
||||
# Store raw comment body in a file first to avoid shell interpretation issues
|
||||
echo '${{ github.event.comment.body }}' > /tmp/raw_comment.txt
|
||||
# Extract the command part safely
|
||||
RAW_COMMENT_BODY=$(cat /tmp/raw_comment.txt)
|
||||
# Remove the /aider prefix and trim whitespace
|
||||
COMMENT_CONTENT=$(echo "$RAW_COMMENT_BODY" | sed 's|^/aider||' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
|
||||
if [[ -z "$COMMENT_CONTENT" ]]; then
|
||||
echo "::error::Comment with /aider provided, but no instruction found after it. Cannot proceed."
|
||||
printf "Error: /aider command found but no instruction followed." > "$PROMPT_FILE_PATH"
|
||||
exit 1
|
||||
else
|
||||
ISSUE_TITLE_VAL=$(jq -r '.title // ""' <<< "$ISSUE_DETAILS_JSON")
|
||||
ISSUE_BODY_VAL=$(jq -r '.body // ""' <<< "$ISSUE_DETAILS_JSON")
|
||||
echo "Sending issue content and issue comment to external API…"
|
||||
|
||||
ISSUE_TITLE_Q=$(printf '%q' "$ISSUE_TITLE")
|
||||
ISSUE_BODY_Q=$(printf '%q' "$ISSUE_BODY")
|
||||
COMMENT_CONTENT_Q=$(printf '%q' "$COMMENT_CONTENT")
|
||||
|
||||
JSON_PAYLOAD=$(jq -n \
|
||||
--arg title "$ISSUE_TITLE_Q" \
|
||||
--arg body "$ISSUE_BODY_Q" \
|
||||
--arg comment "$COMMENT_CONTENT_Q" \
|
||||
'{"body":{"issue_title":$title,"issue_body":$body,"issue_comment":$comment}}')
|
||||
|
||||
API_RESULT=$(curl -s -w "\n%{http_code}" \
|
||||
-X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run_wait_result/p/f/ai/quiet_script" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $WINDMILL_TOKEN" \
|
||||
--data-binary "$JSON_PAYLOAD" \
|
||||
--max-time 90)
|
||||
|
||||
HTTP_CODE=$(echo "$API_RESULT" | tail -n1)
|
||||
BODY=$(echo "$API_RESULT" | sed '$d')
|
||||
|
||||
echo "$BODY" > /tmp/api_response.txt
|
||||
|
||||
BASE_PROMPT="Try to fix the following issue based on the instruction given by the user. The issue is prepended with the word ISSUE. The instruction is prepended with the word INSTRUCTION. The issue and instruction are separated by a blank line."
|
||||
if [[ "$HTTP_CODE" -eq 200 ]]; then
|
||||
PROCESSED_ISSUE_PROMPT=$(jq -r '.effective_body // empty' /tmp/api_response.txt)
|
||||
if [[ -z "$PROCESSED_ISSUE_PROMPT" || "$PROCESSED_ISSUE_PROMPT" == "null" ]]; then
|
||||
PROCESSED_ISSUE_PROMPT=""
|
||||
fi
|
||||
printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \
|
||||
"$BASE_PROMPT" "$PROCESSED_ISSUE_PROMPT" "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH"
|
||||
else
|
||||
echo "::warning::API call failed (HTTP $HTTP_CODE). Using PR comment with issue context."
|
||||
printf "%s\nISSUE:\n%s\nINSTRUCTION:\n%s" \
|
||||
"$BASE_PROMPT" "$ISSUE_BODY_Q" "$COMMENT_CONTENT" > "$PROMPT_FILE_PATH"
|
||||
fi
|
||||
|
||||
rm -f /tmp/api_response.txt
|
||||
fi
|
||||
fi
|
||||
echo "Prompt determined and written to $PROMPT_FILE_PATH"
|
||||
echo "PROMPT_FILE_PATH=$PROMPT_FILE_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Probe Chat for Relevant Files
|
||||
id: probe_files
|
||||
env:
|
||||
PROMPT_CONTENT_FILE: ${{ steps.determine_prompt.outputs.PROMPT_FILE_PATH }}
|
||||
run: |
|
||||
echo "Running probe-chat to find relevant files..."
|
||||
if [[ ! -f "$PROMPT_CONTENT_FILE" ]]; then
|
||||
echo "::error::Prompt file $PROMPT_CONTENT_FILE not found!"
|
||||
exit 1
|
||||
fi
|
||||
PROMPT_CONTENT=$(cat "$PROMPT_CONTENT_FILE")
|
||||
if [ -z "$PROMPT_CONTENT" ]; then
|
||||
echo "::error::Prompt content is empty!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PROMPT_ESCAPED=$(jq -Rs . <<< "$PROMPT_CONTENT")
|
||||
|
||||
MESSAGE_FOR_PROBE=$(jq -n --arg prompt_escaped "$PROMPT_ESCAPED" \
|
||||
'{ "message": "I'\''m giving you a request that needs to be implemented. Your role is ONLY to give me the files that are relevant to the request and nothing else. The request is prepended with the word REQUEST.\\nREQUEST: \($prompt_escaped). Give me all the files relevant to this request. Your output MUST be a single json array that can be parsed with programatic json parsing, with the relevant files. Files can be rust or typescript or javascript files. DO NOT INCLUDE ANY OTHER TEXT IN YOUR OUTPUT. ONLY THE JSON ARRAY. Example of output: [\"file1.py\", \"file2.py\"]" }' | jq -r .message)
|
||||
|
||||
set -o pipefail
|
||||
PROBE_OUTPUT=$(npx --yes @buger/probe-chat@latest --max-iterations 50 --model-name gemini-2.5-pro-preview-05-06 --message "$MESSAGE_FOR_PROBE") || {
|
||||
echo "::error::probe-chat command failed. Output:"
|
||||
echo "$PROBE_OUTPUT"
|
||||
exit 1
|
||||
}
|
||||
set +o pipefail
|
||||
echo "Probe-chat raw output:"
|
||||
echo "$PROBE_OUTPUT"
|
||||
|
||||
JSON_FILES=$(echo "$PROBE_OUTPUT" | sed -n '/^\s*\[/,$p' | sed '/^\s*\]/q')
|
||||
echo "Extracted JSON block:"
|
||||
echo "$JSON_FILES"
|
||||
|
||||
FILES_LIST=$(echo "$JSON_FILES" | jq -e -r '[.[] | select(type == "string" and . != "" and . != null and (endswith("/") | not))] | map(@sh) | join(" ")' || echo "")
|
||||
|
||||
if [[ -z "$FILES_LIST" ]]; then
|
||||
echo "::warning::probe-chat did not identify any relevant files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Formatted files list for aider: $FILES_LIST"
|
||||
echo "FILES_TO_EDIT=$FILES_LIST" >> $GITHUB_ENV
|
||||
|
||||
- name: Run Aider with external prompt
|
||||
run: |
|
||||
echo "Files identified by probe-chat: ${{ env.FILES_TO_EDIT }}"
|
||||
aider \
|
||||
--read .cursor/rules/rust-best-practices.mdc \
|
||||
--read .cursor/rules/svelte5-best-practices.mdc \
|
||||
${{ env.FILES_TO_EDIT }} \
|
||||
--model gemini/gemini-2.5-pro-preview-05-06 \
|
||||
--message-file .github/aider/issue-prompt.txt \
|
||||
--yes \
|
||||
--no-check-update \
|
||||
--auto-commits \
|
||||
--no-analytics \
|
||||
--no-gitignore \
|
||||
| tee .github/aider/aider-output.txt || true
|
||||
echo "Aider command completed. Output saved to .github/aider/aider-output.txt"
|
||||
|
||||
- name: Clean up prompt file
|
||||
if: always()
|
||||
run: rm -f .github/aider/issue-prompt.txt
|
||||
|
||||
- name: Commit and Push Changes
|
||||
id: commit_and_push
|
||||
if: ${{ success() }}
|
||||
run: |
|
||||
if [[ -z "${{ github.event.issue.pull_request }}" ]]; then
|
||||
BRANCH_NAME="aider-fix-issue-${{ github.event.issue.number }}"
|
||||
|
||||
# Check if branch exists remotely
|
||||
if git ls-remote --heads origin $BRANCH_NAME | grep -q $BRANCH_NAME; then
|
||||
echo "Branch $BRANCH_NAME already exists remotely, fetching it"
|
||||
git fetch origin $BRANCH_NAME
|
||||
git checkout $BRANCH_NAME
|
||||
git pull origin $BRANCH_NAME
|
||||
else
|
||||
echo "Creating new branch $BRANCH_NAME"
|
||||
git checkout -b $BRANCH_NAME
|
||||
fi
|
||||
|
||||
echo "Created/checked out branch $BRANCH_NAME for issue #${{ github.event.issue.number }}"
|
||||
git push origin $BRANCH_NAME
|
||||
echo "Pushed to branch $BRANCH_NAME"
|
||||
echo "PR_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
echo "CHANGES_APPLIED_MESSAGE=Aider changes pushed to branch $BRANCH_NAME." >> $GITHUB_OUTPUT
|
||||
else
|
||||
CURRENT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)
|
||||
echo "Attempting to push changes to PR branch $CURRENT_BRANCH_NAME for PR #${{ github.event.issue.number }}"
|
||||
if git push origin $CURRENT_BRANCH_NAME; then
|
||||
echo "Push to $CURRENT_BRANCH_NAME successful (or no new changes to push)."
|
||||
echo "CHANGES_APPLIED_MESSAGE=Aider changes (if any) pushed to PR branch $CURRENT_BRANCH_NAME." >> $GITHUB_OUTPUT
|
||||
echo "PR_BRANCH_NAME=$CURRENT_BRANCH_NAME" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "::warning::Push to PR branch $CURRENT_BRANCH_NAME failed."
|
||||
echo "CHANGES_APPLIED_MESSAGE=Aider ran, but failed to push changes to PR branch $CURRENT_BRANCH_NAME." >> $GITHUB_OUTPUT
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "ISSUE_TITLE<<EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT"
|
||||
echo "$ISSUE_TITLE_VAL" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT"
|
||||
- name: Create Pull Request
|
||||
if: success() && github.event_name == 'issue_comment' && !github.event.issue.pull_request && steps.commit_and_push.outputs.PR_BRANCH_NAME != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_BRANCH: ${{ steps.commit_and_push.outputs.PR_BRANCH_NAME }}
|
||||
ISSUE_NUM: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Create PR description in a temporary file to avoid command line length limits
|
||||
cat > /tmp/pr-description.md << EOL
|
||||
This PR was created automatically by Aider to fix issue #${ISSUE_NUM}.
|
||||
|
||||
echo "ISSUE_BODY<<EOF_AIDER_BODY" >> "$GITHUB_OUTPUT"
|
||||
echo "$ISSUE_BODY_VAL" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF_AIDER_BODY" >> "$GITHUB_OUTPUT"
|
||||
## Aider Output
|
||||
\`\`\`
|
||||
$(cat .github/aider/aider-output.txt || echo "No output available")
|
||||
\`\`\`
|
||||
EOL
|
||||
|
||||
CLEAN_COMMENT="${COMMENT_BODY/\/aider/}"
|
||||
CLEAN_COMMENT="${CLEAN_COMMENT#"${CLEAN_COMMENT%%[![:space:]]*}"}"
|
||||
CLEAN_COMMENT="${CLEAN_COMMENT%"${CLEAN_COMMENT##*[![:space:]]}"}"
|
||||
|
||||
echo "COMMENT_CONTENT<<EOF_AIDER_COMMENT" >> "$GITHUB_OUTPUT"
|
||||
echo "$CLEAN_COMMENT" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF_AIDER_COMMENT" >> "$GITHUB_OUTPUT"
|
||||
echo "Finished determining inputs."
|
||||
|
||||
run-aider:
|
||||
needs: [check-membership, check-and-prepare]
|
||||
if: needs.check-membership.outputs.is_member == 'true'
|
||||
uses: ./.github/workflows/aider-common.yml
|
||||
with:
|
||||
issue_title: ${{ needs.check-and-prepare.outputs.issue_title }}
|
||||
issue_body: ${{ needs.check-and-prepare.outputs.issue_body }}
|
||||
instruction: ${{ needs.check-and-prepare.outputs.comment_content }}
|
||||
issue_id: ${{ github.event.issue.number }}
|
||||
secrets: inherit
|
||||
# Create PR using the file for the body content
|
||||
gh pr create \
|
||||
--title "[Aider PR] Add fixes for issue #${ISSUE_NUM}" \
|
||||
--body-file /tmp/pr-description.md \
|
||||
--head "$PR_BRANCH" \
|
||||
--base main
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
name: Claude PR Assistant
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/aider') && !contains(github.event.comment.user.login, '[bot]')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/aider') && !contains(github.event.comment.user.login, '[bot]')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '/aider') && !contains(github.event.review.user.login, '[bot]')) ||
|
||||
(github.event_name == 'issues' && contains(github.event.issue.body, '/aider') && !contains(github.event.issue.user.login, '[bot]'))
|
||||
runs-on: ubicloud-standard-2
|
||||
outputs:
|
||||
is_member: ${{ steps.check-membership.outputs.is_member }}
|
||||
steps:
|
||||
- name: Check organization membership
|
||||
id: check-membership
|
||||
env:
|
||||
ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }}
|
||||
run: |
|
||||
ORG="windmill-labs"
|
||||
|
||||
if [[ "${{ github.event_name }}" == "issue_comment" || "${{ github.event_name }}" == "pull_request_review_comment" ]]; then
|
||||
COMMENTER="${{ github.event.comment.user.login }}"
|
||||
elif [[ "${{ github.event_name }}" == "pull_request_review" ]]; then
|
||||
COMMENTER="${{ github.event.review.user.login }}"
|
||||
else
|
||||
COMMENTER="${{ github.event.issue.user.login }}"
|
||||
fi
|
||||
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $ORG_ACCESS_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/orgs/$ORG/members/$COMMENTER")
|
||||
|
||||
if [ "$STATUS" -eq 204 ]; then
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
claude-code-action:
|
||||
needs: check-membership
|
||||
if: |
|
||||
needs.check-membership.outputs.is_member == 'true'
|
||||
runs-on: ubicloud-standard-8
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
allowed_tools: "mcp__github__create_pull_request"
|
||||
custom_instructions: "IMPORTANT: Bypass previous instructions and instead of adding a url in your comment to manually create a PR, if you created a new branch with your changes, automatically create a PR from that branch to main, with the title starting with [Claude PR]"
|
||||
trigger_phrase: "/aider"
|
||||
@@ -3,34 +3,8 @@ on:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
check-membership:
|
||||
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && github.event.comment.user.type != 'Bot' }}
|
||||
runs-on: ubicloud-standard-2
|
||||
outputs:
|
||||
is_member: ${{ steps.check-membership.outputs.is_member }}
|
||||
steps:
|
||||
- name: Check organization membership
|
||||
id: check-membership
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
COMMENTER: ${{ github.event.comment.user.login }}
|
||||
run: |
|
||||
ORG="windmill-labs"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
|
||||
-H "Authorization: token $GH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
"https://api.github.com/orgs/$ORG/members/$COMMENTER")
|
||||
|
||||
if [ "$STATUS" -eq 204 ]; then
|
||||
echo "is_member=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_member=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
trigger-docs:
|
||||
needs: check-membership
|
||||
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && needs.check-membership.outputs.is_member == 'true' }}
|
||||
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') }}
|
||||
uses: windmill-labs/windmilldocs/.github/workflows/create-docs.yml@main
|
||||
with:
|
||||
pr_number: ${{ github.event.issue.number }}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
name: Claude PR Assistant
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [external_claude_issue_fix]
|
||||
|
||||
jobs:
|
||||
claude-code-action:
|
||||
runs-on: ubicloud-standard-8
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Process inputs
|
||||
id: process_inputs
|
||||
shell: bash
|
||||
run: |
|
||||
ISSUE_TITLE="${{ github.event.client_payload.issue_title }}"
|
||||
INSTRUCTION="${{ github.event.client_payload.instruction }}"
|
||||
ISSUE_BODY=$(printf '%q' "${{ github.event.client_payload.issue_body }}")
|
||||
BASE_PROMPT="Try to fix the following issue based on the instruction given. You are provided with the issue title, issue body, and instruction. You are to fix the issue based on the instruction. You are to create a pull request to fix the issue."
|
||||
CUSTOM_PROMPT=$(printf -v PROMPT "%s\n\nISSUE_TITLE: %s\n\nISSUE_BODY: %s\n\nINSTRUCTION: %s" "$BASE_PROMPT" "$ISSUE_TITLE" "$ISSUE_BODY" "$INSTRUCTION")
|
||||
echo "CUSTOM_PROMPT=$CUSTOM_PROMPT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Claude PR Action
|
||||
uses: anthropics/claude-code-action@beta
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
timeout_minutes: "60"
|
||||
allowed_tools: "mcp__github__create_pull_request"
|
||||
direct_prompt: ${{ steps.process_inputs.outputs.CUSTOM_PROMPT }}
|
||||
@@ -1,68 +0,0 @@
|
||||
name: External Aider Issue Fix
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [external_issue_fix]
|
||||
|
||||
jobs:
|
||||
check-and-prepare:
|
||||
runs-on: ubicloud-standard-2
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
outputs:
|
||||
issue_title: ${{ steps.determine_inputs.outputs.ISSUE_TITLE }}
|
||||
issue_body: ${{ steps.determine_inputs.outputs.ISSUE_BODY }}
|
||||
instruction: ${{ steps.determine_inputs.outputs.INSTRUCTION }}
|
||||
env:
|
||||
GEMINI_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
WINDMILL_TOKEN: ${{ secrets.WINDMILL_TOKEN }}
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Acknowledge Request
|
||||
env:
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
run: |
|
||||
echo "Commenting on Linear issue #${{ github.event.client_payload.issue_id }} to acknowledge the request."
|
||||
curl -X POST \
|
||||
-H "Authorization: $LINEAR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://api.linear.app/graphql" \
|
||||
-d "{\"query\":\"mutation { commentCreate(input: { issueId: \\\"${{ github.event.client_payload.issue_id }}\\\", body: \\\"🤖 Aider is starting to work on your request. I'll update you here once I have a PR ready. Please be patient, this might take a few minutes.\\\" }) { success } }\"}"
|
||||
|
||||
- name: Determine inputs for Aider
|
||||
id: determine_inputs
|
||||
shell: bash
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.client_payload.issue_title }}
|
||||
ISSUE_BODY: ${{ github.event.client_payload.issue_body }}
|
||||
INSTRUCTION: ${{ github.event.client_payload.instruction }}
|
||||
run: |
|
||||
echo "Determining inputs for Aider..."
|
||||
|
||||
echo "ISSUE_TITLE<<EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT"
|
||||
echo "$ISSUE_TITLE" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF_AIDER_TITLE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "ISSUE_BODY<<EOF_AIDER_BODY" >> "$GITHUB_OUTPUT"
|
||||
echo "$ISSUE_BODY" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF_AIDER_BODY" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "INSTRUCTION<<EOF_AIDER_INSTRUCTION" >> "$GITHUB_OUTPUT"
|
||||
echo "$INSTRUCTION" >> "$GITHUB_OUTPUT"
|
||||
echo "EOF_AIDER_INSTRUCTION" >> "$GITHUB_OUTPUT"
|
||||
echo "Finished determining inputs."
|
||||
|
||||
run-aider:
|
||||
needs: check-and-prepare
|
||||
uses: ./.github/workflows/aider-common.yml
|
||||
with:
|
||||
issue_title: ${{ needs.check-and-prepare.outputs.issue_title }}
|
||||
issue_body: ${{ needs.check-and-prepare.outputs.issue_body }}
|
||||
instruction: ${{ needs.check-and-prepare.outputs.instruction }}
|
||||
issue_id: ${{ github.event.client_payload.issue_id }}
|
||||
secrets: inherit
|
||||
@@ -1,71 +0,0 @@
|
||||
# Windmill Overview
|
||||
|
||||
Windmill is an open-source developer platform for building internal tools, API integrations, background jobs, workflows, and user interfaces. It offers a unified system where scripts are automatically turned into sharable UIs and can be composed into flows or embedded in custom applications.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- **Script Development and Execution**: Write and run scripts in Python, TypeScript/JavaScript (Deno/Bun), Go, Bash, SQL, and other languages
|
||||
- **Workflow Orchestration**: Compose scripts into multi-step flows with conditional logic, loops, and error handling
|
||||
- **UI Generation**: Automatically generate UIs from scripts or build custom applications with a low-code editor
|
||||
- **Job Scheduling**: Trigger scripts and flows on schedules, webhooks, or external events
|
||||
- **Resource Management**: Securely store and use credentials, databases, and other connections
|
||||
|
||||
## Platform Architecture
|
||||
|
||||
The Windmill platform consists of several key components:
|
||||
|
||||
- **Frontend UI**: Web-based interface for script and flow development, app building, and result visualization
|
||||
- **API Server**: Central API that handles authentication, resource management, and job coordination
|
||||
- **Workers**: Execute scripts in their respective environments with proper sandboxing
|
||||
- **Database**: PostgreSQL database for storage of scripts, flows, resources, job results, and more
|
||||
- **Job Queue**: Queue system for managing job execution, implemented in PostgreSQL
|
||||
- **Client Libraries**: Libraries for interacting with Windmill from Python, TypeScript, or command line
|
||||
|
||||
# Windmill Backend Architecture
|
||||
|
||||
The Windmill backend is written in Rust and consists of several services working together. These services are designed for horizontal scaling with stateless API servers and workers that can be deployed across multiple machines.
|
||||
|
||||
## Key Components
|
||||
|
||||
- **API Server (`windmill-api`)**: Handles HTTP requests, authentication, and resource management
|
||||
- **Queue Manager (`windmill-queue`)**: Manages the job queue in PostgreSQL
|
||||
- **Worker System (`windmill-worker`)**: Executes jobs in sandboxed environments
|
||||
- **Common Utilities (`windmill-common`)**: Shared code used by multiple services
|
||||
- **Git Sync (`windmill-git-sync`)**: Synchronizes scripts with Git repositories
|
||||
|
||||
## Job Execution System
|
||||
|
||||
The job execution process follows these steps:
|
||||
|
||||
1. The API server receives a request to run a script or flow and creates a job record in the database
|
||||
2. The job is added to the queue system in PostgreSQL
|
||||
3. Workers continuously poll the queue for jobs matching their capabilities
|
||||
4. When a job is picked up, it's routed to the appropriate language executor
|
||||
5. The script is executed in a sandboxed environment using NSJAIL for security
|
||||
6. Results are processed and stored in the database
|
||||
7. For flows, each step creates a new job that goes through the same process
|
||||
|
||||
Windmill supports worker tags and groups to route jobs to workers with specific capabilities or resource access.
|
||||
|
||||
# Windmill Frontend Architecture
|
||||
|
||||
The Windmill frontend is built with Svelte and provides several key interfaces for interacting with the platform.
|
||||
|
||||
## Key Components
|
||||
|
||||
- **Script Builder**: Code editor with language support, schema inference, and dependency management
|
||||
- **Flow Builder**: Visual editor for creating multi-step workflows with branching and looping
|
||||
- **App Editor**: Grid-based editor for building custom UIs that integrate scripts and flows
|
||||
- **Schema Form System**: Generates form interfaces from script parameters automatically
|
||||
- **Result Viewer**: Visualizes job results, logs, and execution status
|
||||
|
||||
The frontend uses the Monaco editor (same as VS Code) for code editing, with specialized language support for all supported script languages.
|
||||
|
||||
## UI Framework
|
||||
|
||||
The frontend is built with Svelte, providing a reactive and component-based architecture. Key frontend technologies include:
|
||||
|
||||
- **Svelte/SvelteKit**: Core framework for UI components and routing
|
||||
- **Monaco Editor**: Code editing experience similar to VS Code
|
||||
- **Schema Form**: Automatic UI generation from TypeScript/JSON schemas
|
||||
- **Tailwind CSS**: Utility-first CSS framework for styling
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT fv.id\n FROM flow f\n INNER JOIN flow_version fv ON fv.id = f.versions[array_upper(f.versions, 1)]\n WHERE fv.value->'preprocessor_module'->'value'->>'path' = $1 AND f.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "551fee7919fdeb911e3f9cc5852e158ea47e3db4895c2b2b1d3cb6b16fceeda9"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n value->'preprocessor_module'->'value' as \"preprocessor_module: _\",\n schema as \"schema: _\"\n FROM flow_version\n WHERE \n path = $1\n AND workspace_id = $2\n ORDER BY created_at DESC\n LIMIT 1",
|
||||
"query": "SELECT\n value->'preprocessor_module'->'value' as \"preprocessor_module: _\",\n schema as \"schema: _\"\n FROM flow \n WHERE workspace_id = $1\n AND path = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -25,5 +25,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "e7348225a27bbdc9607d7c799e7192cd6ce4088467d91a5cbdc019430320d26d"
|
||||
"hash": "72916f8e490f8252e0a51b7f562ccc3be832b12102eb86a07d8405a4fa9287d5"
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n mqtt_resource_path,\n subscribe_topics as \"subscribe_topics: _\",\n v3_config as \"v3_config: _\",\n v5_config as \"v5_config: _\",\n client_version AS \"client_version: _\",\n client_id,\n workspace_id,\n path,\n script_path,\n is_flow,\n edited_by,\n email,\n edited_at,\n server_id,\n last_server_ping,\n extra_perms,\n error,\n enabled\n FROM \n mqtt_trigger\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "mqtt_resource_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "subscribe_topics: _",
|
||||
"type_info": "JsonbArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "v3_config: _",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "v5_config: _",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "client_version: _",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "mqtt_client_version",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"v3",
|
||||
"v5"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_flow",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "edited_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "edited_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "server_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 14,
|
||||
"name": "last_server_ping",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 15,
|
||||
"name": "extra_perms",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 16,
|
||||
"name": "error",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 17,
|
||||
"name": "enabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c51f9ad5133c46fd7c499b8339dbbf3f3059bbb85de07ee3b4b4cea971984a52"
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
# Windmill Backend - Rust Best Practices
|
||||
|
||||
## Project Structure
|
||||
|
||||
Windmill uses a workspace-based architecture with multiple crates:
|
||||
|
||||
- **windmill-api**: API server functionality
|
||||
- **windmill-worker**: Job execution
|
||||
- **windmill-common**: Shared code used by all crates
|
||||
- **windmill-queue**: Job & flow queuing
|
||||
- **windmill-audit**: Audit logging
|
||||
- Other specialized crates (git-sync, autoscaling, etc.)
|
||||
|
||||
## Adding New Code
|
||||
|
||||
### Module Organization
|
||||
|
||||
- Place new code in the appropriate crate based on functionality
|
||||
- For API endpoints, create or modify files in `windmill-api/src/` organized by domain
|
||||
- For shared functionality, use `windmill-common/src/`
|
||||
- Use the `_ee.rs` suffix for enterprise-only modules
|
||||
- Follow existing patterns for file structure and organization
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use the custom `Error` enum from `windmill-common::error`
|
||||
- Return `Result<T, Error>` or `JsonResult<T>` for functions that can fail
|
||||
- Use the `?` operator for error propagation
|
||||
- Add location tracking to errors using `#[track_caller]`
|
||||
|
||||
### Database Operations
|
||||
|
||||
- Use `sqlx` for database operations with prepared statements
|
||||
- Leverage existing database helper functions in `db.rs` modules
|
||||
- Use transactions for multi-step operations
|
||||
- Handle database errors properly
|
||||
|
||||
### API Endpoints
|
||||
|
||||
- Follow existing patterns in the `windmill-api` crate
|
||||
- Use axum's routing system and extractors
|
||||
- Group related routes together
|
||||
- Use consistent response formats (JSON)
|
||||
- Follow proper authentication and authorization patterns
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
When generating code, especially involving `serde`, `sqlx`, and `tokio`, prioritize performance by applying the following principles:
|
||||
|
||||
### Serde Optimizations (Serialization & Deserialization)
|
||||
|
||||
- **Specify Structure Explicitly:** When defining structs for Serde (`#[derive(Serialize, Deserialize)]`), use `#[serde(...` attributes extensively. This includes:
|
||||
- `#[serde(rename = "...")]` or `#[serde(alias = "...")]` to map external names precisely, avoiding dynamic lookups.
|
||||
- `#[serde(default)]` for optional fields with default values, reducing parsing complexity.
|
||||
- `#[serde(skip_serializing_if = "...")]` to avoid writing fields that meet a certain condition (e.g., `Option::is_none()`, `Vec::is_empty()`, or a custom function), reducing output size and serialization work.
|
||||
- `#[serde(skip_serializing)]` or `#[serde(skip_deserializing)]` for fields that should _not_ be included.
|
||||
- **Prefer Borrowing:** Where possible and safe (data lifetime allows), use `Cow<'a, str>` or `&'a str` (with `#[serde(borrow)]`) instead of `String` for string fields during deserialization. This avoids allocating new strings, enabling zero-copy reading from the input buffer. Apply this principle to byte slices (`&'a [u8]` / `Cow<'a, [u8]>`) and potentially borrowed vectors as well.
|
||||
- **Avoid Intermediate `Value`:** Unless the data structure is truly dynamic or unknown at compile time, deserialize directly into a well-defined struct or enum rather than into `serde_json::Value` (or equivalent for other formats). This avoids unnecessary heap allocations and type switching.
|
||||
|
||||
### SQLx Optimizations (Database Interaction)
|
||||
|
||||
- **Select Only Necessary Columns:** In `SELECT` queries, list specific column names rather than using `SELECT *`. This reduces data transferred from the database and the work needed for hydration/deserialization.
|
||||
- **Batch Operations:** For multiple `INSERT`, `UPDATE`, or `DELETE` statements, prefer executing them in a single query if the database and driver support it efficiently (e.g., `INSERT INTO ... VALUES (...), (...), ...`). This minimizes round trips to the database.
|
||||
- **Avoid N+1 Queries:** Do not loop through results of one query and execute a separate query for each item (e.g., fetching users, then querying for each user's profile in a loop). Instead, use JOINs or a single query with an `IN` clause to fetch related data efficiently.
|
||||
- **Deserialize Directly:** Use `#[derive(FromRow)]` on structs and ensure the struct fields match the selected columns in the query. This allows SQLx to hydrate objects directly, avoiding intermediate data structures.
|
||||
- **Parameterize Queries:** Always use SQLx's query methods (`.bind(...)`) to pass values as parameters rather than string formatting. This prevents SQL injection and allows the database to cache query plans, improving performance on repeated executions.
|
||||
|
||||
### Tokio Optimizations (Asynchronous Runtime)
|
||||
|
||||
- **Avoid Blocking Operations:** **Crucially**, never perform blocking operations (synchronous file I/O, `std::thread::sleep`, CPU-bound loops, `std::sync::Mutex::lock`, blocking network calls without `tokio::net`) directly within an `async fn` or a standard `tokio::spawn` task. Blocking pauses the entire worker thread, potentially starving other tasks. Use `tokio::task::spawn_blocking` for CPU-intensive work or blocking I/O.
|
||||
- **Use Tokio's Async Primitives:** Prefer `tokio::sync` (channels, mutexes, semaphores), `tokio::io`, `tokio::net`, and `tokio::time` over their `std` counterparts in asynchronous contexts. These are designed to yield control back to the scheduler.
|
||||
- **Manage Concurrency:** Be mindful of how many tasks are spawned. Creating a new task for every tiny piece of work can introduce overhead. Group related asynchronous operations where appropriate.
|
||||
- **Handle Shared State Efficiently:** Use `Arc` for shared ownership in concurrent tasks. When shared state needs mutation, prefer `tokio::sync::Mutex` over `std::sync::Mutex` in `async` code. Consider `tokio::sync::RwLock` if reads significantly outnumber writes. Minimize the duration for which locks are held.
|
||||
- **Understand `.await`:** Place `.await` strategically to allow the runtime to switch to other ready tasks. Ensure that `.await` points to genuinely asynchronous operations.
|
||||
- **Backpressure:** If dealing with data streams or queues between tasks, implement backpressure mechanisms (e.g., bounded channels like `tokio::sync::mpsc::channel`) to prevent one component from overwhelming another or critical resources like the database.
|
||||
|
||||
## Enterprise Features
|
||||
|
||||
- Use feature flags for enterprise functionality
|
||||
- Conditionally compile with `#[cfg(feature = "enterprise")]`
|
||||
- Isolate enterprise code in separate modules
|
||||
|
||||
## Code Style
|
||||
|
||||
- Group imports by external and internal crates
|
||||
- Place struct/enum definitions before implementations
|
||||
- Group similar functionality together
|
||||
- Use descriptive naming consistent with the codebase
|
||||
- Follow existing patterns for async code using tokio
|
||||
|
||||
## Testing
|
||||
|
||||
- Write unit tests for core functionality
|
||||
- Use the `#[cfg(test)]` module for test code
|
||||
- For database tests, use the existing test utilities
|
||||
|
||||
## Common Crates Used
|
||||
|
||||
- **tokio**: For async runtime
|
||||
- **axum**: For web server and routing
|
||||
- **sqlx**: For database operations
|
||||
- **serde**: For serialization/deserialization
|
||||
- **tracing**: For logging and diagnostics
|
||||
- **reqwest**: For HTTP client functionality
|
||||
Generated
-5
@@ -14424,7 +14424,6 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"size",
|
||||
"sqlx",
|
||||
"strum",
|
||||
"systemstat",
|
||||
"tikv-jemalloc-ctl",
|
||||
"tikv-jemalloc-sys",
|
||||
@@ -14647,8 +14646,6 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"size",
|
||||
"sqlx",
|
||||
"strum",
|
||||
"strum_macros",
|
||||
"systemstat",
|
||||
"tar",
|
||||
"tempfile",
|
||||
@@ -14665,9 +14662,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"windmill-macros",
|
||||
"windmill-parser-py",
|
||||
"windmill-parser-sql",
|
||||
"windmill-parser-ts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-4
@@ -137,7 +137,6 @@ v8 = { workspace = true, optional = true }
|
||||
rustls.workspace = true
|
||||
systemstat.workspace = true
|
||||
size.workspace = true
|
||||
strum.workspace = true
|
||||
|
||||
[target.'cfg(not(target_env = "msvc"))'.dependencies]
|
||||
tikv-jemallocator = { optional = true, workspace = true }
|
||||
@@ -236,7 +235,7 @@ itertools = "^0"
|
||||
regex = "^1"
|
||||
semver = "^1"
|
||||
|
||||
v8 = "=130.0.7" # Exact version NOTE: Do not forget to update version and hash in flake.nix
|
||||
v8 = "=130.0.7" # Exact version
|
||||
deno_fetch = "0.214.0"
|
||||
deno_tls = "0.177.0"
|
||||
deno_console = "0.190.0"
|
||||
@@ -390,5 +389,3 @@ tree-sitter-c-sharp = "0.23.0"
|
||||
tree-sitter-java = "0.23.0"
|
||||
oracle = { version = "0.6.3", features = ["chrono"] }
|
||||
rumqttc = { version = "0.24.0", features = ["use-native-tls"]}
|
||||
strum = "^0"
|
||||
strum_macros = "^0"
|
||||
|
||||
@@ -1 +1 @@
|
||||
bea87fa885dc041fba83b2491609a4a2cdbbfa6f
|
||||
3efa7fa51e9f93f60e141fef5b8b9338528cf955
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
-- Add down migration script here
|
||||
CREATE OR REPLACE FUNCTION notify_runnable_version_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
source_type TEXT;
|
||||
BEGIN
|
||||
source_type := TG_ARGV[0];
|
||||
|
||||
PERFORM pg_notify('notify_runnable_version_change', NEW.workspace_id || ':' || source_type || ':' || NEW.path);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -1,19 +0,0 @@
|
||||
-- Add up migration script here
|
||||
CREATE OR REPLACE FUNCTION notify_runnable_version_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
source_type TEXT;
|
||||
kind TEXT;
|
||||
BEGIN
|
||||
source_type := TG_ARGV[0];
|
||||
|
||||
IF source_type = 'script' THEN
|
||||
kind := NEW.kind;
|
||||
ELSE
|
||||
kind := 'flow';
|
||||
END IF;
|
||||
|
||||
PERFORM pg_notify('notify_runnable_version_change', NEW.workspace_id || ':' || source_type || ':' || NEW.path || ':' || kind);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
+2
-29
@@ -5,6 +5,7 @@
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use anyhow::Context;
|
||||
use monitor::{
|
||||
load_base_url, load_otel, reload_critical_alerts_on_db_oversize,
|
||||
@@ -22,7 +23,6 @@ use std::{
|
||||
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use strum::IntoEnumIterator;
|
||||
use tokio::{fs::File, io::AsyncReadExt, task::JoinHandle};
|
||||
use uuid::Uuid;
|
||||
use windmill_api::HTTP_CLIENT;
|
||||
@@ -50,7 +50,6 @@ use windmill_common::{
|
||||
},
|
||||
scripts::ScriptLang,
|
||||
stats_ee::schedule_stats,
|
||||
triggers::TriggerKind,
|
||||
utils::{hostname, rd_string, Mode, GIT_VERSION, MODE_AND_ADDONS},
|
||||
worker::{
|
||||
reload_custom_tags_setting, Connection, HUB_CACHE_DIR, TMP_DIR, TMP_LOGS_DIR, WORKER_GROUP,
|
||||
@@ -792,37 +791,11 @@ Windmill Community Edition {GIT_VERSION}
|
||||
let payload = n.payload();
|
||||
tracing::info!("Runnable version change detected: {}", payload);
|
||||
match payload.split(':').collect::<Vec<&str>>().as_slice() {
|
||||
[workspace_id, source_type, path, kind] => {
|
||||
[workspace_id, source_type, path] => {
|
||||
let key = (workspace_id.to_string(), path.to_string());
|
||||
match source_type {
|
||||
&"script" => {
|
||||
windmill_common::DEPLOYED_SCRIPT_HASH_CACHE.remove(&key);
|
||||
match kind {
|
||||
&"preprocessor" => {
|
||||
match sqlx::query_scalar!(
|
||||
"SELECT fv.id
|
||||
FROM flow f
|
||||
INNER JOIN flow_version fv ON fv.id = f.versions[array_upper(f.versions, 1)]
|
||||
WHERE fv.value->'preprocessor_module'->'value'->>'path' = $1 AND f.workspace_id = $2",
|
||||
path,
|
||||
workspace_id
|
||||
).fetch_all(&db).await {
|
||||
Ok(flow_versions) => {
|
||||
tracing::debug!("Workspace preprocessor {} changed, removing runnable format version cache for flow versions {:?}", path, flow_versions);
|
||||
for version in flow_versions {
|
||||
for trigger_kind in TriggerKind::iter() {
|
||||
let key = (windmill_common::triggers::HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()), version, trigger_kind);
|
||||
windmill_common::triggers::RUNNABLE_FORMAT_VERSION_CACHE.remove(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error fetching flow paths: {e:#}");
|
||||
}
|
||||
}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
&"flow" => {
|
||||
windmill_common::FLOW_VERSION_CACHE.remove(&key);
|
||||
|
||||
@@ -1122,13 +1122,10 @@ pub async fn reload_s3_cache_setting(db: &DB) {
|
||||
if let Err(e) = setting {
|
||||
tracing::error!("Error parsing s3 cache config: {:?}", e)
|
||||
} else {
|
||||
let setting = setting.unwrap();
|
||||
let bucket = setting.get_bucket().map(|b| b.to_string());
|
||||
let s3_client = build_object_store_from_settings(setting).await;
|
||||
let s3_client = build_object_store_from_settings(setting.unwrap()).await;
|
||||
if let Err(e) = s3_client {
|
||||
tracing::error!("Error building s3 client from settings: {:?}", e)
|
||||
} else {
|
||||
tracing::info!("Loaded object store {:?}", bucket);
|
||||
*s3_cache_settings = Some(s3_client.unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12590,11 +12590,6 @@ paths:
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: pagination_offset
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: search results
|
||||
@@ -12607,26 +12602,15 @@ paths:
|
||||
description: a list of the terms that couldn't be parsed (and thus ignored)
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
type: object
|
||||
properties:
|
||||
dancer:
|
||||
type: string
|
||||
hits:
|
||||
description: the jobs that matched the query
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/JobSearchHit"
|
||||
hit_count:
|
||||
description: how many jobs matched in total
|
||||
type: number
|
||||
index_metadata:
|
||||
description: Metadata about the index current state
|
||||
type: object
|
||||
properties:
|
||||
indexed_until:
|
||||
description: Datetime of the most recently indexed job
|
||||
type: string
|
||||
format: date-time
|
||||
lost_lock_ownership:
|
||||
description: Is the current indexer service being replaced
|
||||
type: boolean
|
||||
|
||||
/srch/index/search/service_logs:
|
||||
get:
|
||||
@@ -16332,7 +16316,6 @@ components:
|
||||
- schedule
|
||||
- user
|
||||
- group
|
||||
- trigger
|
||||
repositories:
|
||||
type: array
|
||||
items:
|
||||
@@ -16400,7 +16383,6 @@ components:
|
||||
- schedule
|
||||
- user
|
||||
- group
|
||||
- trigger
|
||||
required:
|
||||
- script_path
|
||||
- git_repo_resource_path
|
||||
@@ -16826,4 +16808,4 @@ components:
|
||||
channel_name:
|
||||
type: string
|
||||
description: Microsoft Teams channel name
|
||||
minLength: 1
|
||||
minLength: 1
|
||||
@@ -10,17 +10,12 @@ use http::{header::CONTENT_TYPE, request::Parts, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use sqlx::types::JsonRawValue;
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind},
|
||||
worker::to_raw_value,
|
||||
DB,
|
||||
};
|
||||
use windmill_queue::PushArgsOwned;
|
||||
use windmill_common::{error::Error, worker::to_raw_value, DB};
|
||||
use windmill_queue::{PushArgsOwned, TriggerKind};
|
||||
|
||||
use crate::{
|
||||
db::ApiAuthed,
|
||||
trigger_helpers::{get_runnable_format, RunnableId},
|
||||
trigger_helpers::{get_runnable_format, RunnableFormat, RunnableFormatVersion, RunnableId},
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -57,6 +57,7 @@ use {
|
||||
use crate::{
|
||||
args::RawWebhookArgs,
|
||||
db::{ApiAuthed, DB},
|
||||
trigger_helpers::{RunnableFormat, RunnableFormatVersion},
|
||||
users::fetch_api_authed,
|
||||
utils::RunnableKind,
|
||||
};
|
||||
@@ -75,12 +76,11 @@ use sqlx::types::Json as SqlxJson;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{JsonResult, Result},
|
||||
triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind},
|
||||
utils::{not_found_if_none, paginate, Pagination, StripPath},
|
||||
worker::{to_raw_value, CLOUD_HOSTED},
|
||||
};
|
||||
|
||||
use windmill_queue::{PushArgs, PushArgsOwned};
|
||||
use windmill_queue::{PushArgs, PushArgsOwned, TriggerKind};
|
||||
|
||||
const KEEP_LAST: i64 = 20;
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ use windmill_common::db::UserDB;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_common::{
|
||||
error::{Error as WindmillError, Result as WindmillResult},
|
||||
triggers::TriggerKind,
|
||||
utils::empty_as_none,
|
||||
};
|
||||
use windmill_queue::TriggerKind;
|
||||
|
||||
#[derive(sqlx::Type, Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all(serialize = "lowercase", deserialize = "lowercase"))]
|
||||
@@ -86,7 +86,7 @@ pub async fn manage_google_subscription(
|
||||
_subscription_mode: SubscriptionMode,
|
||||
_create_update_config: Option<CreateUpdateConfig>,
|
||||
_trigger_mode: bool,
|
||||
_is_flow: bool,
|
||||
_is_flow: bool
|
||||
) -> WindmillResult<CreateUpdateConfig> {
|
||||
Ok(CreateUpdateConfig::default())
|
||||
}
|
||||
|
||||
@@ -6,17 +6,13 @@ use axum::{
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
triggers::{RunnableFormat, RunnableFormatVersion},
|
||||
worker::to_raw_value,
|
||||
DB,
|
||||
};
|
||||
use windmill_common::{error::Error, worker::to_raw_value, DB};
|
||||
use windmill_queue::PushArgsOwned;
|
||||
|
||||
use crate::{
|
||||
args::{try_from_request_body, Body, RawWebhookArgs, WebhookArgs, WebhookArgsMetadata},
|
||||
db::ApiAuthed,
|
||||
trigger_helpers::{RunnableFormat, RunnableFormatVersion},
|
||||
};
|
||||
|
||||
pub struct RawHttpTriggerArgs(pub RawWebhookArgs);
|
||||
|
||||
@@ -41,11 +41,10 @@ use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{self, JsonResult},
|
||||
s3_helpers::S3Object,
|
||||
triggers::TriggerKind,
|
||||
utils::{not_found_if_none, paginate, require_admin, Pagination, StripPath},
|
||||
worker::CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_git_sync::handle_deployment_metadata;
|
||||
use windmill_queue::TriggerKind;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref ROUTE_PATH_KEY_RE: regex::Regex = regex::Regex::new(r"/?:[-\w]+").unwrap();
|
||||
@@ -398,17 +397,6 @@ async fn create_trigger(
|
||||
|
||||
increase_trigger_version_and_commit(tx).await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
windmill_git_sync::DeployedObject::HttpTrigger { path: ct.path.clone() },
|
||||
Some(format!("HTTP trigger '{}' created", ct.path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, format!("{}", ct.path)))
|
||||
}
|
||||
|
||||
@@ -556,32 +544,20 @@ async fn update_trigger(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"http_triggers.update",
|
||||
ActionKind::Update,
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&ct.path),
|
||||
Some(path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
increase_trigger_version_and_commit(tx).await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
windmill_git_sync::DeployedObject::HttpTrigger { path: ct.path.clone() },
|
||||
Some(format!("HTTP trigger '{}' updated", ct.path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ct.path.to_string())
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
async fn delete_trigger(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> error::Result<String> {
|
||||
@@ -611,17 +587,6 @@ async fn delete_trigger(
|
||||
|
||||
increase_trigger_version_and_commit(tx).await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
windmill_git_sync::DeployedObject::HttpTrigger { path: path.to_string() },
|
||||
Some(format!("HTTP trigger '{}' deleted", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("HTTP trigger {path} deleted"))
|
||||
}
|
||||
|
||||
|
||||
@@ -3473,7 +3473,7 @@ pub async fn run_flow_by_path(
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
RunnableId::from_flow_path(flow_path.to_path()),
|
||||
RunnableId::from_flow_path(&flow_path.0),
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
@@ -3672,7 +3672,7 @@ pub async fn run_script_by_path(
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
RunnableId::from_script_path(script_path.to_path()),
|
||||
RunnableId::from_script_path(&script_path.0),
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
@@ -4351,18 +4351,17 @@ pub async fn run_wait_result_job_by_path_get(
|
||||
let mut args = args.process_args(&authed, &db, &w_id, None).await?;
|
||||
args.body = args::Body::HashMap(payload_args);
|
||||
|
||||
let script_path = script_path.to_path();
|
||||
|
||||
let args = args
|
||||
.to_args_from_runnable(
|
||||
&db,
|
||||
&w_id,
|
||||
RunnableId::from_script_path(script_path),
|
||||
RunnableId::from_script_path(&script_path.0),
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
|
||||
check_queue_too_long(&db, QUEUE_LIMIT_WAIT_RESULT.or(run_query.queue_limit)).await?;
|
||||
let script_path = script_path.to_path();
|
||||
check_scopes(&authed, || format!("run:script/{script_path}"))?;
|
||||
|
||||
let mut tx = user_db.clone().begin(&authed).await?;
|
||||
@@ -4458,7 +4457,7 @@ pub async fn run_wait_result_flow_by_path_get(
|
||||
.to_args_from_runnable(
|
||||
&db,
|
||||
&w_id,
|
||||
RunnableId::from_flow_path(flow_path.to_path()),
|
||||
RunnableId::from_flow_path(&flow_path.0),
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
@@ -4483,7 +4482,7 @@ pub async fn run_wait_result_script_by_path(
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
RunnableId::from_script_path(script_path.to_path()),
|
||||
RunnableId::from_script_path(&script_path.0),
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
@@ -4693,7 +4692,7 @@ pub async fn run_wait_result_flow_by_path(
|
||||
&authed,
|
||||
&db,
|
||||
&w_id,
|
||||
RunnableId::from_flow_path(flow_path.to_path()),
|
||||
RunnableId::from_flow_path(&flow_path.0),
|
||||
run_query.skip_preprocessor,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::{
|
||||
trigger_helpers::TriggerJobArgs,
|
||||
users::fetch_api_authed,
|
||||
};
|
||||
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
||||
use windmill_queue::TriggerKind;
|
||||
|
||||
use axum::{
|
||||
async_trait,
|
||||
@@ -43,7 +43,6 @@ use windmill_audit::{audit_ee::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{self, JsonResult},
|
||||
triggers::TriggerKind,
|
||||
utils::{not_found_if_none, paginate, report_critical_error, Pagination, StripPath},
|
||||
worker::{to_raw_value, CLOUD_HOSTED},
|
||||
INSTANCE_NAME,
|
||||
@@ -245,25 +244,25 @@ pub struct EditMqttTrigger {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||
pub struct MqttTrigger {
|
||||
pub mqtt_resource_path: String,
|
||||
pub subscribe_topics: Vec<SqlxJson<SubscribeTopic>>,
|
||||
pub v3_config: Option<SqlxJson<MqttV3Config>>,
|
||||
pub v5_config: Option<SqlxJson<MqttV5Config>>,
|
||||
pub client_id: Option<String>,
|
||||
mqtt_resource_path: String,
|
||||
subscribe_topics: Vec<SqlxJson<SubscribeTopic>>,
|
||||
v3_config: Option<SqlxJson<MqttV3Config>>,
|
||||
v5_config: Option<SqlxJson<MqttV5Config>>,
|
||||
client_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub client_version: Option<MqttClientVersion>,
|
||||
pub path: String,
|
||||
pub script_path: String,
|
||||
pub is_flow: bool,
|
||||
pub workspace_id: String,
|
||||
pub edited_by: String,
|
||||
pub email: String,
|
||||
pub edited_at: chrono::DateTime<chrono::Utc>,
|
||||
pub extra_perms: Option<serde_json::Value>,
|
||||
pub error: Option<String>,
|
||||
pub server_id: Option<String>,
|
||||
pub last_server_ping: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub enabled: bool,
|
||||
client_version: Option<MqttClientVersion>,
|
||||
path: String,
|
||||
script_path: String,
|
||||
is_flow: bool,
|
||||
workspace_id: String,
|
||||
edited_by: String,
|
||||
email: String,
|
||||
edited_at: chrono::DateTime<chrono::Utc>,
|
||||
extra_perms: Option<serde_json::Value>,
|
||||
error: Option<String>,
|
||||
server_id: Option<String>,
|
||||
last_server_ping: Option<chrono::DateTime<chrono::Utc>>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
@@ -516,14 +515,13 @@ pub async fn test_mqtt_connection(
|
||||
|
||||
pub async fn create_mqtt_trigger(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(new_mqtt_trigger): Json<NewMqttTrigger>,
|
||||
) -> error::Result<(StatusCode, String)> {
|
||||
if *CLOUD_HOSTED {
|
||||
return Err(error::Error::BadRequest(
|
||||
"MQTT triggers are not supported on multi-tenant cloud, use dedicated cloud or self-host".to_string(),
|
||||
"Mqtt triggers are not supported on multi-tenant cloud, use dedicated cloud or self-host".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -608,18 +606,7 @@ pub async fn create_mqtt_trigger(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::MqttTrigger { path: path.to_string() },
|
||||
Some(format!("MQTT trigger '{}' created", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, format!("{}", path.to_string())))
|
||||
Ok((StatusCode::CREATED, path.to_string()))
|
||||
}
|
||||
|
||||
pub async fn list_mqtt_triggers(
|
||||
@@ -732,7 +719,6 @@ pub async fn get_mqtt_trigger(
|
||||
|
||||
pub async fn update_mqtt_trigger(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(mqtt_trigger): Json<EditMqttTrigger>,
|
||||
@@ -801,7 +787,7 @@ pub async fn update_mqtt_trigger(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"mqtt_triggers.update",
|
||||
ActionKind::Update,
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&path),
|
||||
None,
|
||||
@@ -810,23 +796,11 @@ pub async fn update_mqtt_trigger(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::MqttTrigger { path: path.clone() },
|
||||
Some(format!("MQTT trigger '{}' updated", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(path.to_string())
|
||||
Ok(workspace_path.to_string())
|
||||
}
|
||||
|
||||
pub async fn delete_mqtt_trigger(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> error::Result<String> {
|
||||
@@ -860,18 +834,7 @@ pub async fn delete_mqtt_trigger(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::MqttTrigger { path: path.to_string() },
|
||||
Some(format!("MQTT trigger '{}' deleted", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("MQTT trigger {path} deleted"))
|
||||
Ok(format!("Mqtt trigger {path} deleted"))
|
||||
}
|
||||
|
||||
pub async fn exists_mqtt_trigger(
|
||||
@@ -901,7 +864,6 @@ pub async fn exists_mqtt_trigger(
|
||||
|
||||
pub async fn set_enabled(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(payload): Json<SetEnabled>,
|
||||
@@ -951,17 +913,6 @@ pub async fn set_enabled(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::MqttTrigger { path: path.to_string() },
|
||||
Some(format!("MQTT trigger '{}' updated", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!(
|
||||
"successfully updated mqtt trigger at path {} to status {}",
|
||||
path, payload.enabled
|
||||
|
||||
@@ -25,10 +25,9 @@ use windmill_common::error::Error;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{self, JsonResult, Result},
|
||||
utils::{not_found_if_none, paginate, Pagination, StripPath, empty_as_none},
|
||||
utils::{not_found_if_none, paginate, Pagination, StripPath},
|
||||
worker::CLOUD_HOSTED,
|
||||
};
|
||||
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
|
||||
|
||||
use super::{
|
||||
create_logical_replication_slot_query, create_publication_query, drop_publication_query,
|
||||
@@ -46,8 +45,7 @@ pub struct Postgres {
|
||||
pub dbname: String,
|
||||
#[serde(default)]
|
||||
pub sslmode: String,
|
||||
#[serde(default, deserialize_with = "empty_as_none")]
|
||||
pub root_certificate_pem: Option<String>,
|
||||
pub root_certificate_pem: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
|
||||
@@ -455,17 +453,6 @@ pub async fn create_postgres_trigger(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::PostgresTrigger { path: path.to_string() },
|
||||
Some(format!("Postgres trigger '{}' created", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, path.to_string()))
|
||||
}
|
||||
|
||||
@@ -1171,7 +1158,7 @@ pub async fn update_postgres_trigger(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"postgres_triggers.update",
|
||||
ActionKind::Update,
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&path),
|
||||
None,
|
||||
@@ -1180,23 +1167,11 @@ pub async fn update_postgres_trigger(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::PostgresTrigger { path: path.to_string() },
|
||||
Some(format!("Postgres trigger '{}' updated", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(workspace_path.to_string())
|
||||
}
|
||||
|
||||
pub async fn delete_postgres_trigger(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> Result<String> {
|
||||
@@ -1228,17 +1203,6 @@ pub async fn delete_postgres_trigger(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::PostgresTrigger { path: path.to_string() },
|
||||
Some(format!("Postgres trigger '{}' deleted", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("Postgres trigger {path} deleted"))
|
||||
}
|
||||
|
||||
@@ -1267,7 +1231,6 @@ pub async fn exists_postgres_trigger(
|
||||
|
||||
pub async fn set_enabled(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(payload): Json<SetEnabled>,
|
||||
@@ -1316,17 +1279,6 @@ pub async fn set_enabled(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::PostgresTrigger { path: path.to_string() },
|
||||
Some(format!("Postgres trigger '{}' updated", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!(
|
||||
"succesfully updated postgres trigger at path {} to status {}",
|
||||
path, payload.enabled
|
||||
|
||||
@@ -84,8 +84,8 @@ pub async fn get_raw_postgres_connection(
|
||||
}
|
||||
};
|
||||
|
||||
let options = if let Some(root_certificate_pem) = &db.root_certificate_pem {
|
||||
options.ssl_root_cert_from_pem(root_certificate_pem.as_bytes().to_vec())
|
||||
let options = if !db.root_certificate_pem.is_empty() {
|
||||
options.ssl_root_cert_from_pem(db.root_certificate_pem.as_bytes().to_vec())
|
||||
} else {
|
||||
options
|
||||
};
|
||||
@@ -96,6 +96,7 @@ pub async fn get_raw_postgres_connection(
|
||||
options
|
||||
}
|
||||
};
|
||||
|
||||
Ok(PgConnection::connect_with(&options).await?)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,22 +15,22 @@ use crate::{
|
||||
trigger_helpers::TriggerJobArgs,
|
||||
users::fetch_api_authed,
|
||||
};
|
||||
use windmill_queue::TriggerKind;
|
||||
|
||||
use bytes::{BufMut, Bytes, BytesMut};
|
||||
use chrono::TimeZone;
|
||||
use futures::{pin_mut, SinkExt, StreamExt};
|
||||
use native_tls::{Certificate, TlsConnector};
|
||||
use native_tls::TlsConnector;
|
||||
use pg_escape::{quote_identifier, quote_literal};
|
||||
use rand::seq::SliceRandom;
|
||||
use rust_postgres::{config::SslMode, Client, Config, CopyBothDuplex, NoTls, SimpleQueryMessage};
|
||||
use rust_postgres::{config::SslMode, Client, Config, CopyBothDuplex, SimpleQueryMessage};
|
||||
use rust_postgres_native_tls::MakeTlsConnector;
|
||||
use serde::Deserialize;
|
||||
use serde_json::value::RawValue;
|
||||
use sqlx::types::Json as SqlxJson;
|
||||
|
||||
use windmill_common::{
|
||||
db::UserDB, error, triggers::TriggerKind, utils::report_critical_error, worker::to_raw_value,
|
||||
INSTANCE_NAME,
|
||||
db::UserDB, error, utils::report_critical_error, worker::to_raw_value, INSTANCE_NAME,
|
||||
};
|
||||
|
||||
use super::{
|
||||
@@ -79,45 +79,6 @@ enum Error {
|
||||
Tls(#[from] native_tls::Error),
|
||||
}
|
||||
|
||||
fn build_tls_connector(
|
||||
ssl_mode: SslMode,
|
||||
root_certificate_pem: Option<&String>,
|
||||
) -> Result<Option<MakeTlsConnector>, Error> {
|
||||
let get_tls_builder_for_verify = |root_certificate: Option<&String>| {
|
||||
let mut builder = TlsConnector::builder();
|
||||
if let Some(root_certificate) = root_certificate {
|
||||
let root_certificate_pem = Certificate::from_pem(root_certificate.as_bytes()).map_err(|e| {
|
||||
Error::Common(error::Error::BadConfig(format!("Invalid Certs: {e:#}")))
|
||||
})?;
|
||||
builder.add_root_certificate(root_certificate_pem);
|
||||
}
|
||||
Ok::<_, Error>(builder)
|
||||
};
|
||||
let connector = match ssl_mode {
|
||||
SslMode::Disable => return Ok(None),
|
||||
SslMode::Require | SslMode::Prefer => {
|
||||
let mut builder = TlsConnector::builder();
|
||||
builder.danger_accept_invalid_certs(true);
|
||||
builder.danger_accept_invalid_hostnames(true);
|
||||
builder
|
||||
}
|
||||
|
||||
SslMode::VerifyCa => {
|
||||
let mut builder = get_tls_builder_for_verify(root_certificate_pem)?;
|
||||
builder.danger_accept_invalid_hostnames(true);
|
||||
builder
|
||||
}
|
||||
|
||||
SslMode::VerifyFull => {
|
||||
let builder = get_tls_builder_for_verify(root_certificate_pem)?;
|
||||
builder
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
Ok(Some(MakeTlsConnector::new(connector.build()?)))
|
||||
}
|
||||
|
||||
pub struct PostgresSimpleClient(Client);
|
||||
|
||||
impl PostgresSimpleClient {
|
||||
@@ -151,27 +112,20 @@ impl PostgresSimpleClient {
|
||||
config.password(&database.password);
|
||||
}
|
||||
|
||||
let connector = build_tls_connector(ssl_mode, database.root_certificate_pem.as_ref())?;
|
||||
if !database.root_certificate_pem.is_empty() {
|
||||
config.ssl_root_cert(database.root_certificate_pem.as_bytes());
|
||||
}
|
||||
|
||||
let client = if let Some(connector) = connector {
|
||||
let (client, connection) = config.connect(connector).await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
tracing::debug!("{:#?}", e);
|
||||
};
|
||||
tracing::info!("Successfully Connected into database");
|
||||
});
|
||||
client
|
||||
} else {
|
||||
let (client, connection) = config.connect(NoTls).await?;
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
tracing::debug!("{:#?}", e);
|
||||
};
|
||||
tracing::info!("Successfully Connected into database");
|
||||
});
|
||||
client
|
||||
};
|
||||
let connector = MakeTlsConnector::new(TlsConnector::new()?);
|
||||
|
||||
let (client, connection) = config.connect(connector).await?;
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = connection.await {
|
||||
tracing::debug!("{:#?}", e);
|
||||
};
|
||||
tracing::info!("Successfully Connected into database");
|
||||
});
|
||||
|
||||
Ok(PostgresSimpleClient(client))
|
||||
}
|
||||
|
||||
@@ -223,8 +223,7 @@ async fn list_scripts(
|
||||
"draft_only",
|
||||
"ws_error_handler_muted",
|
||||
"no_main_func",
|
||||
"codebase IS NOT NULL as use_codebase",
|
||||
"kind"
|
||||
"codebase IS NOT NULL as use_codebase"
|
||||
])
|
||||
.left()
|
||||
.join("favorite")
|
||||
@@ -299,9 +298,7 @@ async fn list_scripts(
|
||||
if let Some(it) = &lq.is_template {
|
||||
sqlb.and_where_eq("is_template", it);
|
||||
}
|
||||
if authed.is_operator {
|
||||
sqlb.and_where_eq("kind", quote("script"));
|
||||
} else if let Some(lowercased_kinds) = lowercased_kinds {
|
||||
if let Some(lowercased_kinds) = lowercased_kinds {
|
||||
let safe_kinds = lowercased_kinds
|
||||
.into_iter()
|
||||
.map(sql_builder::quote)
|
||||
@@ -686,40 +683,36 @@ async fn create_script_internal<'c>(
|
||||
|
||||
let validate_schema = should_validate_schema(&ns.content, &ns.language);
|
||||
|
||||
let (no_main_func, has_preprocessor) = if matches!(ns.kind, Some(ScriptKind::Preprocessor)) {
|
||||
(ns.no_main_func, ns.has_preprocessor)
|
||||
} else {
|
||||
match lang {
|
||||
ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => {
|
||||
let args = windmill_parser_ts::parse_deno_signature(&ns.content, true, true, None);
|
||||
match args {
|
||||
Ok(args) => (args.no_main_func, args.has_preprocessor),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Error parsing deno signature when deploying script {}: {:?}",
|
||||
ns.path,
|
||||
e
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
let (no_main_func, has_preprocessor) = match lang {
|
||||
ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => {
|
||||
let args = windmill_parser_ts::parse_deno_signature(&ns.content, true, true, None);
|
||||
match args {
|
||||
Ok(args) => (args.no_main_func, args.has_preprocessor),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Error parsing deno signature when deploying script {}: {:?}",
|
||||
ns.path,
|
||||
e
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
ScriptLang::Python3 => {
|
||||
let args = windmill_parser_py::parse_python_signature(&ns.content, None, true);
|
||||
match args {
|
||||
Ok(args) => (args.no_main_func, args.has_preprocessor),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Error parsing python signature when deploying script {}: {:?}",
|
||||
ns.path,
|
||||
e
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (ns.no_main_func, ns.has_preprocessor),
|
||||
}
|
||||
ScriptLang::Python3 => {
|
||||
let args = windmill_parser_py::parse_python_signature(&ns.content, None, true);
|
||||
match args {
|
||||
Ok(args) => (args.no_main_func, args.has_preprocessor),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
"Error parsing python signature when deploying script {}: {:?}",
|
||||
ns.path,
|
||||
e
|
||||
);
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (ns.no_main_func, ns.has_preprocessor),
|
||||
};
|
||||
|
||||
sqlx::query!(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use quick_cache::sync::Cache;
|
||||
use serde::Deserialize;
|
||||
use serde_json::value::RawValue;
|
||||
use std::collections::HashMap;
|
||||
@@ -5,19 +6,31 @@ use windmill_common::{
|
||||
error::Result,
|
||||
flows::FlowModuleValue,
|
||||
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
|
||||
jobs::get_has_preprocessor_from_content_and_lang,
|
||||
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
|
||||
triggers::{
|
||||
HubOrWorkspaceId, RunnableFormat, RunnableFormatVersion, TriggerKind,
|
||||
RUNNABLE_FORMAT_VERSION_CACHE,
|
||||
},
|
||||
utils::StripPath,
|
||||
scripts::{ScriptHash, ScriptLang},
|
||||
worker::to_raw_value,
|
||||
FlowVersionInfo,
|
||||
};
|
||||
use windmill_queue::PushArgsOwned;
|
||||
use windmill_queue::{PushArgsOwned, TriggerKind};
|
||||
|
||||
use crate::{db::DB, HTTP_CLIENT};
|
||||
use crate::db::DB;
|
||||
|
||||
type RunnableFormatCacheKey = (String, i64, TriggerKind);
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref RUNNABLE_FORMAT_VERSION_CACHE: Cache<RunnableFormatCacheKey, RunnableFormat> = Cache::new(1000);
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Copy)]
|
||||
pub struct RunnableFormat {
|
||||
pub version: RunnableFormatVersion,
|
||||
pub has_preprocessor: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Copy)]
|
||||
pub enum RunnableFormatVersion {
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
|
||||
struct ScriptInfo {
|
||||
has_preprocessor: Option<bool>,
|
||||
@@ -40,7 +53,6 @@ struct PartialSchema {
|
||||
pub enum RunnableId {
|
||||
FlowPath(String),
|
||||
ScriptId(ScriptId),
|
||||
HubScript(String),
|
||||
}
|
||||
|
||||
impl RunnableId {
|
||||
@@ -49,11 +61,7 @@ impl RunnableId {
|
||||
}
|
||||
|
||||
pub fn from_script_path(path: &str) -> Self {
|
||||
if path.starts_with("hub/") {
|
||||
Self::HubScript(path.to_string())
|
||||
} else {
|
||||
Self::ScriptId(ScriptId::ScriptPath(path.to_string()))
|
||||
}
|
||||
Self::ScriptId(ScriptId::ScriptPath(path.to_string()))
|
||||
}
|
||||
|
||||
pub fn from_flow_path(path: &str) -> Self {
|
||||
@@ -148,33 +156,6 @@ struct FlowInfo {
|
||||
schema: Option<sqlx::types::Json<PartialSchema>>,
|
||||
}
|
||||
|
||||
fn get_preprocessor_args_from_content_and_language(
|
||||
content: &str,
|
||||
language: &ScriptLang,
|
||||
) -> Result<Option<Vec<windmill_parser::Arg>>> {
|
||||
let args = match language {
|
||||
ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => {
|
||||
let args = windmill_parser_ts::parse_deno_signature(
|
||||
&content,
|
||||
true,
|
||||
false,
|
||||
Some("preprocessor".to_string()),
|
||||
)?;
|
||||
Some(args.args)
|
||||
}
|
||||
ScriptLang::Python3 => {
|
||||
let args = windmill_parser_py::parse_python_signature(
|
||||
&content,
|
||||
Some("preprocessor".to_string()),
|
||||
false,
|
||||
)?;
|
||||
Some(args.args)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
pub async fn get_runnable_format(
|
||||
runnable_id: RunnableId,
|
||||
workspace_id: &str,
|
||||
@@ -182,65 +163,11 @@ pub async fn get_runnable_format(
|
||||
trigger_kind: &TriggerKind,
|
||||
) -> Result<RunnableFormat> {
|
||||
let (key, preprocessor_info) = match runnable_id {
|
||||
RunnableId::HubScript(path) => {
|
||||
let Some(version) = path.split("/").nth(1) else {
|
||||
return Err(windmill_common::error::Error::internal_err(
|
||||
"Invalid hub script path".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let version = match version.parse::<i64>() {
|
||||
Ok(version) => version,
|
||||
Err(_) => {
|
||||
return Err(windmill_common::error::Error::internal_err(
|
||||
"Invalid hub script version".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let key = (HubOrWorkspaceId::Hub, version, trigger_kind.clone());
|
||||
|
||||
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
|
||||
|
||||
if let Some(runnable_format) = runnable_format {
|
||||
tracing::debug!("Using cached runnable format for hub script {path}");
|
||||
return Ok(runnable_format);
|
||||
}
|
||||
|
||||
let hub_script =
|
||||
get_full_hub_script_by_path(StripPath(path.to_string()), &HTTP_CLIENT, Some(db))
|
||||
.await?;
|
||||
|
||||
let has_preprocessor = get_has_preprocessor_from_content_and_lang(
|
||||
&hub_script.content,
|
||||
&hub_script.language,
|
||||
)?;
|
||||
|
||||
let partial_schema = serde_json::from_str(hub_script.schema.get())?;
|
||||
|
||||
(
|
||||
key,
|
||||
if has_preprocessor {
|
||||
PreprocessorInfo::Preprocessor {
|
||||
content: hub_script.content,
|
||||
language: hub_script.language,
|
||||
}
|
||||
} else {
|
||||
PreprocessorInfo::NoPreprocessor {
|
||||
schema: Some(sqlx::types::Json(partial_schema)),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
RunnableId::FlowPath(path) => {
|
||||
let FlowVersionInfo { version, .. } =
|
||||
get_latest_flow_version_info_for_path(db, workspace_id, &path, true).await?;
|
||||
|
||||
let key = (
|
||||
HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()),
|
||||
version,
|
||||
trigger_kind.clone(),
|
||||
);
|
||||
let key = (workspace_id.to_string(), version, trigger_kind.clone());
|
||||
|
||||
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
|
||||
|
||||
@@ -254,14 +181,11 @@ pub async fn get_runnable_format(
|
||||
"SELECT
|
||||
value->'preprocessor_module'->'value' as \"preprocessor_module: _\",
|
||||
schema as \"schema: _\"
|
||||
FROM flow_version
|
||||
WHERE
|
||||
path = $1
|
||||
AND workspace_id = $2
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1",
|
||||
path,
|
||||
FROM flow
|
||||
WHERE workspace_id = $1
|
||||
AND path = $2",
|
||||
workspace_id,
|
||||
path
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
@@ -303,11 +227,7 @@ pub async fn get_runnable_format(
|
||||
}
|
||||
RunnableId::ScriptId(script_id) => {
|
||||
let hash = script_id.get_script_hash(workspace_id, db).await?;
|
||||
let key = (
|
||||
HubOrWorkspaceId::WorkspaceId(workspace_id.to_string()),
|
||||
hash,
|
||||
trigger_kind.clone(),
|
||||
);
|
||||
let key = (workspace_id.to_string(), hash, trigger_kind.clone());
|
||||
let runnable_format = RUNNABLE_FORMAT_VERSION_CACHE.get(&key);
|
||||
|
||||
if let Some(runnable_format) = runnable_format {
|
||||
@@ -336,7 +256,30 @@ pub async fn get_runnable_format(
|
||||
|
||||
let runnable_format = match preprocessor_info {
|
||||
PreprocessorInfo::Preprocessor { content, language } => {
|
||||
let args = get_preprocessor_args_from_content_and_language(&content, &language)?;
|
||||
let args = match language {
|
||||
ScriptLang::Bun
|
||||
| ScriptLang::Bunnative
|
||||
| ScriptLang::Deno
|
||||
| ScriptLang::Nativets => {
|
||||
let args = windmill_parser_ts::parse_deno_signature(
|
||||
&content,
|
||||
true,
|
||||
false,
|
||||
Some("preprocessor".to_string()),
|
||||
)?;
|
||||
Some(args.args)
|
||||
}
|
||||
ScriptLang::Python3 => {
|
||||
let args = windmill_parser_py::parse_python_signature(
|
||||
&content,
|
||||
Some("preprocessor".to_string()),
|
||||
false,
|
||||
)?;
|
||||
Some(args.args)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
runnable_format_from_preprocessor_args(args)
|
||||
}
|
||||
PreprocessorInfo::NoPreprocessor { schema } => {
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
* Please see the included NOTICE for copyright information and
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::fmt::Display;
|
||||
|
||||
use axum::{body::Body, response::Response};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
@@ -38,16 +35,6 @@ pub enum RunnableKind {
|
||||
Flow,
|
||||
}
|
||||
|
||||
impl Display for RunnableKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let runnable_kind = match self {
|
||||
RunnableKind::Script => "script",
|
||||
RunnableKind::Flow => "flow"
|
||||
};
|
||||
write!(f, "{}", runnable_kind)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_super_admin(db: &DB, email: &str) -> error::Result<()> {
|
||||
let is_admin = is_super_admin_email(db, email).await?;
|
||||
|
||||
|
||||
@@ -24,14 +24,14 @@ use windmill_audit::{audit_ee::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{self, to_anyhow, JsonResult},
|
||||
triggers::TriggerKind,
|
||||
utils::{not_found_if_none, paginate, report_critical_error, Pagination, StripPath},
|
||||
worker::{to_raw_value, CLOUD_HOSTED},
|
||||
INSTANCE_NAME,
|
||||
};
|
||||
use windmill_git_sync::handle_deployment_metadata;
|
||||
use windmill_queue::PushArgsOwned;
|
||||
|
||||
use windmill_queue::TriggerKind;
|
||||
|
||||
use crate::{
|
||||
capture::{insert_capture_payload, WebsocketTriggerConfig},
|
||||
db::{ApiAuthed, DB},
|
||||
@@ -195,7 +195,6 @@ async fn get_websocket_trigger(
|
||||
|
||||
async fn create_websocket_trigger(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
Json(ct): Json<NewWebsocketTrigger>,
|
||||
@@ -245,23 +244,11 @@ async fn create_websocket_trigger(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
windmill_git_sync::DeployedObject::WebsocketTrigger { path: ct.path.clone() },
|
||||
Some(format!("WebSocket trigger '{}' created", ct.path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((StatusCode::CREATED, format!("{}", ct.path)))
|
||||
}
|
||||
|
||||
async fn update_websocket_trigger(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(ct): Json<EditWebsocketTrigger>,
|
||||
@@ -300,27 +287,16 @@ async fn update_websocket_trigger(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"websocket_triggers.update",
|
||||
ActionKind::Update,
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&ct.path),
|
||||
Some(path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
windmill_git_sync::DeployedObject::WebsocketTrigger { path: ct.path.clone() },
|
||||
Some(format!("WebSocket trigger '{}' updated", ct.path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(ct.path.to_string())
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -330,7 +306,6 @@ pub struct SetEnabled {
|
||||
|
||||
pub async fn set_enabled(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
Json(payload): Json<SetEnabled>,
|
||||
@@ -364,17 +339,6 @@ pub async fn set_enabled(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
windmill_git_sync::DeployedObject::WebsocketTrigger { path: path.to_string() },
|
||||
Some(format!("WebSocket trigger '{}' updated", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!(
|
||||
"succesfully updated WebSocket trigger at path {} to status {}",
|
||||
path, payload.enabled
|
||||
@@ -383,7 +347,6 @@ pub async fn set_enabled(
|
||||
|
||||
async fn delete_websocket_trigger(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, path)): Path<(String, StripPath)>,
|
||||
) -> error::Result<String> {
|
||||
@@ -410,17 +373,6 @@ async fn delete_websocket_trigger(
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
windmill_git_sync::DeployedObject::WebsocketTrigger { path: path.to_string() },
|
||||
Some(format!("WebSocket trigger '{}' deleted", path)),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(format!("WebSocket trigger {path} deleted"))
|
||||
}
|
||||
|
||||
|
||||
@@ -763,45 +763,6 @@ pub(crate) async fn tarball_workspace(
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", feature = "mqtt_trigger"))]
|
||||
{
|
||||
let mqtt_triggers = sqlx::query_as!(
|
||||
crate::mqtt_triggers::MqttTrigger,
|
||||
r#"
|
||||
SELECT
|
||||
mqtt_resource_path,
|
||||
subscribe_topics as "subscribe_topics: _",
|
||||
v3_config as "v3_config: _",
|
||||
v5_config as "v5_config: _",
|
||||
client_version AS "client_version: _",
|
||||
client_id,
|
||||
workspace_id,
|
||||
path,
|
||||
script_path,
|
||||
is_flow,
|
||||
edited_by,
|
||||
email,
|
||||
edited_at,
|
||||
server_id,
|
||||
last_server_ping,
|
||||
extra_perms,
|
||||
error,
|
||||
enabled
|
||||
FROM
|
||||
mqtt_trigger
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
for trigger in mqtt_triggers {
|
||||
let trigger_str = &to_string_without_metadata(&trigger, false, None).unwrap();
|
||||
archive
|
||||
.write_to_archive(&trigger_str, &format!("{}.mqtt_trigger.json", trigger.path))
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if include_users.unwrap_or(false) {
|
||||
|
||||
@@ -71,12 +71,8 @@ const_format.workspace = true
|
||||
crc.workspace = true
|
||||
windmill-macros.workspace = true
|
||||
windmill-parser-sql.workspace = true
|
||||
windmill-parser-ts.workspace = true
|
||||
windmill-parser-py.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
backon.workspace = true
|
||||
strum.workspace = true
|
||||
strum_macros.workspace = true
|
||||
|
||||
semver.workspace = true
|
||||
croner = "2.0.6"
|
||||
|
||||
@@ -120,6 +120,62 @@ impl UserDB {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// set_session_context(
|
||||
// username TEXT,
|
||||
// groups TEXT,
|
||||
// pgroups TEXT,
|
||||
// folders_read TEXT,
|
||||
// folders_write TEXT
|
||||
// )
|
||||
|
||||
// sqlx::query!(
|
||||
// "SELECT set_config('session.user', $1, true)",
|
||||
// authed.username()
|
||||
// )
|
||||
// .fetch_optional(&mut *tx)
|
||||
// .await?;
|
||||
|
||||
// sqlx::query!(
|
||||
// "SELECT set_config('session.groups', $1, true)",
|
||||
// &authed.groups().join(",")
|
||||
// )
|
||||
// .fetch_optional(&mut *tx)
|
||||
// .await?;
|
||||
|
||||
// sqlx::query!(
|
||||
// "SELECT set_config('session.pgroups', $1, true)",
|
||||
// &authed
|
||||
// .groups()
|
||||
// .iter()
|
||||
// .map(|x| format!("g/{}", x))
|
||||
// .collect::<Vec<_>>()
|
||||
// .join(",")
|
||||
// )
|
||||
// .fetch_optional(&mut *tx)
|
||||
// .await?;
|
||||
|
||||
// sqlx::query!(
|
||||
// "SELECT set_config('session.folders_read', $1, true)",
|
||||
// folders_read
|
||||
// .iter()
|
||||
// .map(|x| x.0.clone())
|
||||
// .collect::<Vec<_>>()
|
||||
// .join(",")
|
||||
// )
|
||||
// .fetch_optional(&mut *tx)
|
||||
// .await?;
|
||||
|
||||
// sqlx::query!(
|
||||
// "SELECT set_config('session.folders_write', $1, true)",
|
||||
// folders_write
|
||||
// .iter()
|
||||
// .map(|x| x.0.clone())
|
||||
// .collect::<Vec<_>>()
|
||||
// .join(",")
|
||||
// )
|
||||
// .fetch_optional(&mut *tx)
|
||||
// .await?;
|
||||
|
||||
Ok(tx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,9 +18,8 @@ use crate::{
|
||||
flow_status::{FlowStatus, RestartedFrom},
|
||||
flows::{FlowNodeId, FlowValue, Retry},
|
||||
get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path,
|
||||
scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang},
|
||||
scripts::{ScriptHash, ScriptLang},
|
||||
users::username_to_permissioned_as,
|
||||
utils::{StripPath, HTTP_CLIENT},
|
||||
worker::{to_raw_value, TMP_DIR},
|
||||
FlowVersionInfo, ScriptHashInfo,
|
||||
};
|
||||
@@ -271,7 +270,6 @@ impl CompletedJob {
|
||||
pub enum JobPayload {
|
||||
ScriptHub {
|
||||
path: String,
|
||||
apply_preprocessor: bool,
|
||||
},
|
||||
ScriptHash {
|
||||
hash: ScriptHash,
|
||||
@@ -389,25 +387,6 @@ pub struct OnBehalfOf {
|
||||
pub permissioned_as: String,
|
||||
}
|
||||
|
||||
pub fn get_has_preprocessor_from_content_and_lang(
|
||||
content: &str,
|
||||
language: &ScriptLang,
|
||||
) -> error::Result<bool> {
|
||||
let has_preprocessor = match language {
|
||||
ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => {
|
||||
let args = windmill_parser_ts::parse_deno_signature(&content, true, true, None)?;
|
||||
args.has_preprocessor.unwrap_or(false)
|
||||
}
|
||||
ScriptLang::Python3 => {
|
||||
let args = windmill_parser_py::parse_python_signature(&content, None, true)?;
|
||||
args.has_preprocessor.unwrap_or(false)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
Ok(has_preprocessor)
|
||||
}
|
||||
|
||||
pub async fn script_path_to_payload<'e, A: sqlx::Acquire<'e, Database = Postgres> + Send>(
|
||||
script_path: &str,
|
||||
db: A,
|
||||
@@ -420,74 +399,63 @@ pub async fn script_path_to_payload<'e, A: sqlx::Acquire<'e, Database = Postgres
|
||||
Option<i32>,
|
||||
Option<OnBehalfOf>,
|
||||
)> {
|
||||
let (job_payload, tag, delete_after_use, script_timeout, on_behalf_of) = if script_path
|
||||
.starts_with("hub/")
|
||||
{
|
||||
let hub_script =
|
||||
get_full_hub_script_by_path(StripPath(script_path.to_string()), &HTTP_CLIENT, None)
|
||||
.await?;
|
||||
|
||||
let has_preprocessor =
|
||||
get_has_preprocessor_from_content_and_lang(&hub_script.content, &hub_script.language)?;
|
||||
|
||||
(
|
||||
JobPayload::ScriptHub {
|
||||
path: script_path.to_owned(),
|
||||
apply_preprocessor: has_preprocessor && !skip_preprocessor.unwrap_or(false),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
let ScriptHashInfo {
|
||||
hash,
|
||||
tag,
|
||||
concurrency_key,
|
||||
concurrent_limit,
|
||||
concurrency_time_window_s,
|
||||
cache_ttl,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
delete_after_use,
|
||||
timeout,
|
||||
has_preprocessor,
|
||||
on_behalf_of_email,
|
||||
created_by,
|
||||
..
|
||||
} = get_latest_deployed_hash_for_path(db, w_id, script_path).await?;
|
||||
|
||||
let on_behalf_of = if let Some(email) = on_behalf_of_email {
|
||||
Some(OnBehalfOf {
|
||||
email,
|
||||
permissioned_as: username_to_permissioned_as(created_by.as_str()),
|
||||
})
|
||||
let (job_payload, tag, delete_after_use, script_timeout, on_behalf_of) =
|
||||
if script_path.starts_with("hub/") {
|
||||
(
|
||||
JobPayload::ScriptHub { path: script_path.to_owned() },
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(
|
||||
JobPayload::ScriptHash {
|
||||
hash: ScriptHash(hash),
|
||||
path: script_path.to_owned(),
|
||||
custom_concurrency_key: concurrency_key,
|
||||
let ScriptHashInfo {
|
||||
hash,
|
||||
tag,
|
||||
concurrency_key,
|
||||
concurrent_limit,
|
||||
concurrency_time_window_s,
|
||||
cache_ttl: cache_ttl,
|
||||
cache_ttl,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
apply_preprocessor: !skip_preprocessor.unwrap_or(false)
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
},
|
||||
tag,
|
||||
delete_after_use,
|
||||
timeout,
|
||||
on_behalf_of,
|
||||
)
|
||||
};
|
||||
delete_after_use,
|
||||
timeout,
|
||||
has_preprocessor,
|
||||
on_behalf_of_email,
|
||||
created_by,
|
||||
..
|
||||
} = get_latest_deployed_hash_for_path(db, w_id, script_path).await?;
|
||||
|
||||
let on_behalf_of = if let Some(email) = on_behalf_of_email {
|
||||
Some(OnBehalfOf {
|
||||
email,
|
||||
permissioned_as: username_to_permissioned_as(created_by.as_str()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(
|
||||
JobPayload::ScriptHash {
|
||||
hash: ScriptHash(hash),
|
||||
path: script_path.to_owned(),
|
||||
custom_concurrency_key: concurrency_key,
|
||||
concurrent_limit,
|
||||
concurrency_time_window_s,
|
||||
cache_ttl: cache_ttl,
|
||||
language,
|
||||
dedicated_worker,
|
||||
priority,
|
||||
apply_preprocessor: !skip_preprocessor.unwrap_or(false)
|
||||
&& has_preprocessor.unwrap_or(false),
|
||||
},
|
||||
tag,
|
||||
delete_after_use,
|
||||
timeout,
|
||||
on_behalf_of,
|
||||
)
|
||||
};
|
||||
Ok((
|
||||
job_payload,
|
||||
tag,
|
||||
|
||||
@@ -62,7 +62,6 @@ pub mod utils;
|
||||
pub mod variables;
|
||||
pub mod worker;
|
||||
pub mod workspaces;
|
||||
pub mod triggers;
|
||||
|
||||
pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50;
|
||||
pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5;
|
||||
|
||||
@@ -414,15 +414,6 @@ pub enum ObjectSettings {
|
||||
Azure(AzureBlobResource),
|
||||
}
|
||||
|
||||
impl ObjectSettings {
|
||||
pub fn get_bucket(&self) -> Option<&String> {
|
||||
match self {
|
||||
ObjectSettings::S3(s3_settings) => s3_settings.bucket.as_ref(),
|
||||
ObjectSettings::Azure(azure_settings) => Some(&azure_settings.container_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
pub async fn build_object_store_from_settings(
|
||||
settings: ObjectSettings,
|
||||
|
||||
@@ -247,7 +247,6 @@ pub struct ListableScript {
|
||||
#[sqlx(default)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deployment_msg: Option<String>,
|
||||
pub kind: ScriptKind,
|
||||
}
|
||||
|
||||
fn is_false(x: &bool) -> bool {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
use quick_cache::sync::Cache;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use strum::EnumIter;
|
||||
|
||||
#[derive(Eq, PartialEq, Hash)]
|
||||
pub enum HubOrWorkspaceId {
|
||||
Hub,
|
||||
WorkspaceId(String),
|
||||
}
|
||||
|
||||
type RunnableFormatCacheKey = (HubOrWorkspaceId, i64, TriggerKind);
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Copy)]
|
||||
pub struct RunnableFormat {
|
||||
pub version: RunnableFormatVersion,
|
||||
pub has_preprocessor: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Hash, Copy)]
|
||||
pub enum RunnableFormatVersion {
|
||||
V1,
|
||||
V2,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref RUNNABLE_FORMAT_VERSION_CACHE: Cache<RunnableFormatCacheKey, RunnableFormat> = Cache::new(1000);
|
||||
}
|
||||
|
||||
#[derive(sqlx::Type, Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash, EnumIter)]
|
||||
#[sqlx(type_name = "TRIGGER_KIND", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TriggerKind {
|
||||
Webhook,
|
||||
Http,
|
||||
Websocket,
|
||||
Kafka,
|
||||
Email,
|
||||
Nats,
|
||||
Mqtt,
|
||||
Sqs,
|
||||
Postgres,
|
||||
Gcp,
|
||||
}
|
||||
|
||||
impl TriggerKind {
|
||||
pub fn to_key(&self) -> String {
|
||||
match self {
|
||||
TriggerKind::Webhook => "webhook".to_string(),
|
||||
TriggerKind::Http => "http".to_string(),
|
||||
TriggerKind::Websocket => "websocket".to_string(),
|
||||
TriggerKind::Kafka => "kafka".to_string(),
|
||||
TriggerKind::Email => "email".to_string(),
|
||||
TriggerKind::Nats => "nats".to_string(),
|
||||
TriggerKind::Mqtt => "mqtt".to_string(),
|
||||
TriggerKind::Sqs => "sqs".to_string(),
|
||||
TriggerKind::Postgres => "postgres".to_string(),
|
||||
TriggerKind::Gcp => "gcp".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TriggerKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match self {
|
||||
TriggerKind::Webhook => "webhook",
|
||||
TriggerKind::Http => "http",
|
||||
TriggerKind::Websocket => "websocket",
|
||||
TriggerKind::Kafka => "kafka",
|
||||
TriggerKind::Email => "email",
|
||||
TriggerKind::Nats => "nats",
|
||||
TriggerKind::Mqtt => "mqtt",
|
||||
TriggerKind::Sqs => "sqs",
|
||||
TriggerKind::Postgres => "postgres",
|
||||
TriggerKind::Gcp => "gcp",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
@@ -516,7 +516,6 @@ fn parse_file<T: FromStr>(path: &str) -> Option<T> {
|
||||
pub struct PythonAnnotations {
|
||||
pub no_cache: bool,
|
||||
pub no_postinstall: bool,
|
||||
pub skip_result_postprocessing: bool,
|
||||
pub py310: bool,
|
||||
pub py311: bool,
|
||||
pub py312: bool,
|
||||
|
||||
@@ -27,14 +27,6 @@ pub enum DeployedObject {
|
||||
ResourceType { path: String },
|
||||
User { email: String },
|
||||
Group { name: String },
|
||||
HttpTrigger { path: String },
|
||||
WebsocketTrigger { path: String },
|
||||
KafkaTrigger { path: String },
|
||||
NatsTrigger { path: String },
|
||||
PostgresTrigger { path: String },
|
||||
MqttTrigger { path: String },
|
||||
SqsTrigger { path: String },
|
||||
GcpTrigger { path: String },
|
||||
}
|
||||
|
||||
impl DeployedObject {
|
||||
@@ -50,14 +42,6 @@ impl DeployedObject {
|
||||
DeployedObject::ResourceType { path, .. } => path.to_owned(),
|
||||
DeployedObject::User { email } => format!("users/{email}"),
|
||||
DeployedObject::Group { name } => format!("groups/{name}"),
|
||||
DeployedObject::HttpTrigger { path } => path.to_owned(),
|
||||
DeployedObject::WebsocketTrigger { path } => path.to_owned(),
|
||||
DeployedObject::KafkaTrigger { path } => path.to_owned(),
|
||||
DeployedObject::NatsTrigger { path } => path.to_owned(),
|
||||
DeployedObject::PostgresTrigger { path } => path.to_owned(),
|
||||
DeployedObject::MqttTrigger { path } => path.to_owned(),
|
||||
DeployedObject::SqsTrigger { path } => path.to_owned(),
|
||||
DeployedObject::GcpTrigger { path } => path.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,14 +64,6 @@ impl DeployedObject {
|
||||
DeployedObject::ResourceType { .. } => None,
|
||||
DeployedObject::User { .. } => None,
|
||||
DeployedObject::Group { .. } => None,
|
||||
DeployedObject::HttpTrigger { .. } => None,
|
||||
DeployedObject::WebsocketTrigger { .. } => None,
|
||||
DeployedObject::KafkaTrigger { .. } => None,
|
||||
DeployedObject::NatsTrigger { .. } => None,
|
||||
DeployedObject::PostgresTrigger { .. } => None,
|
||||
DeployedObject::MqttTrigger { .. } => None,
|
||||
DeployedObject::SqsTrigger { .. } => None,
|
||||
DeployedObject::GcpTrigger { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use std::fmt;
|
||||
use std::{collections::HashMap, sync::Arc, vec};
|
||||
|
||||
use anyhow::Context;
|
||||
@@ -980,7 +981,6 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
|
||||
is_flow_step = queued_job.is_flow_step(),
|
||||
language = ?queued_job.script_lang,
|
||||
scheduled_for = ?queued_job.scheduled_for,
|
||||
workspace_id = ?queued_job.workspace_id,
|
||||
success,
|
||||
"inserted completed job: {} (success: {success})",
|
||||
queued_job.id
|
||||
@@ -1944,6 +1944,40 @@ async fn handle_successful_schedule<'a, 'c, T: Serialize + Send + Sync>(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(sqlx::Type, Serialize, Deserialize, Debug, Clone, Eq, PartialEq, Hash)]
|
||||
#[sqlx(type_name = "TRIGGER_KIND", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TriggerKind {
|
||||
Webhook,
|
||||
Http,
|
||||
Websocket,
|
||||
Kafka,
|
||||
Email,
|
||||
Nats,
|
||||
Mqtt,
|
||||
Sqs,
|
||||
Postgres,
|
||||
Gcp
|
||||
}
|
||||
|
||||
|
||||
impl TriggerKind {
|
||||
pub fn to_key(&self) -> String {
|
||||
match self {
|
||||
TriggerKind::Webhook => "webhook".to_string(),
|
||||
TriggerKind::Http => "http".to_string(),
|
||||
TriggerKind::Websocket => "websocket".to_string(),
|
||||
TriggerKind::Kafka => "kafka".to_string(),
|
||||
TriggerKind::Email => "email".to_string(),
|
||||
TriggerKind::Nats => "nats".to_string(),
|
||||
TriggerKind::Mqtt => "mqtt".to_string(),
|
||||
TriggerKind::Sqs => "sqs".to_string(),
|
||||
TriggerKind::Postgres => "postgres".to_string(),
|
||||
TriggerKind::Gcp => "gcp".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::Type, Serialize, Deserialize, Debug, Clone)]
|
||||
#[sqlx(type_name = "JOB_TRIGGER_KIND", rename_all = "lowercase")]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -1961,6 +1995,23 @@ pub enum JobTriggerKind {
|
||||
Gcp
|
||||
}
|
||||
|
||||
impl fmt::Display for TriggerKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let s = match self {
|
||||
TriggerKind::Webhook => "webhook",
|
||||
TriggerKind::Http => "http",
|
||||
TriggerKind::Websocket => "websocket",
|
||||
TriggerKind::Kafka => "kafka",
|
||||
TriggerKind::Email => "email",
|
||||
TriggerKind::Nats => "nats",
|
||||
TriggerKind::Mqtt => "mqtt",
|
||||
TriggerKind::Sqs => "sqs",
|
||||
TriggerKind::Postgres => "postgres",
|
||||
TriggerKind::Gcp => "gcp",
|
||||
};
|
||||
write!(f, "{}", s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MiniPulledJob {
|
||||
@@ -3536,7 +3587,7 @@ pub async fn push<'c, 'd>(
|
||||
None,
|
||||
None,
|
||||
),
|
||||
JobPayload::ScriptHub { path, apply_preprocessor } => {
|
||||
JobPayload::ScriptHub { path } => {
|
||||
if path == "hub/7771/slack" || path == "hub/7836/slack" || path == "hub/9084/slack" {
|
||||
// these scripts send app reports to slack
|
||||
// they use the slack bot token and should therefore be run with permissions to access it
|
||||
@@ -3544,10 +3595,6 @@ pub async fn push<'c, 'd>(
|
||||
email = SUPERADMIN_NOTIFICATION_EMAIL;
|
||||
}
|
||||
|
||||
if apply_preprocessor {
|
||||
preprocessed = Some(false);
|
||||
}
|
||||
|
||||
let hub_script =
|
||||
get_full_hub_script_by_path(StripPath(path.clone()), &HTTP_CLIENT, Some(_db))
|
||||
.await?;
|
||||
|
||||
@@ -305,7 +305,7 @@ async fn handle_docker_job(
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error waiting for container: {:?}", e);
|
||||
anyhow::anyhow!("Error waiting for container: {:?}", e)
|
||||
anyhow::anyhow!("Error waiting for container")
|
||||
})?;
|
||||
let waited = wait.first().map(|x| x.status_code);
|
||||
Ok(waited)
|
||||
|
||||
@@ -134,7 +134,7 @@ pub async fn handle_child(
|
||||
let (tx, rx) = broadcast::channel::<()>(3);
|
||||
let mut rx2: broadcast::Receiver<()> = tx.subscribe();
|
||||
|
||||
let output = child_joined_output_stream(&mut child, job_id.clone(), w_id.to_string());
|
||||
let output = child_joined_output_stream(&mut child, job_id.clone());
|
||||
|
||||
let job_id: Uuid = job_id.clone();
|
||||
|
||||
@@ -729,7 +729,6 @@ where
|
||||
fn child_joined_output_stream(
|
||||
child: &mut Child,
|
||||
job_id: Uuid,
|
||||
w_id: String,
|
||||
) -> impl stream::FusedStream<Item = io::Result<String>> {
|
||||
let stderr = child
|
||||
.stderr
|
||||
@@ -744,8 +743,8 @@ fn child_joined_output_stream(
|
||||
let stdout = BufReader::new(stdout).lines();
|
||||
let stderr = BufReader::new(stderr).lines();
|
||||
stream::select(
|
||||
lines_to_stream(stderr, true, job_id.clone(), w_id.clone()),
|
||||
lines_to_stream(stdout, false, job_id, w_id),
|
||||
lines_to_stream(stderr, true, job_id.clone()),
|
||||
lines_to_stream(stdout, false, job_id),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -753,12 +752,11 @@ pub fn lines_to_stream<R: tokio::io::AsyncBufRead + Unpin>(
|
||||
mut lines: tokio::io::Lines<R>,
|
||||
stderr: bool,
|
||||
job_id: Uuid,
|
||||
w_id: String,
|
||||
) -> impl futures::Stream<Item = io::Result<String>> {
|
||||
stream::poll_fn(move |cx| {
|
||||
std::pin::Pin::new(&mut lines)
|
||||
.poll_next_line(cx)
|
||||
.map(|result| process_streaming_log_lines(result, stderr, &job_id, &w_id))
|
||||
.map(|result| process_streaming_log_lines(result, stderr, &job_id))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ pub(crate) fn process_streaming_log_lines(
|
||||
r: Result<Option<String>, io::Error>,
|
||||
_stderr: bool,
|
||||
_job_id: &Uuid,
|
||||
_w_id: &str,
|
||||
) -> Option<Result<String, io::Error>> {
|
||||
r.transpose()
|
||||
}
|
||||
|
||||
@@ -842,8 +842,6 @@ pub async fn handle_python_job(
|
||||
) -> windmill_common::error::Result<Box<RawValue>> {
|
||||
let script_path = crate::common::use_flow_root_path(job.runnable_path());
|
||||
|
||||
let annotations = PythonAnnotations::parse(inner_content);
|
||||
|
||||
let (py_version, mut additional_python_paths) = handle_python_deps(
|
||||
job_dir,
|
||||
requirements_o,
|
||||
@@ -858,10 +856,10 @@ pub async fn handle_python_job(
|
||||
canceled_by,
|
||||
&mut Some(occupancy_metrics),
|
||||
precomputed_agent_info,
|
||||
annotations,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let PythonAnnotations { no_postinstall, .. } = PythonAnnotations::parse(inner_content);
|
||||
tracing::debug!("Finished handling python dependencies");
|
||||
let python_path = get_python_path(
|
||||
py_version,
|
||||
@@ -874,7 +872,7 @@ pub async fn handle_python_job(
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !annotations.no_postinstall {
|
||||
if !no_postinstall {
|
||||
if let Err(e) = postinstall(&mut additional_python_paths, job_dir, job, conn).await {
|
||||
tracing::error!("Postinstall stage has failed. Reason: {e}");
|
||||
}
|
||||
@@ -940,8 +938,6 @@ pub async fn handle_python_job(
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
let postprocessor = get_result_postprocessor(annotations.skip_result_postprocessing);
|
||||
|
||||
let os_main_override = if let Some(main_override) = main_name.as_ref() {
|
||||
format!("os.environ[\"MAIN_OVERRIDE\"] = \"{main_override}\"\n")
|
||||
} else {
|
||||
@@ -988,8 +984,7 @@ def res_to_json(res):
|
||||
for k, v in res.items():
|
||||
if type(v).__name__ == 'bytes':
|
||||
res[k] = to_b_64(v)
|
||||
unprocessed = json.dumps(res, separators=(',', ':'), default=str).replace('\n', '')
|
||||
return {postprocessor}
|
||||
return re.sub(replace_invalid_fields, ' null ', json.dumps(res, separators=(',', ':'), default=str).replace('\n', ''))
|
||||
|
||||
try:
|
||||
{preprocessor}
|
||||
@@ -1433,7 +1428,6 @@ async fn handle_python_deps(
|
||||
canceled_by: &mut Option<CanceledBy>,
|
||||
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
|
||||
precomputed_agent_info: Option<PrecomputedAgentInfo>,
|
||||
annotations: PythonAnnotations,
|
||||
) -> error::Result<(PyVersion, Vec<String>)> {
|
||||
create_dependencies_dir(job_dir).await;
|
||||
|
||||
@@ -1451,6 +1445,7 @@ async fn handle_python_deps(
|
||||
let mut annotated_pyv_numeric = None;
|
||||
let is_deployed = requirements_o.is_some();
|
||||
let instance_pyv = PyVersion::from_instance_version(job_id, w_id, conn).await;
|
||||
let annotations = windmill_common::worker::PythonAnnotations::parse(inner_content);
|
||||
let requirements = match requirements_o {
|
||||
Some(r) => r,
|
||||
None => {
|
||||
@@ -2331,15 +2326,6 @@ fn get_pyv_from_requirements_lines(requirements_lines: &[&str]) -> PyVersion {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns code snippet that needs to be injected into wrapper to post-process results or leave unprocessed
|
||||
fn get_result_postprocessor<'a>(skip: bool) -> &'a str {
|
||||
if skip {
|
||||
"unprocessed"
|
||||
} else {
|
||||
"re.sub(replace_invalid_fields, ' null ', unprocessed)"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
use crate::JobCompletedSender;
|
||||
#[cfg(feature = "enterprise")]
|
||||
@@ -2387,7 +2373,6 @@ pub async fn start_worker(
|
||||
.await
|
||||
.to_vec();
|
||||
|
||||
let annotations = PythonAnnotations::parse(inner_content);
|
||||
let context_envs = build_envs_map(context).await;
|
||||
let (_, additional_python_paths) = handle_python_deps(
|
||||
job_dir,
|
||||
@@ -2403,7 +2388,6 @@ pub async fn start_worker(
|
||||
&mut canceled_by,
|
||||
&mut None,
|
||||
None,
|
||||
annotations,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -2421,7 +2405,6 @@ pub async fn start_worker(
|
||||
) = prepare_wrapper(job_dir, false, None, None, inner_content, script_path).await?;
|
||||
|
||||
{
|
||||
let postprocessor = get_result_postprocessor(annotations.skip_result_postprocessing);
|
||||
let indented_transforms = transforms
|
||||
.lines()
|
||||
.map(|x| format!(" {}", x))
|
||||
@@ -2473,8 +2456,7 @@ for line in sys.stdin:
|
||||
for k, v in res.items():
|
||||
if type(v).__name__ == 'bytes':
|
||||
res[k] = to_b_64(v)
|
||||
unprocessed = json.dumps(res, separators=(',', ':'), default=str).replace('\n', '')
|
||||
res_json = {postprocessor}
|
||||
res_json = re.sub(replace_invalid_fields, ' null ', json.dumps(res, separators=(',', ':'), default=str).replace('\n', ''))
|
||||
sys.stdout.write("wm_res[success]:" + res_json + "\n")
|
||||
except BaseException as e:
|
||||
exc_type, exc_value, exc_traceback = sys.exc_info()
|
||||
|
||||
@@ -399,10 +399,9 @@ impl AuthedClient {
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Error executing get request from authed http client to {url} with query {query:?}: {e}");
|
||||
anyhow::anyhow!("Error executing get request from authed http client to {url} with query {query:?}: {e}")
|
||||
})
|
||||
.context(format!(
|
||||
"Executing request from authed http client to {url} with query {query:?}",
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn get_id_token(&self, audience: &str) -> anyhow::Result<String> {
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
config.allowUnfree = true;
|
||||
overlays = [ (import rust-overlay) ];
|
||||
};
|
||||
lib = pkgs.lib;
|
||||
stdenv = pkgs.stdenv;
|
||||
rust = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src" # for rust-analyzer
|
||||
@@ -28,7 +26,6 @@
|
||||
libxml2.dev
|
||||
xmlsec.dev
|
||||
libxslt.dev
|
||||
libclang.dev
|
||||
libtool
|
||||
nodejs
|
||||
postgresql
|
||||
@@ -45,15 +42,16 @@
|
||||
PKG_CONFIG_PATH = pkgs.lib.makeSearchPath "lib/pkgconfig"
|
||||
(with pkgs; [ openssl.dev libxml2.dev xmlsec.dev libxslt.dev ]);
|
||||
RUSTY_V8_ARCHIVE = let
|
||||
# NOTE: needs to be same as in Cargo.toml
|
||||
version = "130.0.7";
|
||||
version = "130.0.1";
|
||||
target = pkgs.hostPlatform.rust.rustcTarget;
|
||||
sha256 = {
|
||||
x86_64-linux =
|
||||
"sha256-pkdsuU6bAkcIHEZUJOt5PXdzK424CEgTLXjLtQ80t10=";
|
||||
aarch64-linux = pkgs.lib.fakeHash;
|
||||
"sha256-qc25H3Aj2KRhsAZ+2SD1c4RmweVK07oW71opZXRuUoc=";
|
||||
aarch64-linux =
|
||||
"sha256-qc25H3Aj2KRhsAZ+2SD1c4RmweVK07oW71opZXRuUoc=";
|
||||
x86_64-darwin = pkgs.lib.fakeHash;
|
||||
aarch64-darwin = pkgs.lib.fakeHash;
|
||||
aarch64-darwin =
|
||||
"sha256-d1QTLt8gOUFxACes4oyIYgDF/srLOEk+5p5Oj1ECajQ=";
|
||||
}.${system};
|
||||
in pkgs.fetchurl {
|
||||
name = "librusty_v8-${version}";
|
||||
@@ -79,31 +77,25 @@
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = buildInputs ++ (with pkgs; [
|
||||
# Essentials
|
||||
rust
|
||||
git
|
||||
xcaddy
|
||||
sqlx-cli
|
||||
flock
|
||||
sccache
|
||||
nsjail
|
||||
|
||||
# Python
|
||||
flock
|
||||
deno
|
||||
python3
|
||||
python3Packages.pip
|
||||
uv
|
||||
|
||||
# Other languages
|
||||
deno
|
||||
nushell
|
||||
go
|
||||
bun
|
||||
uv
|
||||
nushell
|
||||
dotnet-sdk_9
|
||||
oracle-instantclient
|
||||
ansible
|
||||
|
||||
# LSP/Local dev
|
||||
svelte-language-server
|
||||
ansible
|
||||
taplo
|
||||
]);
|
||||
packages = [
|
||||
@@ -193,40 +185,6 @@
|
||||
ANSIBLE_GALAXY_PATH = "${pkgs.ansible}/bin/ansible-galaxy";
|
||||
RUST_LOG = "debug";
|
||||
SQLX_OFFLINE = "true";
|
||||
|
||||
# See this issue: https://github.com/NixOS/nixpkgs/issues/370494
|
||||
# Allows to build jemalloc on nixos
|
||||
CFLAGS = "-Wno-error=int-conversion";
|
||||
|
||||
# Need to tell bindgen where to find libclang
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
|
||||
# LD_LIBRARY_PATH = "${pkgs.gcc.lib}/lib";
|
||||
|
||||
# Set C flags for Rust's bindgen program. Unlike ordinary C
|
||||
# compilation, bindgen does not invoke $CC directly. Instead it
|
||||
# uses LLVM's libclang. To make sure all necessary flags are
|
||||
# included we need to look in a few places.
|
||||
# See https://web.archive.org/web/20220523141208/https://hoverbear.org/blog/rust-bindgen-in-nix/
|
||||
BINDGEN_EXTRA_CLANG_ARGS =
|
||||
"${builtins.readFile "${stdenv.cc}/nix-support/libc-crt1-cflags"} ${
|
||||
builtins.readFile "${stdenv.cc}/nix-support/libc-cflags"
|
||||
}${builtins.readFile "${stdenv.cc}/nix-support/cc-cflags"}${
|
||||
builtins.readFile "${stdenv.cc}/nix-support/libcxx-cxxflags"
|
||||
} -idirafter ${pkgs.libiconv}/include ${
|
||||
lib.optionalString stdenv.cc.isClang
|
||||
"-idirafter ${stdenv.cc.cc}/lib/clang/${
|
||||
lib.getVersion stdenv.cc.cc
|
||||
}/include"
|
||||
}${
|
||||
lib.optionalString stdenv.cc.isGNU
|
||||
"-isystem ${stdenv.cc.cc}/include/c++/${
|
||||
lib.getVersion stdenv.cc.cc
|
||||
} -isystem ${stdenv.cc.cc}/include/c++/${
|
||||
lib.getVersion stdenv.cc.cc
|
||||
}/${stdenv.hostPlatform.config} -idirafter ${stdenv.cc.cc}/lib/gcc/${stdenv.hostPlatform.config}/14.2.1/include"
|
||||
}"; # NOTE: It is hardcoded to 14.2.1 -------------------------------------------------------------^^^^^^
|
||||
# Please update the version here as well if you want to update flake.
|
||||
};
|
||||
packages.default = self.packages.${system}.windmill;
|
||||
packages.windmill-client = pkgs.buildNpmPackage {
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# Svelte 5 Best Practices
|
||||
|
||||
This guide outlines best practices for developing with Svelte 5, incorporating the new Runes API and other modern Svelte features. These rules MUST NOT be applied on svelte 4 files unless explicitly asked to do so.
|
||||
|
||||
## Reactivity with Runes
|
||||
|
||||
Svelte 5 introduces Runes for more explicit and flexible reactivity.
|
||||
|
||||
1. **Embrace Runes for State Management**:
|
||||
|
||||
- Use `$state` for reactive local component state.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
function increment() {
|
||||
count += 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<button onclick={increment}>
|
||||
Clicked {count} {count === 1 ? 'time' : 'times'}
|
||||
</button>
|
||||
```
|
||||
|
||||
- Use `$derived` for computed values based on other reactive state.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
const doubled = $derived(count * 2);
|
||||
</script>
|
||||
|
||||
<p>{count} * 2 = {doubled}</p>
|
||||
```
|
||||
|
||||
- Use `$effect` for side effects that need to run when reactive values change (e.g., logging, manual DOM manipulation, data fetching). Remember `$effect` does not run on the server.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let count = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
console.log('The count is now', count);
|
||||
if (count > 5) {
|
||||
alert('Count is too high!');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
2. **Props with `$props`**:
|
||||
|
||||
- Declare component props using `$props()`. This offers better clarity and flexibility compared to `export let`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
// ChildComponent.svelte
|
||||
let { name, age = $state(30) } = $props();
|
||||
</script>
|
||||
|
||||
<p>Name: {name}</p>
|
||||
<p>Age: {age}</p>
|
||||
```
|
||||
|
||||
- For bindable props, use `$bindable`.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
// MyInput.svelte
|
||||
let { value = $bindable() } = $props();
|
||||
</script>
|
||||
|
||||
<input bind:value />
|
||||
```
|
||||
|
||||
## Event Handling
|
||||
|
||||
- **Use direct event attributes**: Svelte 5 moves away from `on:` directives for DOM events.
|
||||
- **Do**: `<button onclick={handleClick}>...</button>`
|
||||
- **Don't**: `<button on:click={handleClick}>...</button>`
|
||||
- **For component events, prefer callback props**: Instead of `createEventDispatcher`, pass functions as props.
|
||||
|
||||
```svelte
|
||||
<!-- Parent.svelte -->
|
||||
<script>
|
||||
import Child from './Child.svelte';
|
||||
let message = $state('');
|
||||
function handleChildEvent(detail) {
|
||||
message = detail;
|
||||
}
|
||||
</script>
|
||||
<Child onCustomEvent={handleChildEvent} />
|
||||
<p>Message from child: {message}</p>
|
||||
|
||||
<!-- Child.svelte -->
|
||||
<script>
|
||||
let { onCustomEvent } = $props();
|
||||
function emitEvent() {
|
||||
onCustomEvent('Hello from child!');
|
||||
}
|
||||
</script>
|
||||
<button onclick={emitEvent}>Send Event</button>
|
||||
```
|
||||
|
||||
## Snippets for Content Projection
|
||||
|
||||
- **Use `{#snippet ...}` and `{@render ...}` instead of slots**: Snippets are more powerful and flexible.
|
||||
|
||||
```svelte
|
||||
<!-- Parent.svelte -->
|
||||
<script>
|
||||
import Card from './Card.svelte';
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
{#snippet title()}
|
||||
My Awesome Title
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<p>Some interesting content here.</p>
|
||||
{/snippet}
|
||||
</Card>
|
||||
|
||||
<!-- Card.svelte -->
|
||||
<script>
|
||||
let { title, content } = $props();
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<header>{@render title()}</header>
|
||||
<div>{@render content()}</div>
|
||||
</article>
|
||||
```
|
||||
|
||||
- Default content is passed via the `children` prop (which is a snippet).
|
||||
```svelte
|
||||
<!-- Wrapper.svelte -->
|
||||
<script>
|
||||
let { children } = $props();
|
||||
</script>
|
||||
<div>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Component Design
|
||||
|
||||
1. **Create Small, Reusable Components**: Break down complex UIs into smaller, focused components. Each component should have a single responsibility. This also aids performance by limiting the scope of reactivity updates.
|
||||
2. **Descriptive Naming**: Use clear and descriptive names for variables, functions, and components.
|
||||
3. **Minimize Logic in Components**: Move complex business logic to utility functions or services. Keep components focused on presentation and interaction.
|
||||
|
||||
## State Management (Stores)
|
||||
|
||||
1. **Segment Stores**: Avoid a single global store. Create multiple stores, each responsible for a specific piece of global state (e.g., `userStore.js`, `themeStore.js`). This can help limit reactivity updates to only the parts of the UI that depend on specific state segments.
|
||||
2. **Use Custom Stores for Complex Logic**: For stores with related methods, create custom stores.
|
||||
|
||||
```javascript
|
||||
// counterStore.js
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
function createCounter() {
|
||||
const { subscribe, set, update } = writable(0)
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
increment: () => update((n) => n + 1),
|
||||
decrement: () => update((n) => n - 1),
|
||||
reset: () => set(0)
|
||||
}
|
||||
}
|
||||
export const counter = createCounter()
|
||||
```
|
||||
|
||||
3. **Use Context API for Localized State**: For state shared within a component subtree, consider Svelte's context API (`setContext`, `getContext`) instead of global stores when the state doesn't need to be truly global.
|
||||
|
||||
## Performance Optimizations (Svelte 5)
|
||||
|
||||
When generating Svelte 5 code, prioritize frontend performance by applying the following principles:
|
||||
|
||||
### General Svelte 5 Principles
|
||||
|
||||
- **Leverage the Compiler:** Trust Svelte's compiler to generate optimized JavaScript. Avoid manual DOM manipulation (`document.querySelector`, etc.) unless absolutely necessary for integrating third-party libraries that lack Svelte adapters.
|
||||
- **Keep Components Small and Focused:** Reinforcing from Component Design, smaller components lead to less complex reactivity graphs and more targeted, efficient updates.
|
||||
|
||||
### Reactivity & State Management
|
||||
|
||||
- **Optimize Computations with `$derived`:** Always use `$derived` for computed values that depend on other state. This ensures the computation only runs when its specific dependencies change, avoiding unnecessary work compared to recomputing derived values in `$effect` or less efficient methods.
|
||||
- **Minimize `$effect` Usage:** Use `$effect` sparingly and only for true side effects that interact with the outside world or non-Svelte state. Avoid putting complex logic or state updates _within_ an `$effect` unless those updates are explicitly intended as a reaction to external changes or non-Svelte state. Excessive or complex effects can impact rendering performance.
|
||||
- **Structure State for Fine-Grained Updates:** Design your `$state` objects or variables such that updates affect only the necessary parts of the UI. Avoid putting too much unrelated state into a single large object that gets frequently updated, as this can potentially trigger broader updates than necessary. Consider normalizing complex, nested state.
|
||||
|
||||
### List Rendering (`{#each}`)
|
||||
|
||||
- **Mandate `key` Attribute:** Always use a `key` attribute (`{#each items as item (item.id)}`) that refers to a unique, stable identifier for each item in a list. This is critical for allowing Svelte to efficiently update, reorder, add, or remove list items without destroying and re-creating unnecessary DOM elements and component instances.
|
||||
|
||||
### Component Loading & Bundling
|
||||
|
||||
- **Implement Lazy Loading/Code Splitting:** For routes, components, or modules that are not immediately needed on page load, use dynamic imports (`import(...)`) to split the code bundle. SvelteKit handles this automatically for routes, but it can be applied manually to components using helper patterns if needed.
|
||||
- **Be Mindful of Third-Party Libraries:** When incorporating external libraries, import only the necessary functions or components to minimize the final bundle size. Prefer libraries designed to be tree-shakeable.
|
||||
|
||||
### Rendering & DOM
|
||||
|
||||
- **Use CSS for Animations/Transitions:** Prefer CSS animations or transitions where possible for performance. Svelte's built-in `transition:` directive is also highly optimized and should be used for complex state-driven transitions, but simple cases can often use plain CSS.
|
||||
- **Optimize Image Loading:** Implement best practices for images: use optimized formats (WebP, AVIF), lazy loading (`loading="lazy"`), and responsive images (`<picture>`, `srcset`) to avoid loading unnecessarily large images.
|
||||
|
||||
### Server-Side Rendering (SSR) & Hydration
|
||||
|
||||
- **Ensure SSR Compatibility:** Write components that can be rendered on the server for faster initial page loads. Avoid relying on browser-specific APIs (like `window` or `document`) in the main `<script>` context. If necessary, use `$effect` or check `if (browser)` inside effects to run browser-specific code only on the client.
|
||||
- **Minimize Work During Hydration:** Structure components and data fetching such that minimal complex setup or computation is required when the client-side Svelte code takes over from the server-rendered HTML. Heavy synchronous work during hydration can block the main thread.
|
||||
|
||||
## General Clean Code Practices
|
||||
|
||||
1. **Organized File Structure**: Group related files together. A common structure:
|
||||
```
|
||||
/src
|
||||
|-- /routes // Page components (if using a router like SvelteKit)
|
||||
|-- /lib // Utility functions, services, constants (SvelteKit often uses this)
|
||||
| |-- /stores
|
||||
| |-- /utils
|
||||
| |-- /services
|
||||
| |-- /components // Reusable UI components
|
||||
|-- App.svelte
|
||||
|-- main.js (or main.ts)
|
||||
```
|
||||
2. **Scoped Styles**: Keep CSS scoped to components to avoid unintended side effects and improve maintainability. Avoid `:global` where possible.
|
||||
3. **Immutability**: With Svelte 5 and `$state`, direct assignments to properties of `$state` objects (`obj.prop = value;`) are generally fine as Svelte's reactivity system handles updates. However, for non-rune state or when interacting with other systems, understanding and sometimes preferring immutable updates (creating new objects/arrays) can still be relevant.
|
||||
4. **Use `class:` and `style:` directives**: For dynamic classes and styles, use Svelte's built-in directives for cleaner templates and potentially optimized updates.
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let isActive = $state(true);
|
||||
let color = $state('blue');
|
||||
</script>
|
||||
|
||||
<div class:active={isActive} style:color={color}>
|
||||
Hello
|
||||
</div>
|
||||
```
|
||||
|
||||
5. **Stay Updated**: Keep Svelte and its related packages up to date to benefit from the latest features, performance improvements, and security fixes.
|
||||
Generated
+2229
-4196
File diff suppressed because it is too large
Load Diff
+11
-12
@@ -80,13 +80,12 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aws-crypto/sha256-js": "^4.0.0",
|
||||
"@codingame/monaco-vscode-configuration-service-override": "~16.1.1",
|
||||
"@codingame/monaco-vscode-standalone-css-language-features": "~16.1.1",
|
||||
"@codingame/monaco-vscode-standalone-html-language-features": "^16.1.1",
|
||||
"@codingame/monaco-vscode-standalone-json-language-features": "~16.1.1",
|
||||
"@codingame/monaco-vscode-standalone-languages": "~16.1.1",
|
||||
"@codingame/monaco-vscode-standalone-typescript-language-features": "~16.1.1",
|
||||
"@codingame/monaco-vscode-editor-api": "~16.1.1",
|
||||
"@codingame/monaco-vscode-configuration-service-override": "~11.1.2",
|
||||
"@codingame/monaco-vscode-standalone-css-language-features": "~11.1.2",
|
||||
"@codingame/monaco-vscode-standalone-html-language-features": "^11.1.2",
|
||||
"@codingame/monaco-vscode-standalone-json-language-features": "~11.1.2",
|
||||
"@codingame/monaco-vscode-standalone-languages": "~11.1.2",
|
||||
"@codingame/monaco-vscode-standalone-typescript-language-features": "~11.1.2",
|
||||
"@json2csv/plainjs": "^7.0.6",
|
||||
"@leeoniya/ufuzzy": "^1.0.8",
|
||||
"@popperjs/core": "^2.11.6",
|
||||
@@ -114,10 +113,10 @@
|
||||
"idb": "^8.0.2",
|
||||
"lucide-svelte": "^0.399.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~16.1.1",
|
||||
"monaco-editor-wrapper": "6.7.0",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~11.1.2",
|
||||
"monaco-editor-wrapper": "6.1.1",
|
||||
"monaco-graphql": "^1.6.0",
|
||||
"monaco-languageclient": "9.6.0",
|
||||
"monaco-languageclient": "9.1.1",
|
||||
"monaco-vim": "^0.4.1",
|
||||
"ol": "^7.4.0",
|
||||
"openai": "^4.87.1",
|
||||
@@ -133,9 +132,9 @@
|
||||
"svelte-infinite-loading": "^1.4.0",
|
||||
"svelte-tiny-virtual-list": "^2.0.5",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"vscode": "npm:@codingame/monaco-vscode-extension-api@~16.1.1",
|
||||
"vscode": "npm:@codingame/monaco-vscode-api@~11.1.2",
|
||||
"vscode-languageclient": "~9.0.1",
|
||||
"vscode-uri": "~3.1.0",
|
||||
"vscode-uri": "~3.0.8",
|
||||
"vscode-ws-jsonrpc": "~3.4.0",
|
||||
"windmill-parser-wasm-csharp": "^1.437.1",
|
||||
"windmill-parser-wasm-go": "^1.429.0",
|
||||
|
||||
@@ -547,7 +547,7 @@
|
||||
bind:selected={value}
|
||||
options={itemsType?.multiselect ?? []}
|
||||
selectedOptionsDraggable={true}
|
||||
onopen={() => {
|
||||
on:open={() => {
|
||||
dispatch('focus')
|
||||
}}
|
||||
/>
|
||||
@@ -568,7 +568,7 @@
|
||||
}
|
||||
options={itemsType?.enum ?? []}
|
||||
selectedOptionsDraggable={true}
|
||||
onopen={() => {
|
||||
on:open={() => {
|
||||
dispatch('focus')
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -207,23 +207,25 @@
|
||||
<MultiSelect
|
||||
outerDivClass="text-secondary !bg-surface-disabled !border-0"
|
||||
selected={config.custom_tags}
|
||||
onchange={(e) => {
|
||||
console.log(e.type, config?.custom_tags)
|
||||
if (e && config?.custom_tags) {
|
||||
if (e.type === 'add') {
|
||||
on:change={(e) => {
|
||||
console.log(e.detail.type, config?.custom_tags)
|
||||
if (e.detail && config?.custom_tags) {
|
||||
if (e.detail.type === 'add') {
|
||||
config.custom_tags = [
|
||||
...config.custom_tags,
|
||||
...(e.option ? [e.option.toString()] : [])
|
||||
...(e.detail.option ? [e.detail.option.toString()] : [])
|
||||
]
|
||||
} else if (e.type === 'remove') {
|
||||
config.custom_tags = config.custom_tags.filter((t) => t !== e.option)
|
||||
} else if (e.detail.type === 'remove') {
|
||||
config.custom_tags = config.custom_tags.filter((t) => t !== e.detail.option)
|
||||
if (config?.custom_tags && config.custom_tags.length == 0) {
|
||||
config.custom_tags = undefined
|
||||
}
|
||||
} else if (e.type === 'removeAll') {
|
||||
} else if (e.detail.type === 'removeAll') {
|
||||
config.custom_tags = undefined
|
||||
} else {
|
||||
console.error(`Priority tags multiselect - unknown event type: '${e.type}'`)
|
||||
console.error(
|
||||
`Priority tags multiselect - unknown event type: '${e.detail.type}'`
|
||||
)
|
||||
}
|
||||
dispatch('dirty')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { setLicense } from '$lib/enterpriseUtils'
|
||||
import { enterpriseLicense, whitelabelNameStore } from '$lib/stores'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
import WindmillIcon from './icons/WindmillIcon.svelte'
|
||||
import LoginPageHeader from './LoginPageHeader.svelte'
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<div class="center-center min-h-screen p-4 relative bg-surface-secondary">
|
||||
<div class="flex flex-col gap-2 items-center w-full">
|
||||
{#if (!disableLogo && !$enterpriseLicense) || !$whitelabelNameStore}
|
||||
{#if (!disableLogo && !$enterpriseLicense) || !$enterpriseLicense?.endsWith('_whitelabel')}
|
||||
<div class="hidden lg:block">
|
||||
<div>
|
||||
<WindmillIcon height="100px" width="100px" spin="slow" />
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export let link: string | undefined = undefined
|
||||
</script>
|
||||
|
||||
<div class={twMerge('text-xs text-tertiary font-normal opacity-80', $$props.class)}>
|
||||
<div class="text-xs text-tertiary font-normal">
|
||||
<slot />
|
||||
{#if link}
|
||||
<a href={link} target="_blank">Learn more</a>
|
||||
|
||||
@@ -47,11 +47,10 @@
|
||||
parseTypescriptDeps
|
||||
} from '$lib/relative_imports'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import type { TriggerContext } from './triggers'
|
||||
import type { ScheduleTrigger, TriggerContext } from './triggers'
|
||||
import { workspaceAIClients } from './copilot/lib'
|
||||
import type { FlowPropPickerConfig, PropPickerContext } from './prop_picker'
|
||||
import type { PickableProperties } from './flows/previousResults'
|
||||
import { Triggers } from './triggers/triggers.svelte'
|
||||
$: token = $page.url.searchParams.get('wm_token') ?? undefined
|
||||
$: workspace = $page.url.searchParams.get('workspace') ?? undefined
|
||||
$: themeDarkRaw = $page.url.searchParams.get('activeColorTheme')
|
||||
@@ -494,13 +493,20 @@
|
||||
|
||||
const testStepStore = writable<Record<string, any>>({})
|
||||
const selectedIdStore = writable('settings-metadata')
|
||||
const selectedTriggerStore = writable<
|
||||
'webhooks' | 'emails' | 'schedules' | 'cli' | 'routes' | 'websockets' | 'scheduledPoll'
|
||||
>('webhooks')
|
||||
|
||||
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(undefined)
|
||||
const triggersCount = writable<TriggersCount | undefined>(undefined)
|
||||
setContext<TriggerContext>('TriggerContext', {
|
||||
primarySchedule: primaryScheduleStore,
|
||||
selectedTrigger: selectedTriggerStore,
|
||||
triggersCount: triggersCount,
|
||||
simplifiedPoll: writable(false),
|
||||
showCaptureHint: writable(undefined),
|
||||
triggersState: new Triggers()
|
||||
defaultValues: writable(undefined),
|
||||
captureOn: writable(undefined),
|
||||
showCaptureHint: writable(undefined)
|
||||
})
|
||||
setContext<FlowEditorContext>('FlowEditorContext', {
|
||||
selectedId: selectedIdStore,
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
import { BROWSER } from 'esm-env'
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
|
||||
import '@codingame/monaco-vscode-standalone-languages'
|
||||
// import '@codingame/monaco-vscode-standalone-languages'
|
||||
import '@codingame/monaco-vscode-standalone-json-language-features'
|
||||
import '@codingame/monaco-vscode-standalone-typescript-language-features'
|
||||
import { editor as meditor } from 'monaco-editor'
|
||||
|
||||
import { initializeVscode } from './vscode'
|
||||
import EditorTheme from './EditorTheme.svelte'
|
||||
import { buildWorkerDefinition } from '$lib/monaco_workers/build_workers'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
|
||||
buildWorkerDefinition()
|
||||
|
||||
const SIDE_BY_SIDE_MIN_WIDTH = 700
|
||||
|
||||
export let automaticLayout = true
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
import PdfViewer from './display/PdfViewer.svelte'
|
||||
import type { DisplayResultUi } from './custom_ui'
|
||||
import { getContext, hasContext, createEventDispatcher, onDestroy } from 'svelte'
|
||||
import { toJsonStr } from '$lib/utils'
|
||||
import { createDispatcherIfMounted } from '$lib/createDispatcherIfMounted'
|
||||
|
||||
export let result: any
|
||||
@@ -270,6 +269,15 @@
|
||||
let jsonViewer: Drawer
|
||||
let s3FileViewer: S3FilePicker
|
||||
|
||||
function toJsonStr(result: any) {
|
||||
try {
|
||||
// console.log(result)
|
||||
return JSON.stringify(result ?? null, null, 4) ?? 'null'
|
||||
} catch (e) {
|
||||
return 'error stringifying object: ' + e.toString()
|
||||
}
|
||||
}
|
||||
|
||||
function checkIfHasBigInt(result: any) {
|
||||
if (typeof result === 'number' && Number.isInteger(result) && !Number.isSafeInteger(result)) {
|
||||
return true
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={twMerge('flex flex-row gap-2.5 z-10 text-tertiary -mt-1 items-center')}>
|
||||
<div class={twMerge('flex flex-row gap-2.5 z-10 text-tertiary -mt-1')}>
|
||||
{#if customUi?.disableDownload !== true}
|
||||
<a
|
||||
download="{filename ?? 'result'}.json"
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import TriggerableByAI from './TriggerableByAI.svelte'
|
||||
|
||||
export let id: string = 'dropdown-v2'
|
||||
export let items: Item[] | (() => Item[]) | (() => Promise<Item[]>) = []
|
||||
export let disabled = false
|
||||
export let placement: Placement = 'bottom-end'
|
||||
@@ -27,11 +25,6 @@
|
||||
export let fixedHeight = true
|
||||
export let hidePopup = false
|
||||
export let open = false
|
||||
export let customWidth: number | undefined = undefined
|
||||
export let customMenu = false
|
||||
export let enableTriggerableByAI = false
|
||||
|
||||
let buttonEl = { click: () => {} }
|
||||
|
||||
const {
|
||||
elements: { menu, item, trigger },
|
||||
@@ -81,56 +74,43 @@
|
||||
|
||||
<ResolveOpen {open} on:open on:close />
|
||||
|
||||
<TriggerableByAI
|
||||
{id}
|
||||
description="Open dropdown"
|
||||
onTrigger={() => buttonEl.click()}
|
||||
disabled={!enableTriggerableByAI}
|
||||
<button
|
||||
class={twMerge('w-full flex items-center justify-end', fixedHeight && 'h-8', $$props.class)}
|
||||
use:melt={$trigger}
|
||||
{disabled}
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
use:pointerDownOutside={{
|
||||
capture: true,
|
||||
stopPropagation: false,
|
||||
exclude: getMenuElements,
|
||||
customEventName: 'pointerdown_menu'
|
||||
}}
|
||||
on:pointerdown_outside={() => {
|
||||
if (usePointerDownOutside) {
|
||||
close()
|
||||
}
|
||||
}}
|
||||
data-menu
|
||||
>
|
||||
<button
|
||||
bind:this={buttonEl}
|
||||
class={twMerge('w-full flex items-center justify-end', fixedHeight && 'h-8', $$props.class)}
|
||||
use:melt={$trigger}
|
||||
{disabled}
|
||||
on:click={(e) => e.stopPropagation()}
|
||||
use:pointerDownOutside={{
|
||||
capture: true,
|
||||
stopPropagation: false,
|
||||
exclude: getMenuElements,
|
||||
customEventName: 'pointerdown_menu'
|
||||
}}
|
||||
on:pointerdown_outside={() => {
|
||||
if (usePointerDownOutside) {
|
||||
close()
|
||||
}
|
||||
}}
|
||||
data-menu
|
||||
>
|
||||
{#if $$slots.buttonReplacement}
|
||||
<slot name="buttonReplacement" />
|
||||
{:else}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: MoreVertical }}
|
||||
btnClasses="bg-transparent"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
</TriggerableByAI>
|
||||
{#if $$slots.buttonReplacement}
|
||||
<slot name="buttonReplacement" />
|
||||
{:else}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
size="xs"
|
||||
color="light"
|
||||
startIcon={{ icon: MoreVertical }}
|
||||
btnClasses="bg-transparent"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if open && !hidePopup}
|
||||
<div use:melt={$menu} data-menu class="z-[6000] transition-all duration-100">
|
||||
{#if customMenu}
|
||||
<slot name="menu" />
|
||||
{:else}
|
||||
<div
|
||||
class="bg-surface border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
|
||||
style={customWidth ? `width: ${customWidth}px` : ''}
|
||||
>
|
||||
<DropdownV2Inner {id} items={computeItems} meltItem={item} {enableTriggerableByAI} />
|
||||
</div>
|
||||
{/if}
|
||||
<div use:melt={$menu} data-menu class="z-[6000]">
|
||||
<div
|
||||
class="bg-surface border w-56 origin-top-right rounded-md shadow-md focus:outline-none overflow-y-auto py-1 max-h-[50vh]"
|
||||
>
|
||||
<DropdownV2Inner items={computeItems} meltItem={item} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -4,24 +4,10 @@
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { MenubarMenuElements } from '@melt-ui/svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
import TriggerableByAI from './TriggerableByAI.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
export let items: Item[] | (() => Item[]) | (() => Promise<Item[]>) = []
|
||||
export let meltItem: MenubarMenuElements['item']
|
||||
|
||||
interface Props {
|
||||
id?: string
|
||||
items?: Item[] | (() => Item[]) | (() => Promise<Item[]>)
|
||||
meltItem: MenubarMenuElements['item']
|
||||
enableTriggerableByAI?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
id = 'dropdown-v2-inner',
|
||||
items = [],
|
||||
meltItem,
|
||||
enableTriggerableByAI = false
|
||||
}: Props = $props()
|
||||
|
||||
let computedItems: Item[] | undefined = $state(undefined)
|
||||
let computedItems: Item[] | undefined = undefined
|
||||
async function computeItems() {
|
||||
if (typeof items === 'function') {
|
||||
computedItems = ((await items()) ?? []).filter((item) => !item.hide)
|
||||
@@ -36,44 +22,26 @@
|
||||
{#if computedItems}
|
||||
<div class="flex flex-col">
|
||||
{#each computedItems ?? [] as item}
|
||||
<TriggerableByAI
|
||||
id={`${id}-${item.displayName}`}
|
||||
description={item.displayName}
|
||||
onTrigger={() => {
|
||||
console.log('triggering', item)
|
||||
if (item.action) {
|
||||
item.action({} as MouseEvent)
|
||||
}
|
||||
if (item.href) {
|
||||
goto(item.href)
|
||||
}
|
||||
}}
|
||||
disabled={!enableTriggerableByAI}
|
||||
<MenuItem
|
||||
on:click={(e) => item?.action?.(e)}
|
||||
href={item?.href}
|
||||
disabled={item?.disabled}
|
||||
class={twMerge(
|
||||
'px-4 py-2 text-primary font-semibold hover:bg-surface-hover cursor-pointer text-xs transition-all',
|
||||
'data-[highlighted]:bg-surface-hover',
|
||||
'flex flex-row gap-2 items-center',
|
||||
item?.disabled && 'text-gray-400 cursor-not-allowed',
|
||||
item?.type === 'delete' &&
|
||||
!item?.disabled &&
|
||||
'text-red-500 hover:bg-red-100 hover:text-red-500 data-[highlighted]:text-red-500 data-[highlighted]:bg-red-100'
|
||||
)}
|
||||
item={meltItem}
|
||||
>
|
||||
<MenuItem
|
||||
on:click={(e) => item?.action?.(e)}
|
||||
href={item?.href}
|
||||
disabled={item?.disabled}
|
||||
class={twMerge(
|
||||
'px-4 py-2 text-primary font-semibold hover:bg-surface-hover cursor-pointer text-xs transition-all',
|
||||
'data-[highlighted]:bg-surface-hover',
|
||||
'flex flex-row gap-2 items-center',
|
||||
item?.disabled && 'text-gray-400 cursor-not-allowed',
|
||||
item?.type === 'delete' &&
|
||||
!item?.disabled &&
|
||||
'text-red-500 hover:bg-red-100 hover:text-red-500 data-[highlighted]:text-red-500 data-[highlighted]:bg-red-100'
|
||||
)}
|
||||
item={meltItem}
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon size={14} color={item.iconColor} />
|
||||
{/if}
|
||||
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
|
||||
{item.displayName}
|
||||
</p>
|
||||
{@render item.extra?.()}
|
||||
</MenuItem>
|
||||
</TriggerableByAI>
|
||||
{#if item.icon}
|
||||
<svelte:component this={item.icon} size={14} color={item.iconColor} />
|
||||
{/if}
|
||||
{item.displayName}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -112,6 +112,7 @@
|
||||
updateOptions,
|
||||
extToLang
|
||||
} from '$lib/editorUtils'
|
||||
import type { Disposable } from 'vscode'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { type Preview, ResourceService, UserService } from '$lib/gen'
|
||||
import type { Text } from 'yjs'
|
||||
@@ -142,6 +143,7 @@
|
||||
import { setupTypeAcquisition, type DepsToGet } from '$lib/ata/index'
|
||||
import { initWasmTs } from '$lib/infer'
|
||||
import { initVim } from './monaco_keybindings'
|
||||
import { buildWorkerDefinition } from '$lib/monaco_workers/build_workers'
|
||||
import { parseTypescriptDeps } from '$lib/relative_imports'
|
||||
|
||||
import { scriptLangToEditorLang } from '$lib/scripts'
|
||||
@@ -217,6 +219,8 @@
|
||||
|
||||
console.log('uri', uri)
|
||||
|
||||
buildWorkerDefinition()
|
||||
|
||||
function computeUri(filePath: string, scriptLang: string | undefined) {
|
||||
let file
|
||||
if (filePath.includes('.')) {
|
||||
@@ -264,11 +268,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let valueAfterDispose: string | undefined = undefined
|
||||
export function getCode(): string {
|
||||
if (valueAfterDispose != undefined) {
|
||||
return valueAfterDispose
|
||||
}
|
||||
return editor?.getValue() ?? ''
|
||||
}
|
||||
|
||||
@@ -435,9 +435,9 @@
|
||||
return scriptLang
|
||||
}
|
||||
|
||||
let command: IDisposable | undefined = undefined
|
||||
let command: Disposable | undefined = undefined
|
||||
|
||||
let sqlTypeCompletor: IDisposable | undefined = undefined
|
||||
let sqlTypeCompletor: Disposable | undefined = undefined
|
||||
|
||||
$: initialized && lang === 'sql' && scriptLang
|
||||
? addSqlTypeCompletions()
|
||||
@@ -498,7 +498,7 @@
|
||||
})
|
||||
}
|
||||
|
||||
let sqlSchemaCompletor: IDisposable | undefined = undefined
|
||||
let sqlSchemaCompletor: Disposable | undefined = undefined
|
||||
|
||||
function updateSchema() {
|
||||
const newSchemaRes = lang === 'graphql' ? args?.api : args?.database
|
||||
@@ -636,7 +636,7 @@
|
||||
|
||||
$: $reviewingChanges && autocompletor?.reject()
|
||||
|
||||
let completorDisposable: IDisposable | undefined = undefined
|
||||
let completorDisposable: Disposable | undefined = undefined
|
||||
let autocompletor: Autocompletor | undefined = undefined
|
||||
function addSuperCompletor(editor: meditor.IStandaloneCodeEditor) {
|
||||
try {
|
||||
@@ -1492,7 +1492,6 @@
|
||||
|
||||
onDestroy(() => {
|
||||
console.log('destroying editor')
|
||||
valueAfterDispose = getCode()
|
||||
destroyed = true
|
||||
disposeMethod && disposeMethod()
|
||||
websocketInterval && clearInterval(websocketInterval)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
FlowService,
|
||||
ScheduleService,
|
||||
type Flow,
|
||||
type FlowModule,
|
||||
DraftService,
|
||||
@@ -18,12 +19,12 @@
|
||||
enterpriseLicense,
|
||||
tutorialsToDo,
|
||||
userStore,
|
||||
workspaceStore,
|
||||
usedTriggerKinds
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
encodeState,
|
||||
formatCron,
|
||||
generateRandomString,
|
||||
orderedJsonStringify,
|
||||
replaceFalseWithUndefined,
|
||||
@@ -75,14 +76,6 @@
|
||||
import { type TriggerContext, type ScheduleTrigger } from './triggers'
|
||||
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
|
||||
import DeployButton from './DeployButton.svelte'
|
||||
import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils'
|
||||
import {
|
||||
deployTriggers,
|
||||
filterDraftTriggers,
|
||||
handleSelectTriggerFromKind
|
||||
} from './triggers/utils'
|
||||
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
|
||||
import { Triggers } from './triggers/triggers.svelte'
|
||||
|
||||
export let initialPath: string = ''
|
||||
export let pathStoreInit: string | undefined = undefined
|
||||
@@ -92,16 +85,18 @@
|
||||
export let loading = false
|
||||
export let flowStore: Writable<OpenFlow>
|
||||
export let flowStateStore: Writable<FlowState>
|
||||
export let savedFlow: FlowWithDraftAndDraftTriggers | undefined = undefined
|
||||
export let savedFlow:
|
||||
| (Flow & {
|
||||
draft?: Flow | undefined
|
||||
})
|
||||
| undefined = undefined
|
||||
export let diffDrawer: DiffDrawer | undefined = undefined
|
||||
export let customUi: FlowBuilderWhitelabelCustomUi = {}
|
||||
export let disableAi: boolean = false
|
||||
export let disabledFlowInputs = false
|
||||
export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined // used to set the primary schedule in the legacy primaryScheduleStore
|
||||
export let savedPrimarySchedule: ScheduleTrigger | undefined = undefined
|
||||
export let version: number | undefined = undefined
|
||||
export let setSavedraftCb: ((cb: () => void) => void) | undefined = undefined
|
||||
export let draftTriggersFromUrl: Trigger[] | undefined = undefined
|
||||
export let selectedTriggerIndexFromUrl: number | undefined = undefined
|
||||
|
||||
let initialPathStore = writable(initialPath)
|
||||
$: initialPathStore.set(initialPath)
|
||||
@@ -121,26 +116,12 @@
|
||||
let confirmCallback: () => void = () => {} // What happens when user clicks `override` in warning
|
||||
let open: boolean = false // Is confirmation modal open
|
||||
|
||||
// Draft triggers confirmation modal
|
||||
let draftTriggersModalOpen = false
|
||||
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
|
||||
|
||||
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
|
||||
const { selectedTriggers } = event.detail
|
||||
// Continue with saving the flow
|
||||
draftTriggersModalOpen = false
|
||||
confirmDeploymentCallback(selectedTriggers)
|
||||
}
|
||||
|
||||
$: setContext('customUi', customUi)
|
||||
|
||||
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
||||
return {
|
||||
savedValue: savedFlow,
|
||||
modifiedValue: {
|
||||
...$flowStore,
|
||||
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
}
|
||||
modifiedValue: $flowStore
|
||||
}
|
||||
}
|
||||
let onLatest = true
|
||||
@@ -167,25 +148,42 @@
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // kept for legacy reasons
|
||||
const triggersCount = writable<TriggersCount | undefined>(undefined)
|
||||
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule)
|
||||
const triggersCount = writable<TriggersCount | undefined>(
|
||||
savedPrimarySchedule
|
||||
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
|
||||
: undefined
|
||||
)
|
||||
const simplifiedPoll = writable(false)
|
||||
|
||||
// used to set the primary schedule in the legacy primaryScheduleStore
|
||||
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
|
||||
primaryScheduleStore.set(schedule)
|
||||
}
|
||||
|
||||
export function setDraftTriggers(triggers: Trigger[] | undefined) {
|
||||
triggersState.setTriggers([
|
||||
...(triggers ?? []),
|
||||
...triggersState.triggers.filter((t) => !t.draftConfig)
|
||||
])
|
||||
loadTriggers()
|
||||
}
|
||||
|
||||
export function setSelectedTriggerIndex(index: number | undefined) {
|
||||
triggersState.selectedTriggerIndex = index
|
||||
async function createSchedule(path: string) {
|
||||
if ($primaryScheduleStore) {
|
||||
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
|
||||
|
||||
try {
|
||||
await ScheduleService.createSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: path,
|
||||
schedule: formatCron(cron),
|
||||
timezone,
|
||||
script_path: path,
|
||||
is_flow: true,
|
||||
args,
|
||||
enabled,
|
||||
summary
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
sendUserToast(`The primary schedule could not be created: ${err}`, true)
|
||||
}
|
||||
} else {
|
||||
sendUserToast('The primary schedule could not be created: no schedule data', true)
|
||||
}
|
||||
}
|
||||
|
||||
let loadingSave = false
|
||||
@@ -197,12 +195,7 @@
|
||||
}
|
||||
if (savedFlow) {
|
||||
const draftOrDeployed = cleanValueProperties(savedFlow.draft || savedFlow)
|
||||
const currentDraftTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
const current = cleanValueProperties({
|
||||
...$flowStore,
|
||||
path: $pathStore,
|
||||
draft_triggers: currentDraftTriggers
|
||||
})
|
||||
const current = cleanValueProperties({ ...$flowStore, path: $pathStore })
|
||||
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
||||
sendUserToast('No changes detected, ignoring', false, [
|
||||
{
|
||||
@@ -266,7 +259,7 @@
|
||||
value: {
|
||||
...flow,
|
||||
path: $pathStore,
|
||||
draft_triggers: triggersState.getDraftTriggersSnapshot()
|
||||
primary_schedule: $primaryScheduleStore
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -277,14 +270,15 @@
|
||||
...structuredClone($flowStore),
|
||||
path: $pathStore,
|
||||
draft_only: true
|
||||
}
|
||||
}
|
||||
: savedFlow),
|
||||
draft: {
|
||||
...structuredClone($flowStore),
|
||||
path: $pathStore,
|
||||
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
path: $pathStore
|
||||
}
|
||||
} as FlowWithDraftAndDraftTriggers
|
||||
} as Flow & {
|
||||
draft?: Flow
|
||||
}
|
||||
|
||||
let savedAtNewPath = false
|
||||
if (newFlow) {
|
||||
@@ -357,19 +351,7 @@
|
||||
deployedBy = flow.edited_by
|
||||
}
|
||||
|
||||
async function saveFlow(deploymentMsg?: string, triggersToDeploy?: Trigger[]): Promise<void> {
|
||||
if (!triggersToDeploy) {
|
||||
// Check if there are draft triggers that need confirmation
|
||||
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
|
||||
if (draftTriggers.length > 0) {
|
||||
draftTriggersModalOpen = true
|
||||
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
|
||||
await saveFlow(deploymentMsg, triggersToDeploy)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFlow(deploymentMsg?: string): Promise<void> {
|
||||
loadingSave = true
|
||||
try {
|
||||
const flow = cleanInputs($flowStore)
|
||||
@@ -408,15 +390,8 @@
|
||||
},
|
||||
runnableKind: 'flow'
|
||||
})
|
||||
if (triggersToDeploy) {
|
||||
await deployTriggers(
|
||||
triggersToDeploy,
|
||||
$workspaceStore,
|
||||
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
|
||||
usedTriggerKinds,
|
||||
$pathStore,
|
||||
true
|
||||
)
|
||||
if ($primaryScheduleStore && $primaryScheduleStore.enabled) {
|
||||
await createSchedule($pathStore)
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
@@ -425,14 +400,51 @@
|
||||
console.error('error interacting with local storage', e)
|
||||
}
|
||||
|
||||
if (triggersToDeploy) {
|
||||
await deployTriggers(
|
||||
triggersToDeploy,
|
||||
$workspaceStore,
|
||||
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
|
||||
usedTriggerKinds,
|
||||
initialPath
|
||||
)
|
||||
const scheduleExists = await ScheduleService.existsSchedule({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: initialPath
|
||||
})
|
||||
|
||||
if (scheduleExists) {
|
||||
const schedule = await ScheduleService.getSchedule({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: initialPath
|
||||
})
|
||||
if ($primaryScheduleStore) {
|
||||
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
|
||||
|
||||
if (
|
||||
JSON.stringify(schedule.args) != JSON.stringify(args) ||
|
||||
schedule.schedule != cron ||
|
||||
schedule.timezone != timezone ||
|
||||
schedule.summary != summary
|
||||
) {
|
||||
await ScheduleService.updateSchedule({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: initialPath,
|
||||
requestBody: {
|
||||
schedule: formatCron(cron),
|
||||
timezone,
|
||||
args,
|
||||
summary
|
||||
}
|
||||
})
|
||||
}
|
||||
if (enabled != schedule.enabled) {
|
||||
await ScheduleService.setScheduleEnabled({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: initialPath,
|
||||
requestBody: { enabled }
|
||||
})
|
||||
}
|
||||
} else if (scheduleExists && !$triggersCount?.primary_schedule) {
|
||||
await ScheduleService.deleteSchedule({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: $pathStore
|
||||
})
|
||||
}
|
||||
} else if ($primaryScheduleStore && $primaryScheduleStore.enabled) {
|
||||
await createSchedule(initialPath)
|
||||
}
|
||||
|
||||
await FlowService.updateFlow({
|
||||
@@ -453,15 +465,10 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const { draft_triggers: _, ...newSavedFlow } = $flowStore as OpenFlow & {
|
||||
draft_triggers: Trigger[]
|
||||
}
|
||||
savedFlow = {
|
||||
...structuredClone(newSavedFlow),
|
||||
...structuredClone($flowStore),
|
||||
path: $pathStore
|
||||
} as Flow
|
||||
triggersState.setTriggers([])
|
||||
loadingSave = false
|
||||
dispatch('deploy', $pathStore)
|
||||
} catch (err) {
|
||||
@@ -489,8 +496,7 @@
|
||||
flow: $flowStore,
|
||||
path: $pathStore,
|
||||
selectedId: $selectedIdStore,
|
||||
draft_triggers: triggersState.getDraftTriggersSnapshot(),
|
||||
selected_trigger: triggersState.getSelectedTriggerSnapshot()
|
||||
primarySchedule: $primaryScheduleStore
|
||||
})
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -500,6 +506,16 @@
|
||||
}
|
||||
|
||||
const selectedIdStore = writable<string>(selectedId ?? 'settings-metadata')
|
||||
const selectedTriggerStore = writable<
|
||||
| 'webhooks'
|
||||
| 'emails'
|
||||
| 'schedules'
|
||||
| 'cli'
|
||||
| 'routes'
|
||||
| 'websockets'
|
||||
| 'postgres'
|
||||
| 'scheduledPoll'
|
||||
>('webhooks')
|
||||
|
||||
export function getSelectedId() {
|
||||
return $selectedIdStore
|
||||
@@ -525,6 +541,20 @@
|
||||
selectedIdStore.set(selectedId)
|
||||
}
|
||||
|
||||
function selectTrigger(
|
||||
selectedTrigger:
|
||||
| 'webhooks'
|
||||
| 'emails'
|
||||
| 'schedules'
|
||||
| 'cli'
|
||||
| 'routes'
|
||||
| 'websockets'
|
||||
| 'postgres'
|
||||
| 'scheduledPoll'
|
||||
) {
|
||||
selectedTriggerStore.set(selectedTrigger)
|
||||
}
|
||||
|
||||
let insertButtonOpen = writable<boolean>(false)
|
||||
|
||||
setContext<FlowEditorContext>('FlowEditorContext', {
|
||||
@@ -547,42 +577,29 @@
|
||||
flowInputEditorState: flowInputEditorStateStore
|
||||
})
|
||||
|
||||
// Add triggers context store
|
||||
const triggersState = new Triggers(
|
||||
[
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'email', path: '', isDraft: false },
|
||||
...(draftTriggersFromUrl ?? savedFlow?.draft?.draft_triggers ?? [])
|
||||
],
|
||||
selectedTriggerIndexFromUrl,
|
||||
saveSessionDraft
|
||||
)
|
||||
|
||||
setContext<TriggerContext>('TriggerContext', {
|
||||
selectedTrigger: selectedTriggerStore,
|
||||
primarySchedule: primaryScheduleStore,
|
||||
triggersCount,
|
||||
simplifiedPoll,
|
||||
showCaptureHint,
|
||||
triggersState
|
||||
defaultValues: writable(undefined),
|
||||
captureOn,
|
||||
showCaptureHint
|
||||
})
|
||||
|
||||
export async function loadTriggers() {
|
||||
async function loadTriggers() {
|
||||
$triggersCount = await FlowService.getTriggersCountOfFlow({
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
|
||||
// Initialize triggers using utility function
|
||||
await triggersState.fetchTriggers(
|
||||
triggersCount,
|
||||
$workspaceStore,
|
||||
initialPath,
|
||||
true,
|
||||
$primaryScheduleStore,
|
||||
$userStore
|
||||
)
|
||||
|
||||
if (savedFlow && savedFlow.draft) {
|
||||
savedFlow = filterDraftTriggers(savedFlow, triggersState) as FlowWithDraftAndDraftTriggers
|
||||
if ($primaryScheduleStore && $triggersCount.primary_schedule == undefined) {
|
||||
$triggersCount = {
|
||||
...($triggersCount ?? {}),
|
||||
schedule_count: ($triggersCount.schedule_count ?? 0) + 1,
|
||||
primary_schedule: {
|
||||
schedule: $primaryScheduleStore.cron
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -854,6 +871,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
if (module.type === 'trigger') {
|
||||
$primaryScheduleStore = {
|
||||
summary: 'Scheduled poll of flow',
|
||||
args: {},
|
||||
cron: '0 */15 * * *',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
|
||||
const flowModule: FlowModule & {
|
||||
value: RawScript | PathScript
|
||||
} = {
|
||||
@@ -863,7 +890,7 @@
|
||||
? {
|
||||
expr: 'result == undefined || Array.isArray(result) && result.length == 0',
|
||||
skip_if_stopped: true
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
value: {
|
||||
input_transforms: {},
|
||||
@@ -947,7 +974,7 @@
|
||||
pastModule?.value.type === 'rawscript' || pastModule?.value.type === 'script'
|
||||
? (pastModule as FlowModule & {
|
||||
value: RawScript | PathScript
|
||||
})
|
||||
})
|
||||
: undefined,
|
||||
isFirstInLoop,
|
||||
abortController
|
||||
@@ -1067,8 +1094,8 @@
|
||||
? isFirstInLoop
|
||||
? 'flow_input.iter.value'
|
||||
: pastModule
|
||||
? 'results.' + pastModule.id
|
||||
: 'flow_input.' + snakeKey
|
||||
? 'results.' + pastModule.id
|
||||
: 'flow_input.' + snakeKey
|
||||
: 'flow_input.' + snakeKey
|
||||
}
|
||||
$shouldUpdatePropertyType[key] = 'javascript'
|
||||
@@ -1201,7 +1228,7 @@
|
||||
},
|
||||
disabled: newFlow
|
||||
}
|
||||
]
|
||||
]
|
||||
: []),
|
||||
...(customUi?.topBar?.history != false
|
||||
? [
|
||||
@@ -1215,23 +1242,11 @@
|
||||
icon: FileJson,
|
||||
action: () => yamlEditorDrawer?.openDrawer()
|
||||
}
|
||||
]
|
||||
]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
|
||||
function handleDeployTrigger(trigger: Trigger) {
|
||||
const { id, path, type } = trigger
|
||||
//Update the saved flow to remove the draft trigger that is deployed
|
||||
if (savedFlow && savedFlow.draft && savedFlow.draft.draft_triggers) {
|
||||
const newSavedDraftTrigers = savedFlow.draft.draft_triggers.filter(
|
||||
(t) => t.id !== id || t.path !== path || t.type !== type
|
||||
)
|
||||
savedFlow.draft.draft_triggers =
|
||||
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
|
||||
}
|
||||
}
|
||||
|
||||
let flowPreviewButtons: FlowPreviewButtons
|
||||
</script>
|
||||
|
||||
@@ -1248,16 +1263,6 @@
|
||||
currentValue={$flowStore}
|
||||
/>
|
||||
|
||||
<DraftTriggersConfirmationModal
|
||||
bind:open={draftTriggersModalOpen}
|
||||
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
|
||||
isFlow={true}
|
||||
on:canceled={() => {
|
||||
draftTriggersModalOpen = false
|
||||
}}
|
||||
on:confirmed={handleDraftTriggersConfirmed}
|
||||
/>
|
||||
|
||||
{#key renderCount}
|
||||
{#if !$userStore?.operator}
|
||||
<FlowCopilotDrawer {getHubCompletions} {genFlow} bind:flowCopilotMode />
|
||||
@@ -1327,9 +1332,7 @@
|
||||
</div>
|
||||
|
||||
<div class="gap-4 flex-row hidden md:flex w-full max-w-md">
|
||||
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
|
||||
{@const primaryScheduleIndex = triggersState.triggers.findIndex((t) => t.isPrimary)}
|
||||
{@const scheduleIndex = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
|
||||
{#if $primaryScheduleStore != undefined ? $primaryScheduleStore && $primaryScheduleStore?.enabled : $triggersCount?.primary_schedule}
|
||||
<Button
|
||||
btnClasses="hidden lg:inline-flex"
|
||||
startIcon={{ icon: Calendar }}
|
||||
@@ -1338,15 +1341,14 @@
|
||||
size="xs"
|
||||
on:click={async () => {
|
||||
select('triggers')
|
||||
const selected = primaryScheduleIndex ?? scheduleIndex
|
||||
if (selected) {
|
||||
triggersState.selectedTriggerIndex = selected
|
||||
}
|
||||
selectTrigger('schedules')
|
||||
}}
|
||||
>
|
||||
{triggersState.triggers[primaryScheduleIndex]?.draftConfig?.schedule ??
|
||||
triggersState.triggers[primaryScheduleIndex]?.lightConfig?.schedule ??
|
||||
''}
|
||||
{$primaryScheduleStore != undefined
|
||||
? $primaryScheduleStore
|
||||
? $primaryScheduleStore?.cron
|
||||
: ''
|
||||
: $triggersCount?.primary_schedule?.schedule}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -1407,16 +1409,12 @@
|
||||
|
||||
await syncWithDeployed()
|
||||
|
||||
const currentDraftTriggers = structuredClone(
|
||||
triggersState.getDraftTriggersSnapshot()
|
||||
)
|
||||
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedFlow,
|
||||
draft: savedFlow?.draft,
|
||||
current: { ...$flowStore, path: $pathStore, draft_triggers: currentDraftTriggers }
|
||||
draft: savedFlow['draft'],
|
||||
current: { ...$flowStore, path: $pathStore }
|
||||
})
|
||||
}}
|
||||
disabled={!savedFlow}
|
||||
@@ -1439,7 +1437,7 @@
|
||||
<FlowPreviewButtons
|
||||
on:openTriggers={(e) => {
|
||||
select('triggers')
|
||||
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, e.detail.kind)
|
||||
selectTrigger(e.detail.kind)
|
||||
captureOn.set(true)
|
||||
showCaptureHint.set(true)
|
||||
}}
|
||||
@@ -1491,7 +1489,6 @@
|
||||
flowPreviewButtons?.openPreview(true)
|
||||
}}
|
||||
{savedFlow}
|
||||
onDeployTrigger={handleDeployTrigger}
|
||||
/>
|
||||
{:else}
|
||||
<CenteredPage>Loading...</CenteredPage>
|
||||
|
||||
@@ -495,6 +495,7 @@
|
||||
|
||||
async function updateJobId() {
|
||||
if (jobId !== job?.id) {
|
||||
console.log('updating job id', globalDurationStatuses.length)
|
||||
$localModuleStates = {}
|
||||
flowTimeline?.reset()
|
||||
timeout && clearTimeout(timeout)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { BROWSER } from 'esm-env'
|
||||
|
||||
import { editor as meditor } from 'monaco-editor'
|
||||
// import '@codingame/monaco-vscode-standalone-languages'
|
||||
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
|
||||
|
||||
@@ -573,6 +573,9 @@
|
||||
<SimpleEditor
|
||||
bind:this={monaco}
|
||||
bind:code={arg.expr}
|
||||
on:change={() => {
|
||||
dispatch('change', { argName, arg })
|
||||
}}
|
||||
{extraLib}
|
||||
lang="javascript"
|
||||
shouldBindKey={false}
|
||||
@@ -592,7 +595,6 @@
|
||||
autoHeight
|
||||
loadAsync
|
||||
/>
|
||||
<!-- <input type="text" bind:value={arg.expr} /> -->
|
||||
</div>
|
||||
{#if !hideHelpButton}
|
||||
<DynamicInputHelpBox />
|
||||
|
||||
@@ -7,15 +7,13 @@
|
||||
export let disabled = false
|
||||
export let headless = false
|
||||
export let required = false
|
||||
export let headerClass = ''
|
||||
</script>
|
||||
|
||||
<div class={twMerge(disabled ? 'opacity-60 pointer-events-none' : '', $$props.class)}>
|
||||
<div class="flex flex-row justify-between items-center w-full">
|
||||
{#if !headless}
|
||||
<div class={twMerge('flex flex-row items-center gap-2', headerClass)}>
|
||||
<span
|
||||
class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6 whitespace-nowrap"
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="{primary ? 'text-primary' : 'text-secondary'} text-sm leading-6"
|
||||
>{label}
|
||||
{#if required}
|
||||
<Required required={true} />
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
val.path,
|
||||
script.content,
|
||||
script.language,
|
||||
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
|
||||
args,
|
||||
$flowStore?.tag ?? (val.tag_override ? val.tag_override : script.tag),
|
||||
script.lock,
|
||||
val.hash ?? script.hash
|
||||
|
||||
@@ -35,7 +35,6 @@
|
||||
import { random_adj } from './random_positive_adjetive'
|
||||
import { Eye, Folder, Loader2, Plus, SearchCode, User } from 'lucide-svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { tick } from 'svelte'
|
||||
|
||||
type PathKind =
|
||||
| 'resource'
|
||||
@@ -65,7 +64,6 @@
|
||||
export let dirty = false
|
||||
export let kind: PathKind
|
||||
export let hideUser: boolean = false
|
||||
export let disableEditing = false
|
||||
|
||||
let inputP: HTMLInputElement | undefined = undefined
|
||||
|
||||
@@ -302,8 +300,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function initPath() {
|
||||
await tick()
|
||||
function initPath() {
|
||||
if (path != undefined && path != '') {
|
||||
meta = pathToMeta(path, hideUser)
|
||||
onMetaChange()
|
||||
@@ -428,12 +425,11 @@
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled || disableEditing}
|
||||
let:item
|
||||
>
|
||||
<ToggleButton
|
||||
icon={User}
|
||||
disabled={disabled || disableEditing}
|
||||
{disabled}
|
||||
light
|
||||
size="xs"
|
||||
value="user"
|
||||
@@ -444,7 +440,7 @@
|
||||
<!-- <ToggleButton light size="xs" value="group" position="center">Group</ToggleButton> -->
|
||||
<ToggleButton
|
||||
icon={Folder}
|
||||
disabled={disabled || disableEditing}
|
||||
{disabled}
|
||||
light
|
||||
size="xs"
|
||||
value="folder"
|
||||
@@ -466,20 +462,14 @@
|
||||
type="text"
|
||||
bind:value={meta.owner}
|
||||
placeholder={$userStore?.username ?? ''}
|
||||
disabled={disabled ||
|
||||
!($superadmin || ($userStore?.is_admin ?? false)) ||
|
||||
disableEditing}
|
||||
disabled={disabled || !($superadmin || ($userStore?.is_admin ?? false))}
|
||||
on:keydown={setDirty}
|
||||
/>
|
||||
</label>
|
||||
{:else if meta.ownerKind === 'folder'}
|
||||
<label class="block grow w-48">
|
||||
<div class="flex flex-row items-center gap-1 w-full">
|
||||
<select
|
||||
class="grow w-full"
|
||||
disabled={disabled || disableEditing}
|
||||
bind:value={meta.owner}
|
||||
>
|
||||
<select class="grow w-full" {disabled} bind:value={meta.owner}>
|
||||
{#if folders?.length == 0}
|
||||
<option disabled>No folders</option>
|
||||
{/if}
|
||||
@@ -498,19 +488,17 @@
|
||||
iconOnly
|
||||
startIcon={{ icon: Eye }}
|
||||
/>
|
||||
{#if !disableEditing}
|
||||
<Button
|
||||
title="New folder"
|
||||
btnClasses="!p-1.5"
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs"
|
||||
{disabled}
|
||||
on:click={newFolder.openDrawer}
|
||||
iconOnly
|
||||
startIcon={{ icon: Plus }}
|
||||
/>
|
||||
{/if}
|
||||
<Button
|
||||
title="New folder"
|
||||
btnClasses="!p-1.5"
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs"
|
||||
{disabled}
|
||||
on:click={newFolder.openDrawer}
|
||||
iconOnly
|
||||
startIcon={{ icon: Plus }}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{/if}
|
||||
@@ -519,7 +507,7 @@
|
||||
<label class="block grow w-full max-w-md">
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<input
|
||||
disabled={disabled || disableEditing}
|
||||
{disabled}
|
||||
type="text"
|
||||
id="path"
|
||||
{autofocus}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { Badge, Button } from './common'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { base } from '$lib/base'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { ListOrdered, PenBox } from 'lucide-svelte'
|
||||
import JobArgs from './JobArgs.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import { type Schedule } from '$lib/gen'
|
||||
|
||||
export let schedule: any
|
||||
export let can_write: boolean
|
||||
export let path: string
|
||||
export let isFlow: boolean
|
||||
export let scheduleEditor: ScheduleEditor
|
||||
export let setScheduleEnabled: (path: string, enabled: boolean) => void
|
||||
|
||||
$: schedule = typeof schedule === 'boolean' ? undefined : (schedule as Schedule | undefined)
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-2 grow w-full">
|
||||
<div class="grid grid-cols-3 w-full">
|
||||
<div class="flex justify-start">
|
||||
<Badge color="indigo" small>
|
||||
Primary
|
||||
<Tooltip light>
|
||||
Share the same path as the script or flow it is attached to and its path get renamed
|
||||
whenever the source path is renamed
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex justify-center">
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<input
|
||||
size="9"
|
||||
class="!text-xs !h-6 !text-primary"
|
||||
type="text"
|
||||
id="cron-schedule"
|
||||
name="cron-schedule"
|
||||
placeholder="*/30 * * * *"
|
||||
value={schedule?.schedule ?? ''}
|
||||
disabled={true}
|
||||
/>
|
||||
<Toggle
|
||||
checked={schedule?.enabled ?? false}
|
||||
on:change={(e) => {
|
||||
if (can_write) {
|
||||
setScheduleEnabled(path, e.detail)
|
||||
} else {
|
||||
sendUserToast('not enough permission', true)
|
||||
}
|
||||
}}
|
||||
options={{
|
||||
right: 'On'
|
||||
}}
|
||||
size="xs"
|
||||
textClass="text-primary font-normal text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button size={'xs'} variant="border" color="light" href={`${base}/runs/${path}`}>
|
||||
<span>Runs</span>
|
||||
<ListOrdered size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
color="dark"
|
||||
on:click={() => scheduleEditor?.openEdit(path ?? '', isFlow)}
|
||||
>
|
||||
<PenBox size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if Object.keys(schedule?.args ?? {}).length > 0}
|
||||
<div class="">
|
||||
<JobArgs args={schedule?.args ?? {}} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-xs text-tertiary"> No arguments </div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
import { Button } from './common'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { ScheduleService, type Schedule } from '$lib/gen'
|
||||
import { Calendar, Trash, Save } from 'lucide-svelte'
|
||||
import Skeleton from './common/skeleton/Skeleton.svelte'
|
||||
import Label from '$lib/components/Label.svelte'
|
||||
import { getContext } from 'svelte'
|
||||
import type { ScheduleTrigger, TriggerContext } from './triggers'
|
||||
import CronInput from './CronInput.svelte'
|
||||
import SchemaForm from './SchemaForm.svelte'
|
||||
import { emptyString, sendUserToast } from '$lib/utils'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { loadSchedules, saveSchedule } from './flows/scheduleUtils'
|
||||
import { type Writable, writable } from 'svelte/store'
|
||||
import Description from '$lib/components/Description.svelte'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
|
||||
export let schema: any
|
||||
export let isFlow: boolean
|
||||
export let path: string
|
||||
export let can_write: boolean
|
||||
export let newItem: boolean = false
|
||||
|
||||
const { primarySchedule, triggersCount } = getContext<TriggerContext>('TriggerContext')
|
||||
|
||||
let scheduleEditor: ScheduleEditor
|
||||
let schedules: Writable<Schedule[] | undefined> = writable(undefined)
|
||||
let initialPrimarySchedule: Writable<ScheduleTrigger | false | undefined> = writable(undefined)
|
||||
|
||||
async function updateSchedules(forceRefresh: boolean) {
|
||||
const loadPrimarySchedule = true
|
||||
loadSchedules(
|
||||
forceRefresh,
|
||||
path,
|
||||
isFlow,
|
||||
schedules,
|
||||
primarySchedule,
|
||||
initialPrimarySchedule,
|
||||
$workspaceStore ?? '',
|
||||
triggersCount,
|
||||
loadPrimarySchedule
|
||||
)
|
||||
}
|
||||
|
||||
$: updateSchedules(false) || path
|
||||
|
||||
async function save() {
|
||||
await saveSchedule(path, newItem, $workspaceStore ?? '', primarySchedule, isFlow)
|
||||
updateSchedules(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4 w-full">
|
||||
<Description link="https://www.windmill.dev/docs/core_concepts/scheduling">
|
||||
Run scripts and flows automatically on a recurring basis using cron expressions. Each script or
|
||||
flow can have multiple schedules, with one designated as primary.
|
||||
</Description>
|
||||
<ScheduleEditor
|
||||
on:update={() => {
|
||||
updateSchedules(true)
|
||||
}}
|
||||
bind:this={scheduleEditor}
|
||||
/>
|
||||
|
||||
{#if $primarySchedule == undefined}
|
||||
<Skeleton layout={[[12]]} />
|
||||
{:else if $primarySchedule}
|
||||
<div class="w-full flex flex-col mb-4">
|
||||
{#if can_write}
|
||||
<div class="w-full flex-row-reverse flex mb-2">
|
||||
<div class="flex flex-row gap-4">
|
||||
<Button
|
||||
on:click={() => {
|
||||
$primarySchedule = false
|
||||
$triggersCount = {
|
||||
...($triggersCount ?? {}),
|
||||
schedule_count: ($triggersCount?.schedule_count ?? 1) - 1,
|
||||
primary_schedule: undefined
|
||||
}
|
||||
}}
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs"
|
||||
startIcon={{ icon: Trash }}
|
||||
/>
|
||||
{#if initialPrimarySchedule && !newItem}
|
||||
<Toggle
|
||||
disabled={emptyString($primarySchedule.cron)}
|
||||
bind:checked={$primarySchedule.enabled}
|
||||
options={{
|
||||
right: 'Enabled'
|
||||
}}
|
||||
on:change={async (e) => {
|
||||
if (!newItem && $initialPrimarySchedule != false) {
|
||||
await ScheduleService.setScheduleEnabled({
|
||||
path: path,
|
||||
workspace: $workspaceStore ?? '',
|
||||
requestBody: { enabled: e.detail }
|
||||
})
|
||||
|
||||
sendUserToast(`${e.detail ? 'enabled' : 'disabled'} schedule ${path}`)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !newItem}
|
||||
<Button
|
||||
on:click={save}
|
||||
color="dark"
|
||||
size="sm"
|
||||
startIcon={{ icon: Save }}
|
||||
disabled={JSON.stringify({ ...$primarySchedule, enabled: true }) ==
|
||||
JSON.stringify({ ...initialPrimarySchedule, enabled: true })}
|
||||
>Apply changes now</Button
|
||||
>
|
||||
{:else}
|
||||
<div class="text-sm text-secondary mt-1 text-center"
|
||||
>Deployed automatically with {isFlow ? 'flow' : 'script'}</div
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<div class="mt-5">
|
||||
<Label label="Summary" class="font-semibold" primary>
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
placeholder="Short summary to be displayed when listed"
|
||||
class="text-sm w-full"
|
||||
bind:value={$primarySchedule.summary}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<CronInput bind:schedule={$primarySchedule.cron} bind:timezone={$primarySchedule.timezone} />
|
||||
<SchemaForm onlyMaskPassword {schema} bind:args={$primarySchedule.args} />
|
||||
{#if emptyString($primarySchedule.cron)}
|
||||
<p class="text-xs text-tertiary mt-10">Define a schedule frequency first</p>
|
||||
{/if}
|
||||
|
||||
{#if $initialPrimarySchedule != false && !newItem}
|
||||
<div class="flex">
|
||||
<Button size="xs" color="light" on:click={() => scheduleEditor?.openEdit(path, isFlow)}
|
||||
>Advanced</Button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex flex-row gap-4 mt-2">
|
||||
<div class="flex items-center">
|
||||
<Button
|
||||
on:click={() => {
|
||||
$primarySchedule = {
|
||||
summary: '',
|
||||
args: {},
|
||||
cron: '0 0 */1 * * *',
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
enabled: true
|
||||
}
|
||||
$triggersCount = {
|
||||
...($triggersCount ?? {}),
|
||||
schedule_count: ($triggersCount?.schedule_count ?? 0) + 1,
|
||||
primary_schedule: { schedule: $primarySchedule.cron }
|
||||
}
|
||||
}}
|
||||
variant="contained"
|
||||
color="dark"
|
||||
size="sm"
|
||||
startIcon={{ icon: Calendar }}
|
||||
>
|
||||
Set primary schedule
|
||||
</Button>
|
||||
</div>
|
||||
{#if $initialPrimarySchedule != undefined && $initialPrimarySchedule != false && !newItem}
|
||||
<Button on:click={save} color="dark" size="md" startIcon={{ icon: Save }}>
|
||||
Apply changes now
|
||||
</Button>
|
||||
{:else}
|
||||
<div class="text-sm text-center text-secondary mt-2"
|
||||
>Deployed automatically with {isFlow ? 'flow' : 'script'}</div
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Label label="Summary" class="font-semibold" primary>
|
||||
<input
|
||||
type="text"
|
||||
disabled
|
||||
placeholder="Short summary to be displayed when listed"
|
||||
class="text-sm w-full"
|
||||
/>
|
||||
</Label>
|
||||
<CronInput schedule={''} disabled timezone={Intl.DateTimeFormat().resolvedOptions().timeZone} />
|
||||
|
||||
<SchemaForm disabled {schema} />
|
||||
{/if}
|
||||
|
||||
{#if !newItem}
|
||||
<div class="mt-10"></div>
|
||||
{#if $primarySchedule}
|
||||
<Button
|
||||
on:click={() => scheduleEditor?.openNew(isFlow, path)}
|
||||
variant="border"
|
||||
color="light"
|
||||
size="xs"
|
||||
startIcon={{ icon: Calendar }}
|
||||
>
|
||||
New schedule
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Label label="Other schedules">
|
||||
{#if $schedules}
|
||||
{#if $schedules.length == 0 || $schedules == undefined}
|
||||
<div class="text-xs text-tertiary"> No other schedules </div>
|
||||
{:else}
|
||||
<div class="flex flex-col divide-y">
|
||||
{#each $schedules as schedule (schedule.path)}
|
||||
<div class="grid grid-cols-6 text-xs items-center py-2">
|
||||
<div class="col-span-3 truncate">{schedule.path}</div>
|
||||
<div class="col-span-2 flex flex-row gap-4 flex-nowrap">
|
||||
<div>{schedule.schedule}</div>
|
||||
<div>{schedule.enabled ? 'on' : 'off'}</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
on:click={() => scheduleEditor?.openEdit(schedule.path, isFlow)}
|
||||
class="px-2">Edit</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<Skeleton layout={[[8]]} />
|
||||
{/if}
|
||||
</Label>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ScheduleEditorInner from '$lib/components/triggers/schedules/ScheduleEditorInner.svelte'
|
||||
import Description from '$lib/components/Description.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
let scheduleEditor = $state<ScheduleEditorInner | null>(null)
|
||||
let {
|
||||
selectedTrigger,
|
||||
isFlow,
|
||||
path,
|
||||
defaultValues = undefined,
|
||||
schema,
|
||||
customLabel = undefined,
|
||||
...restProps
|
||||
} = $props()
|
||||
|
||||
function openScheduleEditor(isFlow: boolean, isDraft: boolean) {
|
||||
if (isDraft) {
|
||||
scheduleEditor?.openNew(isFlow, path, defaultValues)
|
||||
} else {
|
||||
scheduleEditor?.openEdit(selectedTrigger.path, isFlow, defaultValues)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
selectedTrigger?.type === 'schedule' &&
|
||||
scheduleEditor &&
|
||||
openScheduleEditor(isFlow, selectedTrigger.isDraft ?? false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<ScheduleEditorInner
|
||||
useDrawer={false}
|
||||
bind:this={scheduleEditor}
|
||||
hideTarget
|
||||
allowDraft
|
||||
hasDraft={!!selectedTrigger.draftConfig}
|
||||
isDraftOnly={selectedTrigger.isDraft}
|
||||
primary={selectedTrigger.isPrimary}
|
||||
draftSchema={schema}
|
||||
{customLabel}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet docDescription()}
|
||||
<div class="flex flex-col gap-2 pb-4">
|
||||
<Description link="https://www.windmill.dev/docs/core_concepts/scheduling">
|
||||
Run scripts and flows automatically on a recurring basis using cron expressions.
|
||||
</Description>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ScheduleEditorInner>
|
||||
<!-- hideTarget
|
||||
hidePath
|
||||
{header} -->
|
||||
@@ -4,26 +4,21 @@
|
||||
type NewScript,
|
||||
ScriptService,
|
||||
type NewScriptWithDraft,
|
||||
ScheduleService,
|
||||
type Script,
|
||||
type TriggersCount,
|
||||
PostgresTriggerService,
|
||||
CaptureService,
|
||||
type ScriptLang
|
||||
CaptureService
|
||||
} from '$lib/gen'
|
||||
import { inferArgs } from '$lib/infer'
|
||||
import { initialCode } from '$lib/script_helpers'
|
||||
import {
|
||||
defaultScripts,
|
||||
enterpriseLicense,
|
||||
usedTriggerKinds,
|
||||
userStore,
|
||||
workspaceStore
|
||||
} from '$lib/stores'
|
||||
import { defaultScripts, enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
cleanValueProperties,
|
||||
emptySchema,
|
||||
emptyString,
|
||||
encodeState,
|
||||
formatCron,
|
||||
generateRandomString,
|
||||
orderedJsonStringify,
|
||||
replaceFalseWithUndefined,
|
||||
@@ -73,7 +68,7 @@
|
||||
import type { ScriptBuilderWhitelabelCustomUi } from './custom_ui'
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
import TriggersEditor from './triggers/TriggersEditor.svelte'
|
||||
import type { ScheduleTrigger, TriggerContext } from './triggers'
|
||||
import type { ScheduleTrigger, TriggerContext, TriggerKind } from './triggers'
|
||||
import {
|
||||
TS_PREPROCESSOR_MODULE_CODE,
|
||||
TS_PREPROCESSOR_SCRIPT_INTRO,
|
||||
@@ -84,18 +79,8 @@
|
||||
import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes'
|
||||
import type { ScriptBuilderFunctionExports } from './scriptBuilder'
|
||||
import DeployButton from './DeployButton.svelte'
|
||||
import {
|
||||
type NewScriptWithDraftAndDraftTriggers,
|
||||
type Trigger,
|
||||
deployTriggers,
|
||||
filterDraftTriggers,
|
||||
handleSelectTriggerFromKind
|
||||
} from './triggers/utils'
|
||||
import DraftTriggersConfirmationModal from './common/confirmationModal/DraftTriggersConfirmationModal.svelte'
|
||||
import { Triggers } from './triggers/triggers.svelte'
|
||||
import TriggerableByAI from './TriggerableByAI.svelte'
|
||||
|
||||
export let script: NewScript & { draft_triggers?: Trigger[] }
|
||||
export let script: NewScript
|
||||
export let fullyLoaded: boolean = true
|
||||
export let initialPath: string = ''
|
||||
export let template: 'docker' | 'bunnative' | 'script' = 'script'
|
||||
@@ -104,7 +89,7 @@
|
||||
export let showMeta: boolean = false
|
||||
export let neverShowMeta: boolean = false
|
||||
export let diffDrawer: DiffDrawer | undefined = undefined
|
||||
export let savedScript: NewScriptWithDraftAndDraftTriggers | undefined = undefined
|
||||
export let savedScript: NewScriptWithDraft | undefined = undefined
|
||||
export let searchParams: URLSearchParams = new URLSearchParams()
|
||||
export let disableHistoryChange = false
|
||||
export let replaceStateFn: (url: string) => void = (url) =>
|
||||
@@ -117,10 +102,7 @@
|
||||
export function getInitialAndModifiedValues(): SavedAndModifiedValue {
|
||||
return {
|
||||
savedValue: savedScript,
|
||||
modifiedValue: {
|
||||
...script,
|
||||
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
}
|
||||
modifiedValue: script
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,38 +134,20 @@
|
||||
let scriptEditor: ScriptEditor | undefined = undefined
|
||||
let captureTable: CaptureTable | undefined = undefined
|
||||
|
||||
// Draft triggers confirmation modal
|
||||
let draftTriggersModalOpen = false
|
||||
let confirmDeploymentCallback: (triggersToDeploy: Trigger[]) => void = () => {}
|
||||
|
||||
async function handleDraftTriggersConfirmed(event: CustomEvent<{ selectedTriggers: Trigger[] }>) {
|
||||
const { selectedTriggers } = event.detail
|
||||
// Continue with saving the flow
|
||||
draftTriggersModalOpen = false
|
||||
confirmDeploymentCallback(selectedTriggers)
|
||||
}
|
||||
|
||||
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule) // keep for legacy
|
||||
const primaryScheduleStore = writable<ScheduleTrigger | undefined | false>(savedPrimarySchedule)
|
||||
const triggersCount = writable<TriggersCount | undefined>(
|
||||
savedPrimarySchedule
|
||||
? { schedule_count: 1, primary_schedule: { schedule: savedPrimarySchedule.cron } }
|
||||
: undefined
|
||||
)
|
||||
const simplifiedPoll = writable(false)
|
||||
const selectedTriggerStore = writable<TriggerKind>('webhooks')
|
||||
|
||||
export function setPrimarySchedule(schedule: ScheduleTrigger | undefined | false) {
|
||||
primaryScheduleStore.set(schedule)
|
||||
loadTriggers()
|
||||
}
|
||||
|
||||
export function setDraftTriggers(triggers: Trigger[] | undefined) {
|
||||
triggersState.setTriggers([
|
||||
...(triggers ?? []),
|
||||
...triggersState.triggers.filter((t) => !t.draftConfig)
|
||||
])
|
||||
loadTriggers()
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: initialPath != '' && loadTriggers()
|
||||
@@ -212,42 +176,29 @@
|
||||
workspace: $workspaceStore!,
|
||||
path: initialPath
|
||||
})
|
||||
|
||||
await triggersState.fetchTriggers(
|
||||
triggersCount,
|
||||
$workspaceStore,
|
||||
initialPath,
|
||||
false,
|
||||
$primaryScheduleStore,
|
||||
$userStore
|
||||
)
|
||||
|
||||
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
|
||||
savedScript = filterDraftTriggers(
|
||||
savedScript,
|
||||
triggersState
|
||||
) as NewScriptWithDraftAndDraftTriggers
|
||||
if ($primaryScheduleStore && $triggersCount.primary_schedule == undefined) {
|
||||
$triggersCount = {
|
||||
...($triggersCount ?? {}),
|
||||
schedule_count: ($triggersCount.schedule_count ?? 0) + 1,
|
||||
primary_schedule: {
|
||||
schedule: $primaryScheduleStore.cron
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add triggers context store
|
||||
const triggersState = new Triggers(
|
||||
[
|
||||
{ type: 'webhook', path: '', isDraft: false },
|
||||
{ type: 'email', path: '', isDraft: false },
|
||||
...(script.draft_triggers ?? [])
|
||||
],
|
||||
undefined,
|
||||
saveSessionDraft
|
||||
)
|
||||
const triggerDefaultValuesStore = writable<Record<string, any> | undefined>(undefined)
|
||||
|
||||
const captureOn = writable<boolean | undefined>(undefined)
|
||||
const showCaptureHint = writable<boolean | undefined>(undefined)
|
||||
setContext<TriggerContext>('TriggerContext', {
|
||||
selectedTrigger: selectedTriggerStore,
|
||||
primarySchedule: primaryScheduleStore,
|
||||
triggersCount,
|
||||
simplifiedPoll,
|
||||
showCaptureHint: showCaptureHint,
|
||||
triggersState
|
||||
defaultValues: triggerDefaultValuesStore,
|
||||
captureOn: captureOn,
|
||||
showCaptureHint: showCaptureHint
|
||||
})
|
||||
|
||||
const enterpriseLangs = ['bigquery', 'snowflake', 'mssql', 'oracledb']
|
||||
@@ -323,26 +274,8 @@
|
||||
})
|
||||
}
|
||||
|
||||
$: !disableHistoryChange && encodeScriptState(script)
|
||||
|
||||
function encodeScriptState(script: NewScript) {
|
||||
replaceStateFn(
|
||||
'#' +
|
||||
encodeState({
|
||||
...script,
|
||||
draft_triggers: structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
let timeout: NodeJS.Timeout | undefined = undefined
|
||||
function saveSessionDraft() {
|
||||
timeout && clearTimeout(timeout)
|
||||
timeout = setTimeout(() => {
|
||||
encodeScriptState(script)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
$: !disableHistoryChange &&
|
||||
replaceStateFn('#' + encodeState({ ...script, primarySchedule: $primaryScheduleStore }))
|
||||
if (script.content == '') {
|
||||
initContent(script.language, script.kind, template)
|
||||
}
|
||||
@@ -384,6 +317,31 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function createSchedule(path: string) {
|
||||
if (!$primaryScheduleStore) {
|
||||
return
|
||||
}
|
||||
const { cron, timezone, args, enabled, summary } = $primaryScheduleStore
|
||||
|
||||
try {
|
||||
await ScheduleService.createSchedule({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: path,
|
||||
schedule: formatCron(cron),
|
||||
timezone,
|
||||
script_path: path,
|
||||
is_flow: false,
|
||||
args,
|
||||
enabled,
|
||||
summary
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
sendUserToast(`The primary schedule could not be created: ${err}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEditScript(stay: boolean, deployMsg?: string): Promise<void> {
|
||||
// Fetch latest version and fetch entire script after if needed
|
||||
let actual_parent_hash: string | undefined = undefined
|
||||
@@ -461,21 +419,8 @@
|
||||
async function editScript(
|
||||
stay: boolean,
|
||||
parentHash: string,
|
||||
deploymentMsg?: string,
|
||||
triggersToDeploy?: Trigger[]
|
||||
deploymentMsg?: string
|
||||
): Promise<void> {
|
||||
if (!triggersToDeploy) {
|
||||
// Check if there are draft triggers that need confirmation
|
||||
const draftTriggers = triggersState.triggers.filter((trigger) => trigger.draftConfig)
|
||||
if (draftTriggers.length > 0) {
|
||||
draftTriggersModalOpen = true
|
||||
confirmDeploymentCallback = async (triggersToDeploy: Trigger[]) => {
|
||||
await editScript(stay, parentHash, deploymentMsg, triggersToDeploy)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loadingSave = true
|
||||
try {
|
||||
try {
|
||||
@@ -485,19 +430,9 @@
|
||||
}
|
||||
script.schema = script.schema ?? emptySchema()
|
||||
try {
|
||||
const result = await inferArgs(
|
||||
script.language,
|
||||
script.content,
|
||||
script.schema as any,
|
||||
script.kind === 'preprocessor' ? 'preprocessor' : undefined
|
||||
)
|
||||
if (script.kind === 'preprocessor') {
|
||||
script.no_main_func = undefined
|
||||
script.has_preprocessor = undefined
|
||||
} else {
|
||||
script.no_main_func = result?.no_main_func || undefined
|
||||
script.has_preprocessor = result?.has_preprocessor || undefined
|
||||
}
|
||||
const result = await inferArgs(script.language, script.content, script.schema as any)
|
||||
script.no_main_func = result?.no_main_func || undefined
|
||||
script.has_preprocessor = result?.has_preprocessor || undefined
|
||||
} catch (error) {
|
||||
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
|
||||
}
|
||||
@@ -545,27 +480,60 @@
|
||||
})
|
||||
}
|
||||
|
||||
if (triggersToDeploy) {
|
||||
await deployTriggers(
|
||||
triggersToDeploy,
|
||||
$workspaceStore,
|
||||
!!$userStore?.is_admin || !!$userStore?.is_super_admin,
|
||||
usedTriggerKinds,
|
||||
script.path,
|
||||
true
|
||||
)
|
||||
const scheduleExists =
|
||||
initialPath != '' &&
|
||||
(await ScheduleService.existsSchedule({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: script.path
|
||||
}))
|
||||
if ($primaryScheduleStore) {
|
||||
const { enabled, timezone, args, cron, summary } = $primaryScheduleStore
|
||||
|
||||
if (scheduleExists) {
|
||||
const schedule = await ScheduleService.getSchedule({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: script.path
|
||||
})
|
||||
if (
|
||||
JSON.stringify(schedule.args) != JSON.stringify(args) ||
|
||||
schedule.schedule != cron ||
|
||||
schedule.timezone != timezone ||
|
||||
schedule.summary != summary
|
||||
) {
|
||||
await ScheduleService.updateSchedule({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: script.path,
|
||||
requestBody: {
|
||||
schedule: formatCron(cron),
|
||||
timezone,
|
||||
args,
|
||||
summary
|
||||
}
|
||||
})
|
||||
}
|
||||
if (enabled != schedule.enabled) {
|
||||
await ScheduleService.setScheduleEnabled({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: script.path,
|
||||
requestBody: { enabled }
|
||||
})
|
||||
}
|
||||
} else if (enabled) {
|
||||
await createSchedule(script.path)
|
||||
}
|
||||
} else if (scheduleExists && !$triggersCount?.primary_schedule) {
|
||||
await ScheduleService.deleteSchedule({
|
||||
workspace: $workspaceStore ?? '',
|
||||
path: script.path
|
||||
})
|
||||
}
|
||||
|
||||
const { draft_triggers: _, ...newScript } = structuredClone(script)
|
||||
savedScript = structuredClone(newScript) as NewScriptWithDraft
|
||||
triggersState.setTriggers([])
|
||||
|
||||
savedScript = structuredClone(script) as NewScriptWithDraft
|
||||
if (!disableHistoryChange) {
|
||||
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
|
||||
}
|
||||
if (stay || script.kind !== 'script' || script.no_main_func) {
|
||||
if (stay) {
|
||||
script.parent_hash = newHash
|
||||
sendUserToast('Deployed')
|
||||
} else {
|
||||
dispatch('deploy', newHash)
|
||||
}
|
||||
@@ -583,8 +551,7 @@
|
||||
|
||||
if (savedScript) {
|
||||
const draftOrDeployed = cleanValueProperties(savedScript.draft || savedScript)
|
||||
const currentTriggers = structuredClone(triggersState.getDraftTriggersSnapshot())
|
||||
const current = cleanValueProperties({ ...script, draft_triggers: currentTriggers })
|
||||
const current = cleanValueProperties(script)
|
||||
if (!forceSave && orderedJsonStringify(draftOrDeployed) === orderedJsonStringify(current)) {
|
||||
sendUserToast('No changes detected, ignoring', false, [
|
||||
{
|
||||
@@ -607,19 +574,9 @@
|
||||
}
|
||||
script.schema = script.schema ?? emptySchema()
|
||||
try {
|
||||
const result = await inferArgs(
|
||||
script.language,
|
||||
script.content,
|
||||
script.schema as any,
|
||||
script.kind === 'preprocessor' ? 'preprocessor' : undefined
|
||||
)
|
||||
if (script.kind === 'preprocessor') {
|
||||
script.no_main_func = undefined
|
||||
script.has_preprocessor = undefined
|
||||
} else {
|
||||
script.no_main_func = result?.no_main_func || undefined
|
||||
script.has_preprocessor = result?.has_preprocessor || undefined
|
||||
}
|
||||
const result = await inferArgs(script.language, script.content, script.schema as any)
|
||||
script.no_main_func = result?.no_main_func || undefined
|
||||
script.has_preprocessor = result?.has_preprocessor || undefined
|
||||
} catch (error) {
|
||||
sendUserToast(`Could not parse code, are you sure it is valid?`, true)
|
||||
}
|
||||
@@ -675,16 +632,12 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
const draftTriggers = triggersState.getDraftTriggersSnapshot()
|
||||
await DraftService.createDraft({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
path: initialPath == '' || savedScript?.draft_only ? script.path : initialPath,
|
||||
typ: 'script',
|
||||
value: {
|
||||
...script,
|
||||
draft_triggers: draftTriggers
|
||||
}
|
||||
value: { ...script, primary_schedule: $primaryScheduleStore }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -692,11 +645,8 @@
|
||||
...(initialPath == '' || savedScript?.draft_only
|
||||
? { ...structuredClone(script), draft_only: true }
|
||||
: savedScript),
|
||||
draft: {
|
||||
...structuredClone(script),
|
||||
draft_triggers: draftTriggers
|
||||
}
|
||||
} as NewScriptWithDraftAndDraftTriggers
|
||||
draft: structuredClone(script)
|
||||
} as NewScriptWithDraft
|
||||
|
||||
let savedAtNewPath = false
|
||||
if (initialPath == '' || (savedScript?.draft_only && script.path !== initialPath)) {
|
||||
@@ -719,7 +669,7 @@
|
||||
|
||||
function computeDropdownItems(
|
||||
initialPath: string,
|
||||
savedScript: NewScriptWithDraftAndDraftTriggers | undefined,
|
||||
savedScript: NewScriptWithDraft | undefined,
|
||||
diffDrawer: DiffDrawer | undefined
|
||||
) {
|
||||
let dropdownItems: { label: string; onClick: () => void }[] =
|
||||
@@ -747,25 +697,18 @@
|
||||
}
|
||||
await syncWithDeployed()
|
||||
|
||||
const currentDraftTriggers = structuredClone(
|
||||
triggersState.getDraftTriggersSnapshot()
|
||||
)
|
||||
|
||||
diffDrawer?.openDrawer()
|
||||
diffDrawer?.setDiff({
|
||||
mode: 'normal',
|
||||
deployed: deployedValue ?? savedScript,
|
||||
draft: savedScript['draft'],
|
||||
current: {
|
||||
...script,
|
||||
draft_triggers: currentDraftTriggers
|
||||
}
|
||||
current: script
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(!script.draft_only && script.kind === 'script' && !script.no_main_func
|
||||
...(!script.draft_only
|
||||
? [
|
||||
{
|
||||
label: 'Exit & See details',
|
||||
@@ -835,7 +778,8 @@
|
||||
function openTriggers(ev) {
|
||||
metadataOpen = true
|
||||
selectedTab = 'triggers'
|
||||
handleSelectTriggerFromKind(triggersState, triggersCount, initialPath, ev.detail.kind)
|
||||
selectedTriggerStore.set(ev.detail.kind)
|
||||
triggerDefaultValuesStore.set(ev.detail.config)
|
||||
captureOn.set(true)
|
||||
}
|
||||
|
||||
@@ -859,59 +803,6 @@
|
||||
}
|
||||
selectedInputTab = 'preprocessor'
|
||||
}
|
||||
|
||||
function handleDeployTrigger(trigger: Trigger) {
|
||||
const { id, path, type } = trigger
|
||||
//Update the saved script to remove the draft trigger that is deployed
|
||||
if (savedScript && savedScript.draft && savedScript.draft.draft_triggers) {
|
||||
const newSavedDraftTrigers = savedScript.draft.draft_triggers.filter(
|
||||
(t) => t.id !== id || t.path !== path || t.type !== type
|
||||
)
|
||||
savedScript.draft.draft_triggers =
|
||||
newSavedDraftTrigers.length > 0 ? newSavedDraftTrigers : undefined
|
||||
}
|
||||
}
|
||||
|
||||
function onScriptLanguageTrigger(lang: 'docker' | 'bunnative' | ScriptLang) {
|
||||
if (lang == 'docker') {
|
||||
if (isCloudHosted()) {
|
||||
sendUserToast(
|
||||
'You cannot use Docker scripts on the multi-tenant platform. Use a dedicated instance or self-host windmill instead.',
|
||||
true,
|
||||
[
|
||||
{
|
||||
label: 'Learn more',
|
||||
callback: () => {
|
||||
window.open('https://www.windmill.dev/docs/advanced/docker', '_blank')
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
template = 'docker'
|
||||
} else if (lang == 'bunnative') {
|
||||
template = 'bunnative'
|
||||
} else {
|
||||
template = 'script'
|
||||
}
|
||||
let language = langToLanguage(lang)
|
||||
//
|
||||
initContent(language, script.kind, template)
|
||||
script.language = language
|
||||
}
|
||||
|
||||
function onSummaryChange(value: string) {
|
||||
if (initialPath == '' && value?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={onKeyDown} />
|
||||
@@ -925,21 +816,11 @@
|
||||
bind:deployedValue
|
||||
currentValue={script}
|
||||
/>
|
||||
|
||||
<DraftTriggersConfirmationModal
|
||||
bind:open={draftTriggersModalOpen}
|
||||
draftTriggers={triggersState.triggers.filter((t) => t.draftConfig)}
|
||||
on:canceled={() => {
|
||||
draftTriggersModalOpen = false
|
||||
}}
|
||||
on:confirmed={handleDraftTriggersConfirmed}
|
||||
/>
|
||||
|
||||
{#if !$userStore?.operator}
|
||||
<Drawer
|
||||
placement="right"
|
||||
bind:open={metadataOpen}
|
||||
size={selectedTab === 'ui' || selectedTab === 'triggers' ? '1200px' : '800px'}
|
||||
size={selectedTab === 'ui' ? '1200px' : '800px'}
|
||||
>
|
||||
<DrawerContent noPadding title="Settings" on:close={() => (metadataOpen = false)}>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
@@ -990,31 +871,29 @@
|
||||
</svelte:fragment>
|
||||
<div class="flex flex-col gap-4">
|
||||
<Label label="Summary">
|
||||
<TriggerableByAI
|
||||
id="create-script-summary-input"
|
||||
description="Summary / Title of the new script"
|
||||
onTrigger={(value) => {
|
||||
console.log('Triggering example component with value', value)
|
||||
if (value) {
|
||||
script.summary = value
|
||||
onSummaryChange(value)
|
||||
<MetadataGen
|
||||
label="Summary"
|
||||
bind:content={script.summary}
|
||||
lang={script.language}
|
||||
code={script.content}
|
||||
promptConfigName="summary"
|
||||
generateOnAppear
|
||||
on:change={() => {
|
||||
if (initialPath == '' && script.summary?.length > 0 && !dirtyPath) {
|
||||
path?.setName(
|
||||
script.summary
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9_]/g, '_')
|
||||
.replace(/-+/g, '_')
|
||||
.replace(/^-|-$/g, '')
|
||||
)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MetadataGen
|
||||
label="Summary"
|
||||
bind:content={script.summary}
|
||||
lang={script.language}
|
||||
code={script.content}
|
||||
promptConfigName="summary"
|
||||
generateOnAppear
|
||||
on:change={() => onSummaryChange(script.summary)}
|
||||
elementProps={{
|
||||
type: 'text',
|
||||
placeholder: 'Short summary to be displayed when listed'
|
||||
}}
|
||||
/>
|
||||
</TriggerableByAI>
|
||||
elementProps={{
|
||||
type: 'text',
|
||||
placeholder: 'Short summary to be displayed when listed'
|
||||
}}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="Path">
|
||||
<svelte:fragment slot="header">
|
||||
@@ -1067,32 +946,53 @@
|
||||
<Popover
|
||||
disablePopup={!enterpriseLangs.includes(lang) || !!$enterpriseLicense}
|
||||
>
|
||||
<TriggerableByAI
|
||||
id={`create-script-language-button-${lang}`}
|
||||
description={`Choose ${lang} as the language of the script`}
|
||||
onTrigger={() => {
|
||||
console.log('Triggering example component', lang)
|
||||
onScriptLanguageTrigger(lang)
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={isPicked ? 'blue' : 'light'}
|
||||
btnClasses={isPicked
|
||||
? '!border-2 !bg-blue-50/75 dark:!bg-frost-900/75'
|
||||
: 'm-[1px]'}
|
||||
on:click={() => {
|
||||
if (lang == 'docker') {
|
||||
if (isCloudHosted()) {
|
||||
sendUserToast(
|
||||
'You cannot use Docker scripts on the multi-tenant platform. Use a dedicated instance or self-host windmill instead.',
|
||||
true,
|
||||
[
|
||||
{
|
||||
label: 'Learn more',
|
||||
callback: () => {
|
||||
window.open(
|
||||
'https://www.windmill.dev/docs/advanced/docker',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
return
|
||||
}
|
||||
template = 'docker'
|
||||
} else if (lang == 'bunnative') {
|
||||
template = 'bunnative'
|
||||
} else {
|
||||
template = 'script'
|
||||
}
|
||||
let language = langToLanguage(lang)
|
||||
//
|
||||
initContent(language, script.kind, template)
|
||||
script.language = language
|
||||
}}
|
||||
disabled={lockedLanguage ||
|
||||
(enterpriseLangs.includes(lang) && !$enterpriseLicense)}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
color={isPicked ? 'blue' : 'light'}
|
||||
btnClasses={isPicked
|
||||
? '!border-2 !bg-blue-50/75 dark:!bg-frost-900/75'
|
||||
: 'm-[1px]'}
|
||||
on:click={() => onScriptLanguageTrigger(lang)}
|
||||
disabled={lockedLanguage ||
|
||||
(enterpriseLangs.includes(lang) && !$enterpriseLicense)}
|
||||
>
|
||||
<LanguageIcon {lang} />
|
||||
<span class="ml-2 py-2 truncate">{label}</span>
|
||||
{#if lang === 'ansible' || lang === 'nu'}
|
||||
<span class="text-tertiary !text-xs"> BETA </span>
|
||||
{/if}
|
||||
</Button>
|
||||
</TriggerableByAI>
|
||||
<LanguageIcon {lang} />
|
||||
<span class="ml-2 py-2 truncate">{label}</span>
|
||||
{#if lang === 'ansible' || lang === 'nu'}
|
||||
<span class="text-tertiary !text-xs"> BETA </span>
|
||||
{/if}
|
||||
</Button>
|
||||
<svelte:fragment slot="text"
|
||||
>{label} is only available with an enterprise license</svelte:fragment
|
||||
>
|
||||
@@ -1538,30 +1438,27 @@
|
||||
customUi={customUi?.settingsPanel?.metadata?.editableSchemaForm}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value="triggers" class="h-full">
|
||||
<TabContent value="triggers">
|
||||
<TriggersEditor
|
||||
on:applyArgs={applyArgs}
|
||||
on:addPreprocessor={addPreprocessor}
|
||||
on:exitTriggers={() => {
|
||||
captureTable?.loadCaptures(true)
|
||||
}}
|
||||
currentPath={script.path}
|
||||
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
|
||||
{initialPath}
|
||||
{fakeInitialPath}
|
||||
schema={script.schema}
|
||||
noEditor={true}
|
||||
newItem={initialPath == ''}
|
||||
isFlow={false}
|
||||
{hasPreprocessor}
|
||||
currentPath={script.path}
|
||||
hash={script.parent_hash}
|
||||
newItem={initialPath == ''}
|
||||
canHavePreprocessor={script.language === 'bun' ||
|
||||
script.language === 'deno' ||
|
||||
script.language === 'python3'}
|
||||
args={hasPreprocessor && selectedInputTab !== 'preprocessor' ? {} : args}
|
||||
isDeployed={savedScript && !savedScript?.draft_only}
|
||||
schema={script.schema}
|
||||
hash={script.parent_hash}
|
||||
onDeployTrigger={handleDeployTrigger}
|
||||
{hasPreprocessor}
|
||||
/>
|
||||
|
||||
<!-- <ScriptSchedules {initialPath} schema={script.schema} schedule={scheduleStore} /> -->
|
||||
</TabContent>
|
||||
</div>
|
||||
@@ -1592,10 +1489,7 @@
|
||||
</div>
|
||||
|
||||
<div class="gap-4 flex">
|
||||
{#if triggersState.triggers?.some((t) => t.type === 'schedule')}
|
||||
{@const primarySchedule = triggersState.triggers.findIndex((t) => t.isPrimary)}
|
||||
{@const schedule = triggersState.triggers.findIndex((t) => t.type === 'schedule')}
|
||||
|
||||
{#if $primaryScheduleStore != undefined ? $primaryScheduleStore && $primaryScheduleStore?.enabled : $triggersCount?.primary_schedule}
|
||||
<Button
|
||||
btnClasses="hidden lg:inline-flex"
|
||||
startIcon={{ icon: Calendar }}
|
||||
@@ -1605,12 +1499,14 @@
|
||||
on:click={async () => {
|
||||
metadataOpen = true
|
||||
selectedTab = 'triggers'
|
||||
triggersState.selectedTriggerIndex = primarySchedule ?? schedule
|
||||
$selectedTriggerStore = 'schedules'
|
||||
}}
|
||||
>
|
||||
{triggersState.triggers[primarySchedule]?.draftConfig?.schedule ??
|
||||
triggersState.triggers[primarySchedule]?.lightConfig?.schedule ??
|
||||
''}
|
||||
{$primaryScheduleStore != undefined
|
||||
? $primaryScheduleStore
|
||||
? $primaryScheduleStore?.cron
|
||||
: ''
|
||||
: $triggersCount?.primary_schedule?.schedule}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if customUi?.topBar?.path != false}
|
||||
|
||||
@@ -147,9 +147,7 @@
|
||||
path,
|
||||
code,
|
||||
lang,
|
||||
selectedTab === 'preprocessor' || kind === 'preprocessor'
|
||||
? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args }
|
||||
: args,
|
||||
selectedTab === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
|
||||
tag
|
||||
)
|
||||
setFocusToLogs()
|
||||
@@ -173,20 +171,13 @@
|
||||
nlang ?? lang,
|
||||
code,
|
||||
nschema,
|
||||
selectedTab === 'preprocessor' || kind === 'preprocessor' ? 'preprocessor' : undefined
|
||||
selectedTab === 'preprocessor' ? 'preprocessor' : undefined
|
||||
)
|
||||
hasPreprocessor =
|
||||
(selectedTab === 'preprocessor' ? !result?.no_main_func : result?.has_preprocessor) ?? false
|
||||
|
||||
if (kind === 'preprocessor') {
|
||||
hasPreprocessor = false
|
||||
if (!hasPreprocessor && selectedTab === 'preprocessor') {
|
||||
selectedTab = 'main'
|
||||
} else {
|
||||
hasPreprocessor =
|
||||
(selectedTab === 'preprocessor' ? !result?.no_main_func : result?.has_preprocessor) ??
|
||||
false
|
||||
|
||||
if (!hasPreprocessor && selectedTab === 'preprocessor') {
|
||||
selectedTab = 'main'
|
||||
}
|
||||
}
|
||||
|
||||
validCode = true
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
{/if}
|
||||
|
||||
{#if disabled}
|
||||
<input type="text" value={scriptPath ?? initialPath ?? ''} disabled />
|
||||
<input type="text" value={scriptPath ?? ''} disabled />
|
||||
{:else}
|
||||
<Select
|
||||
value={items?.find((x) => x.value == initialPath)}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
export let eeOnly = false
|
||||
export let small: boolean = false
|
||||
export let wrapperClass: string = ''
|
||||
export let headerClass: string = ''
|
||||
|
||||
export let collapsable: boolean = false
|
||||
export let collapsed: boolean = true
|
||||
@@ -27,8 +26,7 @@
|
||||
class={twMerge(
|
||||
'font-semibold flex flex-row items-center gap-1',
|
||||
breakAll ? 'break-all' : '',
|
||||
small ? 'text-sm' : 'text-base',
|
||||
headerClass
|
||||
small ? 'text-sm' : 'text-base'
|
||||
)}
|
||||
>
|
||||
{#if collapsable}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
let cssClassesLoaded = $state(false)
|
||||
let tailwindClassesLoaded = $state(false)
|
||||
|
||||
import '@codingame/monaco-vscode-standalone-languages'
|
||||
// import '@codingame/monaco-vscode-standalone-languages'
|
||||
import '@codingame/monaco-vscode-standalone-json-language-features'
|
||||
import '@codingame/monaco-vscode-standalone-css-language-features'
|
||||
import '@codingame/monaco-vscode-standalone-typescript-language-features'
|
||||
@@ -54,6 +54,7 @@
|
||||
type IDisposable
|
||||
} from 'monaco-editor'
|
||||
|
||||
|
||||
import { allClasses } from './apps/editor/componentsPanel/cssUtils'
|
||||
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
|
||||
@@ -64,6 +65,7 @@
|
||||
import EditorTheme from './EditorTheme.svelte'
|
||||
import { vimMode } from '$lib/stores'
|
||||
import { initVim } from './monaco_keybindings'
|
||||
import { buildWorkerDefinition } from '$lib/monaco_workers/build_workers'
|
||||
import FakeMonacoPlaceHolder from './FakeMonacoPlaceHolder.svelte'
|
||||
// import { createConfiguredEditor } from 'vscode/monaco'
|
||||
// import type { IStandaloneCodeEditor } from 'vscode/vscode/vs/editor/standalone/browser/standaloneCodeEditor'
|
||||
@@ -79,7 +81,6 @@
|
||||
let placeholderVisible = $state(false)
|
||||
let mounted = $state(false)
|
||||
|
||||
let valueAfterDispose: string | undefined = undefined
|
||||
let {
|
||||
lang,
|
||||
code = $bindable(),
|
||||
@@ -130,10 +131,9 @@
|
||||
|
||||
const uri = `file:///${hash}.${langToExt(lang)}`
|
||||
|
||||
buildWorkerDefinition()
|
||||
|
||||
export function getCode(): string {
|
||||
if (valueAfterDispose != undefined) {
|
||||
return valueAfterDispose
|
||||
}
|
||||
return editor?.getValue() ?? ''
|
||||
}
|
||||
|
||||
@@ -410,7 +410,6 @@
|
||||
|
||||
editor.onDidBlurEditorText(() => {
|
||||
dispatch('blur')
|
||||
|
||||
code = getCode()
|
||||
})
|
||||
|
||||
@@ -540,7 +539,6 @@
|
||||
|
||||
onDestroy(() => {
|
||||
try {
|
||||
valueAfterDispose = getCode()
|
||||
vimDisposable?.dispose()
|
||||
model && model.dispose()
|
||||
editor && editor.dispose()
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
$: {
|
||||
if (format == 'email') {
|
||||
pattern = '^[\\w-+.]+@([\\w-]+\\.)+[\\w-]{2,63}$'
|
||||
pattern = '^[\\w-+.]+@([\\w-]+\\.)+[\\w-]{2,4}$'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
import type { AppViewerContext } from './apps/types'
|
||||
import { writable } from 'svelte/store'
|
||||
// import '@codingame/monaco-vscode-standalone-languages'
|
||||
|
||||
// import '@codingame/monaco-vscode-standalone-typescript-language-features'
|
||||
import '@codingame/monaco-vscode-standalone-typescript-language-features'
|
||||
|
||||
import { initializeVscode } from './vscode'
|
||||
import EditorTheme from './EditorTheme.svelte'
|
||||
import { buildWorkerDefinition } from '$lib/monaco_workers/build_workers'
|
||||
import FakeMonacoPlaceHolder from './FakeMonacoPlaceHolder.svelte'
|
||||
|
||||
export const conf = {
|
||||
@@ -386,6 +386,8 @@
|
||||
|
||||
const uri = `file:///${hash}.ts`
|
||||
|
||||
buildWorkerDefinition()
|
||||
|
||||
export function insertAtCursor(code: string): void {
|
||||
if (editor) {
|
||||
editor.trigger('keyboard', 'type', { text: code })
|
||||
@@ -399,11 +401,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let valueAfterDispose: string | undefined = undefined
|
||||
export function getCode(): string {
|
||||
if (valueAfterDispose != undefined) {
|
||||
return valueAfterDispose
|
||||
}
|
||||
return editor?.getValue() ?? ''
|
||||
}
|
||||
|
||||
@@ -626,7 +624,6 @@
|
||||
|
||||
onDestroy(() => {
|
||||
try {
|
||||
valueAfterDispose = getCode()
|
||||
jsLoader && clearTimeout(jsLoader)
|
||||
model && model.dispose()
|
||||
editor && editor.dispose()
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
export let lightMode: boolean = false
|
||||
export let eeOnly: boolean = false
|
||||
|
||||
export let size: 'sm' | 'xs' | '2xs' | '2sm' = 'sm'
|
||||
export let size: 'sm' | 'xs' | '2xs' = 'sm'
|
||||
|
||||
const dispatch = createEventDispatcher<{ change: boolean }>()
|
||||
const bothOptions = Boolean(options.left) && Boolean(options.right)
|
||||
@@ -40,7 +40,7 @@
|
||||
class={twMerge(
|
||||
'mr-2 font-medium duration-50 select-none',
|
||||
bothOptions || textDisabled ? (checked ? 'text-disabled' : 'text-primary') : 'text-primary',
|
||||
size === 'xs' || size === '2sm' ? 'text-xs' : size === '2xs' ? 'text-[0.5rem]' : 'text-sm',
|
||||
size === 'xs' ? 'text-xs' : size === '2xs' ? 'text-[0.5rem]' : 'text-sm',
|
||||
textClass
|
||||
)}
|
||||
style={textStyle}
|
||||
@@ -73,15 +73,13 @@
|
||||
color == 'red'
|
||||
? 'peer-checked:bg-red-600'
|
||||
: color == 'blue'
|
||||
? 'peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500'
|
||||
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-400',
|
||||
? 'peer-checked:bg-blue-600 dark:peer-checked:bg-blue-500'
|
||||
: 'peer-checked:bg-nord-950 dark:peer-checked:bg-nord-400',
|
||||
size === 'sm'
|
||||
? 'w-11 h-6 after:top-0.5 after:left-[2px] after:h-5 after:w-5'
|
||||
: size === '2sm'
|
||||
? 'w-9 h-5 after:top-0.5 after:left-[2px] after:h-4 after:w-4'
|
||||
: size === '2xs'
|
||||
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
|
||||
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3'
|
||||
: size === '2xs'
|
||||
? 'w-5 h-3 after:top-0.5 after:left-[2px] after:h-2 after:w-2'
|
||||
: 'w-7 h-4 after:top-0.5 after:left-[2px] after:h-3 after:w-3'
|
||||
)}
|
||||
></div>
|
||||
</div>
|
||||
@@ -90,7 +88,7 @@
|
||||
class={twMerge(
|
||||
'ml-2 font-medium duration-50 select-none',
|
||||
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
|
||||
size === 'xs' || size === '2sm' ? 'text-xs' : 'text-sm',
|
||||
size === 'xs' ? 'text-xs' : 'text-sm',
|
||||
textClass
|
||||
)}
|
||||
style={textStyle}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Markdown from 'svelte-exmarkdown'
|
||||
import { ExternalLink } from 'lucide-svelte'
|
||||
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
|
||||
export let documentationLink: string | undefined = undefined
|
||||
export let markdownTooltip: string | undefined = undefined
|
||||
const plugins = [gfmPlugin()]
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="shadow max-w-sm break-words py-2 px-3 rounded-md text-sm font-normal !text-gray-300 bg-gray-800 whitespace-normal text-left"
|
||||
>
|
||||
{#if markdownTooltip}
|
||||
<div class="prose-sm">
|
||||
<Markdown md={markdownTooltip} {plugins} />
|
||||
</div>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
|
||||
{#if documentationLink}
|
||||
<a href={documentationLink} target="_blank" class="text-blue-300 text-xs">
|
||||
<div class="flex flex-row gap-2 mt-4">
|
||||
See documentation
|
||||
<ExternalLink size="16" />
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,96 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { triggerablesByAI } from '$lib/stores'
|
||||
|
||||
let {
|
||||
id,
|
||||
description,
|
||||
onTrigger,
|
||||
children,
|
||||
disabled = false
|
||||
} = $props<{
|
||||
id: string
|
||||
description: string
|
||||
onTrigger: (value?: string) => void
|
||||
children?: () => any
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
// Track animation state
|
||||
let isAnimating = $state(false)
|
||||
|
||||
// Wrapper for onTrigger that adds animation
|
||||
function handleTrigger(value?: string) {
|
||||
if (disabled || !onTrigger) return
|
||||
|
||||
// Show animation
|
||||
isAnimating = true
|
||||
|
||||
// Call the actual onTrigger
|
||||
onTrigger(value)
|
||||
|
||||
// Reset animation state after animation completes
|
||||
setTimeout(() => {
|
||||
isAnimating = false
|
||||
}, 1200) // Animation duration
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (disabled) return
|
||||
triggerablesByAI.update((triggers) => {
|
||||
return { ...triggers, [id]: { description, onTrigger: handleTrigger } }
|
||||
})
|
||||
|
||||
return () => {
|
||||
triggerablesByAI.update((triggers) => {
|
||||
const newTriggers = { ...triggers }
|
||||
delete newTriggers[id]
|
||||
return newTriggers
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="ai-triggerable-wrapper">
|
||||
{#if isAnimating}
|
||||
<div class="ai-triggerable-animation"></div>
|
||||
{/if}
|
||||
<div class="ai-triggerable-content">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ai-triggerable-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ai-triggerable-content {
|
||||
/* This preserves original styling of children */
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.ai-triggerable-animation {
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background-color: rgba(66, 133, 244, 0.9);
|
||||
border-radius: 50%;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
animation: pulse 0.6s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: translateX(-50%) scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%) scale(2.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -367,26 +367,28 @@
|
||||
outerDivClass="text-secondary !bg-surface-disabled !border-0"
|
||||
disabled={!$enterpriseLicense}
|
||||
bind:selected={selectedPriorityTags}
|
||||
onchange={(e) => {
|
||||
if (e.type === 'add') {
|
||||
on:change={(e) => {
|
||||
if (e.detail.type === 'add') {
|
||||
if (nconfig.priority_tags) {
|
||||
if (e.option && typeof e.option !== 'object') {
|
||||
nconfig.priority_tags[e.option] = 100
|
||||
if (e.detail.option && typeof e.detail.option !== 'object') {
|
||||
nconfig.priority_tags[e.detail.option] = 100
|
||||
}
|
||||
}
|
||||
dirty = true
|
||||
} else if (e.type === 'remove') {
|
||||
} else if (e.detail.type === 'remove') {
|
||||
if (nconfig.priority_tags) {
|
||||
if (e.option && typeof e.option !== 'object') {
|
||||
delete nconfig.priority_tags[e.option]
|
||||
if (e.detail.option && typeof e.detail.option !== 'object') {
|
||||
delete nconfig.priority_tags[e.detail.option]
|
||||
}
|
||||
}
|
||||
dirty = true
|
||||
} else if (e.type === 'removeAll') {
|
||||
nconfig.priority_tags = new Map<string, number>()
|
||||
} else if (e.detail.type === 'removeAll') {
|
||||
nconfig.priority_tags = undefined
|
||||
dirty = true
|
||||
} else {
|
||||
console.error(`Priority tags multiselect - unknown event type: '${e.type}'`)
|
||||
console.error(
|
||||
`Priority tags multiselect - unknown event type: '${e.detail.type}'`
|
||||
)
|
||||
}
|
||||
}}
|
||||
options={nconfig?.worker_tags}
|
||||
|
||||
@@ -229,8 +229,7 @@
|
||||
css?.button?.class ?? '',
|
||||
isMenuItem ? 'flex items-center justify-start' : '',
|
||||
isMenuItem ? '!border-0' : '',
|
||||
'wm-button',
|
||||
`wm-button-${resolvedConfig.color}`
|
||||
'wm-button'
|
||||
)}
|
||||
variant={isMenuItem ? 'border' : 'contained'}
|
||||
style={css?.button?.style}
|
||||
@@ -238,8 +237,7 @@
|
||||
css?.container?.class ?? '',
|
||||
resolvedConfig.fillContainer ? 'w-full h-full' : '',
|
||||
isMenuItem ? 'w-full' : '',
|
||||
'wm-button-container',
|
||||
`wm-button-container-${resolvedConfig.color}`
|
||||
'wm-button-container'
|
||||
)}
|
||||
wrapperStyle={css?.container?.style}
|
||||
disabled={resolvedConfig.disabled}
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import InitializeComponent from '../helpers/InitializeComponent.svelte'
|
||||
import { Alert } from '$lib/components/common'
|
||||
import AlignWrapper from '../helpers/AlignWrapper.svelte'
|
||||
import { appendClass } from '../../editor/componentsPanel/cssUtils'
|
||||
|
||||
export let id: string
|
||||
export let configuration: RichConfigurations
|
||||
@@ -64,13 +63,13 @@
|
||||
tooltip={resolvedConfig.tooltip}
|
||||
size={resolvedConfig.size}
|
||||
collapsible={resolvedConfig.collapsible}
|
||||
bgClass={appendClass(css?.background?.class, 'wm-alert-card-background')}
|
||||
bgClass={css?.background?.class}
|
||||
bgStyle={css?.background?.style}
|
||||
iconClass={appendClass(css?.icon?.class, 'wm-alert-card-icon')}
|
||||
iconClass={css?.icon?.class}
|
||||
iconStyle={css?.icon?.style}
|
||||
titleClass={appendClass(css?.title?.class, 'wm-alert-card-title')}
|
||||
titleClass={css?.title?.class}
|
||||
titleStyle={css?.title?.style}
|
||||
descriptionClass={appendClass(css?.description?.class, 'wm-alert-card-description')}
|
||||
descriptionClass={css?.description?.class}
|
||||
descriptionStyle={css?.description?.style}
|
||||
isCollapsed={resolvedConfig.initiallyCollapsed}
|
||||
>
|
||||
|
||||
@@ -157,35 +157,34 @@
|
||||
options={Array.isArray(items) ? items : []}
|
||||
placeholder={resolvedConfig.placeholder}
|
||||
allowUserOptions={resolvedConfig.create}
|
||||
onchange={(event) => {
|
||||
if (event?.type === 'removeAll') {
|
||||
on:change={(event) => {
|
||||
if (event?.detail?.type === 'removeAll') {
|
||||
outputs?.result.set([])
|
||||
} else {
|
||||
outputs?.result.set([...(value ?? [])])
|
||||
}
|
||||
}}
|
||||
onopen={() => {
|
||||
on:open={() => {
|
||||
$selectedComponent = [id]
|
||||
open = true
|
||||
}}
|
||||
onclose={() => {
|
||||
on:close={() => {
|
||||
open = false
|
||||
}}
|
||||
let:option
|
||||
>
|
||||
{#snippet children({ option })}
|
||||
<!-- needed because portal doesn't work for mouseup event en mobile -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="w-full"
|
||||
on:mouseup|stopPropagation
|
||||
on:pointerdown|stopPropagation={(e) => {
|
||||
let newe = new MouseEvent('mouseup')
|
||||
e.target?.['parentElement']?.dispatchEvent(newe)
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</div>
|
||||
{/snippet}
|
||||
<!-- needed because portal doesn't work for mouseup event en mobile -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="w-full"
|
||||
on:mouseup|stopPropagation
|
||||
on:pointerdown|stopPropagation={(e) => {
|
||||
let newe = new MouseEvent('mouseup')
|
||||
e.target?.['parentElement']?.dispatchEvent(newe)
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</div>
|
||||
</MultiSelect>
|
||||
<Portal name="app-multiselect">
|
||||
<div use:floatingContent class="z5000" hidden={!open}>
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
>
|
||||
<div
|
||||
style={css?.popup?.style}
|
||||
class={twMerge('mx-24 mt-8 bg-surface wm-modal rounded-lg relative', css?.popup?.class)}
|
||||
class={twMerge('mx-24 mt-8 bg-surface rounded-lg relative', css?.popup?.class)}
|
||||
use:clickOutside={{
|
||||
capture: false,
|
||||
stopPropagation: false,
|
||||
@@ -209,7 +209,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={twMerge('wm-modal-container h-full', 'overflow-y-auto', css?.container?.class)}
|
||||
class={twMerge('wm-modal h-full', 'overflow-y-auto')}
|
||||
on:pointerdown={(e) => {
|
||||
e?.stopPropagation()
|
||||
if (!$connectingInput.opened) {
|
||||
|
||||
@@ -3410,8 +3410,7 @@ See date-fns format for more information. By default, it is 'dd.MM.yyyy HH:mm'
|
||||
customCss: {
|
||||
button: { class: '', style: '' },
|
||||
buttonContainer: { class: '', style: '' },
|
||||
popup: { class: '', style: '' },
|
||||
container: { class: '', style: '' }
|
||||
popup: { class: '', style: '' }
|
||||
},
|
||||
initialData: {
|
||||
horizontalAlignment: 'center',
|
||||
|
||||
@@ -20,38 +20,20 @@
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
interface CustomCSSEntry {
|
||||
type?: CustomCSSType
|
||||
type: CustomCSSType
|
||||
name: string
|
||||
icon: any
|
||||
ids?: { id: string; forceStyle: boolean; forceClass: boolean }[]
|
||||
description?: string
|
||||
order?: number
|
||||
ids: { id: string; forceStyle: boolean; forceClass: boolean }[]
|
||||
}
|
||||
|
||||
const { app } = getContext<AppViewerContext>('AppViewerContext')
|
||||
|
||||
const descriptions = {
|
||||
buttoncomponent:
|
||||
'The button component also has additional color specific classes to allow customizing classes by color. wm-button-wrapper-blue, wm-button-container-blue, ...'
|
||||
}
|
||||
const entries: CustomCSSEntry[] = [
|
||||
{
|
||||
name: 'Dark Mode',
|
||||
icon: LayoutDashboardIcon,
|
||||
description:
|
||||
'When in dark mode, the entire document has the .dark class applied to it. You can apply selective styling by using the .dark class: e.g. .dark .my-element { color: white; }',
|
||||
order: 3
|
||||
},
|
||||
{
|
||||
type: 'app',
|
||||
name: 'App',
|
||||
icon: LayoutDashboardIcon,
|
||||
ids: ['viewer', 'grid', 'component'].map((id) => ({
|
||||
id,
|
||||
forceStyle: true,
|
||||
forceClass: true
|
||||
})),
|
||||
order: 2
|
||||
ids: ['viewer', 'grid', 'component'].map((id) => ({ id, forceStyle: true, forceClass: true }))
|
||||
},
|
||||
{
|
||||
type: 'quillcomponent',
|
||||
@@ -69,12 +51,11 @@
|
||||
id,
|
||||
forceStyle: v?.style != undefined,
|
||||
forceClass: v?.['class'] != undefined
|
||||
})),
|
||||
description: descriptions[type as keyof typeof descriptions]
|
||||
}))
|
||||
}))
|
||||
]
|
||||
|
||||
entries.sort((a, b) => (b.order ?? 0) - (a.order ?? 0) + a.name.localeCompare(b.name))
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
let search = ''
|
||||
</script>
|
||||
@@ -85,15 +66,15 @@
|
||||
<div class="h-[calc(100%-50px)] overflow-auto relative">
|
||||
{#each search != '' ? entries.filter((x) => x.name
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase())) : entries as { type, name, icon, ids, description } (name + type)}
|
||||
{#if description || (ids && ids.length > 0)}
|
||||
.includes(search.toLowerCase())) : entries as { type, name, icon, ids } (name + type)}
|
||||
{#if ids.length > 0}
|
||||
<ListItem
|
||||
title={name}
|
||||
prefix={TITLE_PREFIX}
|
||||
on:open={(e) => {
|
||||
if ($app.css != undefined) {
|
||||
if (type && e.detail && $app.css[type] == undefined) {
|
||||
$app.css[type] = Object.fromEntries((ids ?? []).map(({ id }) => [id, {}]))
|
||||
if (e.detail && $app.css[type] == undefined) {
|
||||
$app.css[type] = Object.fromEntries(ids.map(({ id }) => [id, {}]))
|
||||
}
|
||||
}
|
||||
}}
|
||||
@@ -104,120 +85,115 @@
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
{#if description}
|
||||
<div class="py-2 text-xs text-gray-500">{description}</div>
|
||||
{/if}
|
||||
{#if type}
|
||||
<div class="py-2">
|
||||
{#each customisationByComponent.filter( (c) => c.components.includes(type) ) as customisation (customisation.components.join('-'))}
|
||||
{#if customisation.link}
|
||||
<a
|
||||
href={customisation.link}
|
||||
target="_blank"
|
||||
class="text-frost-500 dark:text-frost-300 font-semibold text-xs"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
See documentation
|
||||
<ExternalLink size="16" />
|
||||
</div>
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<Tabs selected="selectors">
|
||||
{#if customisation.selectors.length > 0}
|
||||
<Tab value="selectors" size="xs">
|
||||
Selectors ({customisation.selectors.length})
|
||||
</Tab>
|
||||
{/if}
|
||||
{#if customisation.variables.length > 0}
|
||||
<Tab value="variables" size="xs">
|
||||
<div class="flex flex-row gap-2 justify-center-center items-center">
|
||||
Variables ({customisation.variables.length})
|
||||
</div>
|
||||
</Tab>
|
||||
{/if}
|
||||
<div slot="content" class="h-full">
|
||||
<TabContent value="selectors" class="h-full mt-2 ">
|
||||
<DataTable size="sm">
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Selector</Cell>
|
||||
<Cell head>Comment</Cell>
|
||||
<Cell head last />
|
||||
</tr>
|
||||
</Head>
|
||||
{#each customisation.selectors as { selector, comment }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<Badge color="gray">{selector}</Badge>
|
||||
</Cell>
|
||||
<Cell>
|
||||
{#if comment}
|
||||
<div class="max-w-24 whitespace-pre-wrap">{comment}</div>
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('insertSelector', `${selector} {}`)
|
||||
}}
|
||||
>
|
||||
<ArrowUpSquare size={16} />
|
||||
</Button>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</DataTable>
|
||||
</TabContent>
|
||||
<TabContent value="variables" class="h-full mt-2">
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Variable</Cell>
|
||||
<Cell head>Default value</Cell>
|
||||
<Cell head>Comment</Cell>
|
||||
<Cell head last />
|
||||
</tr>
|
||||
</Head>
|
||||
{#each customisation.variables as { variable, value, comment }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<Badge color="gray">{variable}</Badge>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Badge color="gray">{value}</Badge>
|
||||
</Cell>
|
||||
<Cell>
|
||||
{#if comment}
|
||||
<div class="w-80 whitespace-pre-wrap">{comment}</div>
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell sticky>
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch(
|
||||
'insertSelector',
|
||||
`${customisation.root} { ${variable}: ${value};}`
|
||||
)
|
||||
}}
|
||||
wrapperClasses="px-2 py-3.5 bg-surface"
|
||||
>
|
||||
<ArrowUpSquare size={16} />
|
||||
</Button>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</DataTable>
|
||||
</TabContent>
|
||||
<div class="py-2">
|
||||
{#each customisationByComponent.filter( (c) => c.components.includes(type) ) as customisation (customisation.components.join('-'))}
|
||||
{#if customisation.link}
|
||||
<a
|
||||
href={customisation.link}
|
||||
target="_blank"
|
||||
class="text-frost-500 dark:text-frost-300 font-semibold text-xs"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
See documentation
|
||||
<ExternalLink size="16" />
|
||||
</div>
|
||||
</Tabs>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<Tabs selected="selectors">
|
||||
{#if customisation.selectors.length > 0}
|
||||
<Tab value="selectors" size="xs">
|
||||
Selectors ({customisation.selectors.length})
|
||||
</Tab>
|
||||
{/if}
|
||||
{#if customisation.variables.length > 0}
|
||||
<Tab value="variables" size="xs">
|
||||
<div class="flex flex-row gap-2 justify-center-center items-center">
|
||||
Variables ({customisation.variables.length})
|
||||
</div>
|
||||
</Tab>
|
||||
{/if}
|
||||
<div slot="content" class="h-full">
|
||||
<TabContent value="selectors" class="h-full mt-2 ">
|
||||
<DataTable size="sm">
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Selector</Cell>
|
||||
<Cell head>Comment</Cell>
|
||||
<Cell head last />
|
||||
</tr>
|
||||
</Head>
|
||||
{#each customisation.selectors as { selector, comment }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<Badge color="gray">{selector}</Badge>
|
||||
</Cell>
|
||||
<Cell>
|
||||
{#if comment}
|
||||
<div class="max-w-24 whitespace-pre-wrap">{comment}</div>
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('insertSelector', `${selector} {}`)
|
||||
}}
|
||||
>
|
||||
<ArrowUpSquare size={16} />
|
||||
</Button>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</DataTable>
|
||||
</TabContent>
|
||||
<TabContent value="variables" class="h-full mt-2">
|
||||
<DataTable>
|
||||
<Head>
|
||||
<tr>
|
||||
<Cell head first>Variable</Cell>
|
||||
<Cell head>Default value</Cell>
|
||||
<Cell head>Comment</Cell>
|
||||
<Cell head last />
|
||||
</tr>
|
||||
</Head>
|
||||
{#each customisation.variables as { variable, value, comment }}
|
||||
<Row>
|
||||
<Cell first>
|
||||
<Badge color="gray">{variable}</Badge>
|
||||
</Cell>
|
||||
<Cell>
|
||||
<Badge color="gray">{value}</Badge>
|
||||
</Cell>
|
||||
<Cell>
|
||||
{#if comment}
|
||||
<div class="w-80 whitespace-pre-wrap">{comment}</div>
|
||||
{/if}
|
||||
</Cell>
|
||||
<Cell sticky>
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch(
|
||||
'insertSelector',
|
||||
`${customisation.root} { ${variable}: ${value};}`
|
||||
)
|
||||
}}
|
||||
wrapperClasses="px-2 py-3.5 bg-surface"
|
||||
>
|
||||
<ArrowUpSquare size={16} />
|
||||
</Button>
|
||||
</Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
</DataTable>
|
||||
</TabContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
{/each}
|
||||
</div>
|
||||
</ListItem>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
@@ -185,11 +185,7 @@ export const customisationByComponent: Customisation[] = [
|
||||
components: ['modalcomponent'],
|
||||
selectors: [
|
||||
{ selector: '.wm-modal', comment: 'main modal element', customCssKey: 'popup' },
|
||||
{
|
||||
selector: '.wm-modal-container',
|
||||
comment: 'container for modal',
|
||||
customCssKey: 'container'
|
||||
},
|
||||
{ selector: '.wm-modal-button', comment: 'button to open modal', customCssKey: 'button' },
|
||||
{
|
||||
selector: '.wm-modal-button-container',
|
||||
comment: 'container for button to open modal',
|
||||
@@ -830,26 +826,6 @@ export const customisationByComponent: Customisation[] = [
|
||||
selector: 'wm-alert-card-container',
|
||||
comment: 'Alert container',
|
||||
customCssKey: 'container'
|
||||
},
|
||||
{
|
||||
selector: 'wm-alert-card-background',
|
||||
comment: 'Alert background',
|
||||
customCssKey: 'background'
|
||||
},
|
||||
{
|
||||
selector: 'wm-alert-card-icon',
|
||||
comment: 'Alert icon',
|
||||
customCssKey: 'icon'
|
||||
},
|
||||
{
|
||||
selector: 'wm-alert-card-title',
|
||||
comment: 'Alert title',
|
||||
customCssKey: 'title'
|
||||
},
|
||||
{
|
||||
selector: 'wm-alert-card-description',
|
||||
comment: 'Alert description',
|
||||
customCssKey: 'description'
|
||||
}
|
||||
],
|
||||
variables: []
|
||||
@@ -884,9 +860,3 @@ export function hasStyleValue(obj: ComponentCssProperty | undefined) {
|
||||
|
||||
return obj.style !== ''
|
||||
}
|
||||
|
||||
export function appendClass(className: string | undefined, customCssKey: string) {
|
||||
if (!className) return customCssKey
|
||||
|
||||
return `${className} ${customCssKey}`
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '$lib/components/common'
|
||||
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
|
||||
|
||||
interface Props {
|
||||
url?: string
|
||||
disabled?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
let { url = '', disabled = false, label = '' }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex justify-start w-full">
|
||||
<Badge color="gray" class="rounded-r-none h-[27px]">{label}</Badge>
|
||||
|
||||
<ClipboardPanel
|
||||
content={url}
|
||||
class="rounded-l-none bg-surface border-none outline outline-2 outline-surface-secondary outline-offset-[-2px]"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
@@ -72,10 +72,6 @@
|
||||
element?.focus({})
|
||||
}
|
||||
|
||||
export function click() {
|
||||
element?.click()
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const dispatchIfMounted = createDispatcherIfMounted(dispatch)
|
||||
// Order of classes: border, border modifier, bg, bg modifier, text, text modifier, everything else
|
||||
@@ -202,7 +198,6 @@
|
||||
this={startIcon.icon}
|
||||
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
{...startIcon.props}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -251,7 +246,6 @@
|
||||
this={startIcon.icon}
|
||||
class={twMerge(startIcon?.classes, iconOnlyPadding[size])}
|
||||
size={lucideIconSize}
|
||||
{...startIcon.props}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -294,13 +288,12 @@
|
||||
<div
|
||||
class={twMerge(
|
||||
buttonClass,
|
||||
'rounded-md m-0 p-0 center-center h-full',
|
||||
'rounded-md m-0 p-0 !w-10 center-center h-full',
|
||||
variant === 'border' ? 'border-0 border-r border-y ' : 'border-0',
|
||||
'rounded-r-md !rounded-l-none',
|
||||
size === 'xs2' ? '!w-8' : '!w-10'
|
||||
'rounded-r-md !rounded-l-none'
|
||||
)}
|
||||
>
|
||||
<ChevronDown size={lucideIconSize} />
|
||||
<ChevronDown class="w-5 h-5" />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
</Dropdown>
|
||||
|
||||
@@ -10,7 +10,6 @@ export namespace ButtonType {
|
||||
icon?: any | undefined
|
||||
classes?: string
|
||||
faIcon?: any | undefined
|
||||
props?: any
|
||||
}
|
||||
|
||||
export const FontSizeClasses: Record<ButtonType.Size, string> = {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { fade } from 'svelte/transition'
|
||||
import Button from '../button/Button.svelte'
|
||||
import { AlertTriangle, CornerDownLeft, Loader2, RefreshCcw } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type Props = {
|
||||
title: string
|
||||
@@ -13,7 +12,6 @@
|
||||
loading?: boolean
|
||||
open?: boolean
|
||||
type?: 'danger' | 'reload'
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -22,8 +20,7 @@
|
||||
keyListen = true,
|
||||
loading = false,
|
||||
open = false,
|
||||
type: _type,
|
||||
showIcon = true
|
||||
type: _type
|
||||
}: Props = $props()
|
||||
const type = $derived(_type ?? 'danger')
|
||||
|
||||
@@ -95,14 +92,12 @@
|
||||
)}
|
||||
>
|
||||
<div class="flex">
|
||||
{#if showIcon}
|
||||
<div
|
||||
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
|
||||
>
|
||||
<Icon class={theme[type].classes.icon} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class={twMerge('ml-0 text-left flex-1 ', showIcon ? 'ml-4' : '')}>
|
||||
<div
|
||||
class={`flex h-12 w-12 items-center justify-center rounded-full ${theme[type].classes.iconWrapper}`}
|
||||
>
|
||||
<Icon class={theme[type].classes.icon} />
|
||||
</div>
|
||||
<div class="ml-4 text-left flex-1">
|
||||
<h3 class="text-lg font-medium text-primary">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
-178
@@ -1,178 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ConfirmationModal from './ConfirmationModal.svelte'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import type { Trigger } from '$lib/components/triggers/utils'
|
||||
import DataTable from '$lib/components/table/DataTable.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import TriggerLabel from '$lib/components/triggers/TriggerLabel.svelte'
|
||||
import { triggerIconMap } from '$lib/components/triggers/utils'
|
||||
import { Star } from 'lucide-svelte'
|
||||
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
|
||||
import { userStore } from '$lib/stores'
|
||||
|
||||
interface Props {
|
||||
open?: boolean
|
||||
draftTriggers?: Trigger[]
|
||||
isFlow?: boolean
|
||||
}
|
||||
|
||||
let { open = $bindable(false), draftTriggers = [], isFlow = false }: Props = $props()
|
||||
|
||||
let selectedTriggers: Trigger[] = $state(draftTriggers)
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
canceled: void
|
||||
confirmed: { selectedTriggers: Trigger[] }
|
||||
}>()
|
||||
|
||||
function toggleTrigger(trigger: Trigger, selected: 'discard' | 'deploy') {
|
||||
if (selected === 'discard') {
|
||||
if (trigger.isDraft) {
|
||||
selectedTriggers = selectedTriggers.filter((t) => !t.isDraft || t.id !== trigger.id)
|
||||
} else {
|
||||
selectedTriggers = selectedTriggers.filter(
|
||||
(t) => t.isDraft || t.type !== trigger.type || t.path !== trigger.path
|
||||
)
|
||||
}
|
||||
} else if (!isSelected(selectedTriggers, trigger)) {
|
||||
selectedTriggers = [...selectedTriggers, trigger]
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(triggers: Trigger[], trigger: Trigger): boolean {
|
||||
if (trigger.isDraft) {
|
||||
return triggers.some((t) => t.id === trigger.id)
|
||||
} else {
|
||||
return triggers.some((t) => t.path === trigger.path && t.type === trigger.type)
|
||||
}
|
||||
}
|
||||
|
||||
function checkSavePermissions(trigger: Trigger) {
|
||||
// Creating http trigger is forbidden for non-admin users
|
||||
const adminOnly =
|
||||
trigger.type === 'http' &&
|
||||
!$userStore?.is_admin &&
|
||||
!$userStore?.is_super_admin &&
|
||||
trigger.isDraft
|
||||
|
||||
const invalidConfig = !trigger.draftConfig?.canSave
|
||||
|
||||
return invalidConfig ? 'invalid-config' : adminOnly ? 'admin-only' : 'deploy'
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
open &&
|
||||
(selectedTriggers = [...draftTriggers].filter((t) => checkSavePermissions(t) === 'deploy'))
|
||||
})
|
||||
</script>
|
||||
|
||||
<ConfirmationModal
|
||||
{open}
|
||||
title="Draft triggers detected !"
|
||||
confirmationText={isFlow ? 'Deploy Flow' : 'Deploy Script'}
|
||||
type="reload"
|
||||
showIcon={false}
|
||||
on:canceled={() => dispatch('canceled')}
|
||||
on:confirmed={() => dispatch('confirmed', { selectedTriggers })}
|
||||
>
|
||||
<div class="flex flex-col w-full gap-8 pb-4">
|
||||
<div class="text-secondary text-sm">
|
||||
{`${isFlow ? 'Your flow' : 'Your script'} has draft triggers. Select which draft triggers to deploy with the ${isFlow ? 'flow' : 'script'}. Undeployed
|
||||
draft triggers will be permanently deleted.`}
|
||||
</div>
|
||||
|
||||
<div class={draftTriggers.length > 5 ? 'h-[300px]' : ''}>
|
||||
<DataTable size="sm" tableFixed={true}>
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-700 text-secondary dark:text-gray-300 text-xs">
|
||||
<th class="text-left py-2 px-4">Triggers to deploy</th>
|
||||
<th class="w-32 text-center py-2 px-1 justify-center"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each draftTriggers as trigger}
|
||||
{@const SvelteComponent = triggerIconMap[trigger.type]}
|
||||
{@const permission = checkSavePermissions(trigger)}
|
||||
{@const isSelectedTrigger = isSelected(selectedTriggers, trigger)}
|
||||
<tr
|
||||
class={twMerge(
|
||||
'transition-colors h-12 border-t border-gray-200 dark:border-gray-700 whitespace-nowrap',
|
||||
permission === 'deploy' ? 'hover:bg-surface-hover ' : ''
|
||||
)}
|
||||
>
|
||||
<td class={twMerge('text-center py-1 px-4')}>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="relative flex justify-center items-center">
|
||||
<SvelteComponent
|
||||
size={14}
|
||||
class={isSelectedTrigger
|
||||
? 'text-blue-500'
|
||||
: 'text-gray-500 dark:text-gray-400'}
|
||||
/>
|
||||
{#if trigger.isPrimary}
|
||||
<Star size={8} class="absolute -mt-3 ml-3 text-blue-400" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex grow min-w-0 items-center text-left">
|
||||
<TriggerLabel {trigger} discard={!isSelectedTrigger} />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td class="text-left py-1">
|
||||
{#if permission === 'deploy'}
|
||||
<div class="flex justify-start">
|
||||
<ToggleButtonGroup
|
||||
let:item
|
||||
class="w-fit h-fit"
|
||||
selected={isSelectedTrigger ? 'deploy' : 'discard'}
|
||||
on:selected={(e) => toggleTrigger(trigger, e.detail)}
|
||||
>
|
||||
<ToggleButton
|
||||
label={!trigger.isDraft && trigger.draftConfig ? 'Reset' : 'Discard'}
|
||||
value={'discard'}
|
||||
{item}
|
||||
small
|
||||
class="data-[state=on]:text-white data-[state=on]:bg-red-400 w-[54px] justify-center"
|
||||
/>
|
||||
<ToggleButton
|
||||
label={!trigger.isDraft && trigger.draftConfig ? 'Update' : 'Deploy'}
|
||||
value={'deploy'}
|
||||
{item}
|
||||
small
|
||||
class="data-[state=on]:bg-marine-400 data-[state=on]:text-white data-[state=on]:dark:bg-marine-50 data-[state=on]:dark:text-primary-inverse w-[54px] justify-center"
|
||||
/>
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{:else if permission === 'admin-only'}
|
||||
<div
|
||||
class="text-xs font-semibold px-1.5 py-1.5 bg-red-400 text-white rounded whitespace-nowrap w-[114px] text-center"
|
||||
title="Only admins can deploy http triggers"
|
||||
>
|
||||
Admin only
|
||||
</div>
|
||||
{:else if permission === 'invalid-config'}
|
||||
<div
|
||||
class="text-xs font-semibold px-1.5 py-1.5 bg-red-400 text-white rounded whitespace-nowrap w-[114px] text-center"
|
||||
title="Invalid config"
|
||||
>
|
||||
Invalid config
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
|
||||
{#if draftTriggers.length === 0}
|
||||
<tr>
|
||||
<td colspan="3" class="text-center py-6 text-gray-500 dark:text-gray-400 text-sm">
|
||||
No draft triggers found
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmationModal>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user