Merge remote-tracking branch 'origin/main' into feat/frontend/save-flow-builder-layout

# Conflicts:
#	frontend/src/lib/components/FlowBuilder.svelte
This commit is contained in:
Guilhem
2025-06-23 15:03:56 +01:00
106 changed files with 2328 additions and 1837 deletions
+12
View File
@@ -9,7 +9,14 @@ jobs:
runs-on: ubicloud
container: node:18
steps:
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
- uses: actions/checkout@v4
with:
token: ${{ steps.app.outputs.token }}
- run: git config --system --add safe.directory /__w/windmill/windmill
- name: Change versions
run: ./.github/change-versions.sh "$(cat version.txt)"
@@ -21,3 +28,8 @@ jobs:
cd backend
cargo generate-lockfile
- uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_user_name: windmill-internal-app[bot]
commit_user_email: windmill-internal-app[bot]@users.noreply.github.com
env:
GITHUB_TOKEN: ${{ steps.app.outputs.token }}
@@ -0,0 +1,60 @@
name: Check Organization Membership
on:
workflow_call:
inputs:
commenter:
required: true
type: string
description: 'The username to check for organization membership'
organization:
required: false
type: string
default: 'windmill-labs'
description: 'The organization to check membership for'
trusted_bot:
required: false
type: string
default: 'windmill-internal-app[bot]'
description: 'The trusted bot username to allow'
secrets:
access_token:
required: true
description: 'The access token to use for org membership check'
outputs:
is_member:
description: 'Whether the user is an organization member or trusted bot'
value: ${{ jobs.check-membership.outputs.is_member }}
jobs:
check-membership:
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.access_token }}
COMMENTER: ${{ inputs.commenter }}
ORG: ${{ inputs.organization }}
TRUSTED_BOT: ${{ inputs.trusted_bot }}
run: |
# 1. Allow the trusted bot straight away
if [[ "$COMMENTER" == "$TRUSTED_BOT" ]]; then
echo "is_member=true" >> $GITHUB_OUTPUT
exit 0
fi
# 2. Otherwise fall back to the org-membership check
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
+20 -32
View File
@@ -11,45 +11,40 @@ on:
types: [submitted]
jobs:
check-membership:
determine-commenter:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai') && !contains(github.event.comment.user.login, '[bot]')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai') && !contains(github.event.comment.user.login, '[bot]')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '/ai') && !contains(github.event.review.user.login, '[bot]')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '/ai') && !contains(github.event.issue.user.login, '[bot]'))
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/ai')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/ai')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '/ai')) ||
(github.event_name == 'issues' && contains(github.event.issue.body, '/ai'))
runs-on: ubicloud-standard-2
outputs:
is_member: ${{ steps.check-membership.outputs.is_member }}
commenter: ${{ steps.determine-commenter.outputs.commenter }}
steps:
- name: Check organization membership
id: check-membership
env:
ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }}
- name: Determine commenter
id: determine-commenter
run: |
ORG="windmill-labs"
if [[ "${{ github.event_name }}" == "issue_comment" || "${{ github.event_name }}" == "pull_request_review_comment" ]]; then
# Work out who wrote the comment / review
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
echo "commenter=$COMMENTER" >> $GITHUB_OUTPUT
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-membership:
needs: determine-commenter
uses: ./.github/workflows/check-org-membership.yml
with:
commenter: ${{ needs.determine-commenter.outputs.commenter }}
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
claude-code-action:
needs: check-membership
needs: [determine-commenter, check-membership]
if: |
needs.check-membership.outputs.is_member == 'true'
runs-on: ubicloud-standard-8
@@ -64,13 +59,6 @@ jobs:
with:
fetch-depth: 1
# Cache rust dependencies
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
workspaces: "./backend -> target"
# Cache node dependencies
- uses: actions/cache@v3
with:
path: ~/.npm
+21 -23
View File
@@ -4,38 +4,36 @@ on:
jobs:
check-membership:
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && github.event.comment.user.type != 'Bot' }}
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') }}
uses: ./.github/workflows/check-org-membership.yml
with:
commenter: ${{ github.event.comment.user.login }}
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
generate-token:
needs: check-membership
if: ${{ needs.check-membership.outputs.is_member == 'true' }}
runs-on: ubicloud-standard-2
outputs:
is_member: ${{ steps.check-membership.outputs.is_member }}
app_token: ${{ steps.app.outputs.token }}
steps:
- name: Check organization membership
id: check-membership
env:
ORG_ACCESS_TOKEN: ${{ secrets.ORG_ACCESS_TOKEN }}
COMMENTER: ${{ github.event.comment.user.login }}
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
- name: Generate an installation token
id: app
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
owner: windmill-labs
trigger-docs:
needs: check-membership
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/docs') && needs.check-membership.outputs.is_member == 'true' }}
needs: [generate-token, check-membership]
if: ${{ needs.check-membership.outputs.is_member == 'true' }}
uses: windmill-labs/windmilldocs/.github/workflows/create-docs.yml@main
with:
pr_number: ${{ github.event.issue.number }}
repo: ${{ github.event.repository.name }}
comment_text: ${{ github.event.comment.body }}
secrets:
DOCS_TOKEN: ${{ secrets.DOCS_TOKEN }}
DOCS_TOKEN: ${{ needs.generate-token.outputs.app_token }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }}
merge_success_emoji:
if: github.event.pull_request.merged == true
if: github.event.action == 'closed'
uses: ./.github/workflows/shareable-discord-notification.yml
with:
PR_STATUS: "merged"
+27 -2
View File
@@ -9,11 +9,19 @@ jobs:
runs-on: ubicloud-standard-2
steps:
- name: Generate an installation token
id: app
uses: actions/create-github-app-token@v2
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
owner: windmill-labs
- name: Checkout on helm repository
uses: actions/checkout@v3
with:
repository: windmill-labs/windmill-helm-charts
token: ${{ secrets.HELM_CHART_TOKEN }}
token: ${{ steps.app.outputs.token }}
- name: Get version
id: get_version
@@ -49,6 +57,23 @@ jobs:
APP_VERSION=${APP_VERSION%/}
sed -i "s/appVersion: .*/appVersion: $APP_VERSION/" ./charts/windmill/Chart.yaml
- name: Close existing bump-helm PRs
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
# List open PR numbers whose title starts with the prefix
prs=$(gh pr list \
--state open \
--search '"helm: bump version to" in:title' \
--json number \
-q '.[].number')
for pr in $prs; do
echo "Closing outdated bump PR #$pr"
gh pr close "$pr" \
--comment "Closed automatically – superseded by a newer Helm-chart bump PR."
done
- name: Commit and push
run: |
git add .
@@ -57,7 +82,7 @@ jobs:
- name: Create PR
env:
GH_TOKEN: ${{ secrets.HELM_CHART_TOKEN }}
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
gh pr create \
--title "helm: bump version to ${{ env.VERSION }}" \
+22
View File
@@ -0,0 +1,22 @@
name: Auto Comment on PR Ready for Review
on:
pull_request:
types: [opened, ready_for_review]
jobs:
add-review-comment:
if: github.event.pull_request.draft == false
runs-on: ubicloud-standard-2
steps:
- name: Add review comment
uses: actions/github-script@v7
with:
github-token: ${{ secrets.PUBLIC_REPO_TOKEN }}
script: |
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: '/ai review this PR'
});
+31
View File
@@ -1,5 +1,36 @@
# Changelog
## [1.500.2](https://github.com/windmill-labs/windmill/compare/v1.500.1...v1.500.2) (2025-06-20)
### Bug Fixes
* consistency of root job propagation fixing cases where runFlow in scripts would fail ([9c2f6a7](https://github.com/windmill-labs/windmill/commit/9c2f6a757fb168c7305c991c9fdbf78acd856a1c))
## [1.500.1](https://github.com/windmill-labs/windmill/compare/v1.500.0...v1.500.1) (2025-06-20)
### Bug Fixes
* git repository resource picker effect loop ([#6017](https://github.com/windmill-labs/windmill/issues/6017)) ([1b1bee5](https://github.com/windmill-labs/windmill/commit/1b1bee5b53d78e4407b684b567d0fddd2b5283f3))
## [1.500.0](https://github.com/windmill-labs/windmill/compare/v1.499.0...v1.500.0) (2025-06-20)
### Features
* add typescript client context to ai chat system prompt ([#6004](https://github.com/windmill-labs/windmill/issues/6004)) ([3e82282](https://github.com/windmill-labs/windmill/commit/3e822823519d1d5c22e422e4bd1ad4d37b6428b6))
* blacklist remote agent worker token ([#5985](https://github.com/windmill-labs/windmill/issues/5985)) ([86eb907](https://github.com/windmill-labs/windmill/commit/86eb9074cc94f309f17ea72e9cecd0d502ffd2be))
* **frontend:** run steps from graph ([#5915](https://github.com/windmill-labs/windmill/issues/5915)) ([67e6bce](https://github.com/windmill-labs/windmill/commit/67e6bce9b2eba1653450921afab3eabbd41fc715))
### Bug Fixes
* ai button in inline script editor to open AI chat in flow builder ([#5989](https://github.com/windmill-labs/windmill/issues/5989)) ([4ae5928](https://github.com/windmill-labs/windmill/commit/4ae5928788831196672e212b32ca410afab640e0))
* improve piptar upload - sequential uploads via background task queue ([#5994](https://github.com/windmill-labs/windmill/issues/5994)) ([c4adaee](https://github.com/windmill-labs/windmill/commit/c4adaeeabd287ca1c4f3522bcd8bcea30b00fe6d))
* new MultiSelect component ([#5979](https://github.com/windmill-labs/windmill/issues/5979)) ([fa8d1b4](https://github.com/windmill-labs/windmill/commit/fa8d1b47db19e15fe854e01f9987c8f97cb45b44))
* replace worker tags to listen multiselect ([#5997](https://github.com/windmill-labs/windmill/issues/5997)) ([e4255e6](https://github.com/windmill-labs/windmill/commit/e4255e6276565c4a45b1f45a5d627bcfb5369270))
## [1.499.0](https://github.com/windmill-labs/windmill/compare/v1.498.0...v1.499.0) (2025-06-18)
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n id As \"id!\",\n flow_status->'restarted_from'->'flow_job_id' AS \"restarted_from: Json<Uuid>\"\n FROM v2_as_queue\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $1), $1) = id AND workspace_id = $2",
"query": "SELECT\n id As \"id!\",\n flow_status->'restarted_from'->'flow_job_id' AS \"restarted_from: Json<Uuid>\"\n FROM v2_job_status\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $1), $1) = id",
"describe": {
"columns": [
{
@@ -16,14 +16,13 @@
],
"parameters": {
"Left": [
"Uuid",
"Text"
"Uuid"
]
},
"nullable": [
true,
false,
null
]
},
"hash": "3c0b2a840102b12864c5d721b8e0142602ab37f3e1a95d39b3c7cbd7ff34d0b2"
"hash": "019100d178129340a7c35d60ab61f983c8a9cb810db4369554bf26c6b0d6003d"
}
@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token, expires_at, blacklisted_at, blacklisted_by \n FROM agent_token_blacklist \n ORDER BY blacklisted_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamp"
},
{
"ordinal": 2,
"name": "blacklisted_at",
"type_info": "Timestamp"
},
{
"ordinal": 3,
"name": "blacklisted_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "1c5d3556fc8436ddd294f39c5431e1f501a821d6143c5d8aece20814237a6b86"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM agent_token_blacklist WHERE token = $1 AND expires_at > $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Timestamp"
]
},
"nullable": [
null
]
},
"hash": "2bf99d540365c228e1776ee5d2ba01ebe289183526afab19c1390bbf5082f019"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM agent_token_blacklist WHERE token = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "54fee31b61d62598c89cf7d0729079ac1721fe7bd1844f339236379211defc78"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM agent_token_blacklist WHERE expires_at <= now() RETURNING token",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "995b194da28092d5aa053df936e7a9ee4b80cf3ade038a032c57ecff8fa3c6cf"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT leaf_jobs->$1::text AS \"leaf_jobs: Json<Box<RawValue>>\", parent_job\n FROM v2_as_queue\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $2), $2) = id AND workspace_id = $3",
"query": "SELECT flow_leaf_jobs->$1::text AS \"leaf_jobs: Json<Box<RawValue>>\", v2_job.parent_job\n FROM v2_job_status\n LEFT JOIN v2_job ON v2_job.id = v2_job_status.id AND v2_job.workspace_id = $3\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $2), $2) = v2_job_status.id",
"describe": {
"columns": [
{
@@ -26,5 +26,5 @@
true
]
},
"hash": "cf12a70e7b75ae471a0944de34502384be156cf25129f9c52bda34b240cf469a"
"hash": "b46a0fbebdc8e5e9852a06444b0aeaa4eaf67959e68b69eb2f0896ebe9244691"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,\n created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,\n script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,\n flow_innermost_root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,\n cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $21, $22, $23, $24, $25, $26,\n CASE WHEN $14::VARCHAR IS NOT NULL THEN 'schedule'::job_trigger_kind END,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id) \n values ($1, $32, $33, $34, $35, $36, $37, $2) \n ON CONFLICT (job_id) DO UPDATE SET email = $32, username = $33, is_admin = $34, is_operator = $35, folders = $36, groups = $37, workspace_id = $2\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 THEN now() END, $30, $31)",
"query": "WITH inserted_job AS (\n INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,\n created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,\n script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,\n flow_innermost_root_job, root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,\n cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,\n $19, $20, $38, $21, $22, $23, $24, $25, $26,\n CASE WHEN $14::VARCHAR IS NOT NULL THEN 'schedule'::job_trigger_kind END,\n ($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)\n ),\n inserted_runtime AS (\n INSERT INTO v2_job_runtime (id, ping) VALUES ($1, null)\n ),\n inserted_job_perms AS (\n INSERT INTO job_perms (job_id, email, username, is_admin, is_operator, folders, groups, workspace_id) \n values ($1, $32, $33, $34, $35, $36, $37, $2) \n ON CONFLICT (job_id) DO UPDATE SET email = $32, username = $33, is_admin = $34, is_operator = $35, folders = $36, groups = $37, workspace_id = $2\n )\n INSERT INTO v2_job_queue\n (workspace_id, id, running, scheduled_for, started_at, tag, priority)\n VALUES ($2, $1, $28, COALESCE($29, now()), CASE WHEN $27 THEN now() END, $30, $31)",
"describe": {
"columns": [],
"parameters": {
@@ -97,10 +97,11 @@
"Bool",
"Bool",
"JsonbArray",
"TextArray"
"TextArray",
"Uuid"
]
},
"nullable": []
},
"hash": "cccdcb7fe7968eadfc04d8957a8e98b2f2d92a6d7f687a9dd5a70edb3d5a63e6"
"hash": "b7c3a66c3831eb5d145ff00807badae57bef81be051f150df754fd1444d7356d"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO agent_token_blacklist (token, expires_at, blacklisted_by) \n VALUES ($1, $2, $3) \n ON CONFLICT (token) DO UPDATE SET \n expires_at = EXCLUDED.expires_at,\n blacklisted_at = NOW(),\n blacklisted_by = EXCLUDED.blacklisted_by",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Timestamp",
"Varchar"
]
},
"nullable": []
},
"hash": "c9c040ec228a8fe4fda08439420141bee63339d4e3d5e2d68aabb12009f691c6"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token, expires_at, blacklisted_at, blacklisted_by \n FROM agent_token_blacklist \n WHERE expires_at > $1 \n ORDER BY blacklisted_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "expires_at",
"type_info": "Timestamp"
},
{
"ordinal": 2,
"name": "blacklisted_at",
"type_info": "Timestamp"
},
{
"ordinal": 3,
"name": "blacklisted_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Timestamp"
]
},
"nullable": [
false,
false,
false,
false
]
},
"hash": "d56722c25877222af9affd5da5bb83b28fa8cbb528a2cfc90684cb10a69e4375"
}
+53 -44
View File
@@ -4905,12 +4905,12 @@ dependencies = [
[[package]]
name = "errno"
version = "0.3.12"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -6435,7 +6435,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.2",
"tower-service",
"webpki-roots 1.0.0",
"webpki-roots 1.0.1",
]
[[package]]
@@ -7432,9 +7432,9 @@ dependencies = [
[[package]]
name = "lz4_flex"
version = "0.11.4"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c592ad9fbc1b7838633b3ae55ce69b17d01150c72fcef229fbb819d39ee51ee"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
dependencies = [
"twox-hash 2.1.1",
]
@@ -9822,9 +9822,9 @@ dependencies = [
[[package]]
name = "quinn-udp"
version = "0.5.12"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842"
checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970"
dependencies = [
"cfg_aliases 0.2.1",
"libc",
@@ -10275,7 +10275,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots 1.0.0",
"webpki-roots 1.0.1",
]
[[package]]
@@ -10410,7 +10410,7 @@ dependencies = [
[[package]]
name = "rmcp"
version = "0.1.5"
source = "git+https://github.com/modelcontextprotocol/rust-sdk#8a577659f01917289064c0a3c753392b44305ef4"
source = "git+https://github.com/modelcontextprotocol/rust-sdk#a62c6d1db8c8daf271aabaf49627de5f3a30613d"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -10439,7 +10439,7 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "0.1.5"
source = "git+https://github.com/modelcontextprotocol/rust-sdk#8a577659f01917289064c0a3c753392b44305ef4"
source = "git+https://github.com/modelcontextprotocol/rust-sdk#a62c6d1db8c8daf271aabaf49627de5f3a30613d"
dependencies = [
"proc-macro2",
"quote",
@@ -14377,14 +14377,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e"
dependencies = [
"webpki-root-certs 1.0.0",
"webpki-root-certs 1.0.1",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a83f7e1a9f8712695c03eabe9ed3fbca0feff0152f33f12593e5a6303cb1a4"
checksum = "86138b15b2b7d561bc4469e77027b8dd005a43dc502e9031d1f5afc8ce1f280e"
dependencies = [
"rustls-pki-types",
]
@@ -14395,14 +14395,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.0",
"webpki-roots 1.0.1",
]
[[package]]
name = "webpki-roots"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb"
checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502"
dependencies = [
"rustls-pki-types",
]
@@ -14564,7 +14564,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"axum",
@@ -14616,7 +14616,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"argon2",
@@ -14727,7 +14727,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -14742,7 +14742,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"chrono",
"serde",
@@ -14755,7 +14755,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"serde",
@@ -14769,7 +14769,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"async-stream",
@@ -14846,7 +14846,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"regex",
"serde",
@@ -14860,7 +14860,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"bytes",
@@ -14883,7 +14883,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14895,7 +14895,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14904,7 +14904,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -14916,7 +14916,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"serde_json",
@@ -14928,7 +14928,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"gosyn",
@@ -14940,7 +14940,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -14952,7 +14952,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"serde_json",
@@ -14964,7 +14964,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"nu-parser",
@@ -14975,7 +14975,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14986,7 +14986,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14997,7 +14997,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -15020,7 +15020,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15037,7 +15037,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15049,7 +15049,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15067,7 +15067,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"getrandom 0.2.16",
@@ -15091,7 +15091,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"serde_json",
@@ -15101,7 +15101,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -15134,7 +15134,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -15144,7 +15144,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.499.0"
version = "1.500.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -15473,6 +15473,15 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-sys"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
dependencies = [
"windows-targets 0.53.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.499.0"
version = "1.500.2"
authors.workspace = true
edition.workspace = true
@@ -32,7 +32,7 @@ members = [
]
[workspace.package]
version = "1.499.0"
version = "1.500.2"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
67e727c618cf673850a0887931c803241abfcfe8
835a91c7c31ea749759cd8af0922ad837049ea2a
@@ -0,0 +1,2 @@
-- Remove agent token blacklist table
DROP TABLE IF EXISTS agent_token_blacklist;
@@ -0,0 +1,14 @@
-- Add agent token blacklist table
CREATE TABLE agent_token_blacklist (
token VARCHAR PRIMARY KEY,
expires_at TIMESTAMP NOT NULL,
blacklisted_at TIMESTAMP NOT NULL DEFAULT NOW(),
blacklisted_by VARCHAR NOT NULL
);
-- Add index for efficient expiry cleanup
CREATE INDEX idx_agent_token_blacklist_expires_at ON agent_token_blacklist(expires_at);
-- Grant permissions to windmill users
GRANT ALL ON agent_token_blacklist TO windmill_user;
GRANT ALL ON agent_token_blacklist TO windmill_admin;
@@ -30,8 +30,6 @@ use windmill_common::{
worker::PythonAnnotations,
};
const DEF_MAIN: &str = "def main(";
fn replace_import(x: String) -> String {
SHORT_IMPORTS_MAP
.get(&x)
@@ -48,6 +46,9 @@ lazy_static! {
static ref RE: Regex = Regex::new(r"^\#\s?(\S+)\s*$").unwrap();
static ref PIN_RE: Regex = Regex::new(r"(?:\s*#\s*(pin|repin):\s*)(\S*)").unwrap();
static ref PKG_RE: Regex = Regex::new(r"^([^!=<>]+)(?:[!=<>]|$)").unwrap();
// Regex to properly match main function definition at line start,
// capturing both sync and async variants
static ref DEF_MAIN_RE: Regex = Regex::new(r"(?m)^(async\s+)?def\s+main\s*\(").unwrap();
}
fn process_import(module: Option<String>, path: &str, level: usize) -> Vec<NImport> {
@@ -143,7 +144,12 @@ struct ImportPin {
}
fn parse_code_for_imports(code: &str, path: &str) -> error::Result<Vec<NImport>> {
let mut code = code.split(DEF_MAIN).next().unwrap_or("").to_string();
// Use regex to safely find the main function definition
let mut code = DEF_MAIN_RE
.split(code)
.next()
.unwrap_or_default()
.to_string();
// remove main function decorator from end of file if it exists
if code
@@ -160,10 +166,17 @@ fn parse_code_for_imports(code: &str, path: &str) -> error::Result<Vec<NImport>>
+ "\n";
}
let ast = Suite::parse(&code, "main.py").map_err(|e| {
// Add a fake main function to ensure the parser can process the code correctly
// This is needed because we've split off the real main function above
let code_with_fake_main = format!("{}\n\ndef main(): pass", code);
let ast = Suite::parse(&code_with_fake_main, "main.py").map_err(|e| {
error::Error::ExecutionErr(format!("Error parsing code for imports: {}", e.to_string()))
})?;
// Note: We're still using the original code for finding pins,
// as the TextRange values from the parsed AST would be based on code_with_fake_main
// but we want to match against the original code
let find_pin = |range: TextRange, key: String| {
let hs = code
.chars()
@@ -378,7 +391,7 @@ async fn parse_python_imports_inner(
})
.join("\n")
.parse::<toml::Table>()
.map_err(to_anyhow)?;
.map_err(to_anyhow)?;
{
if let Some(v) = metadata.get("requires-python").and_then(|v| v.as_str()) {
@@ -232,7 +232,14 @@ fn parse_typ(id: &str) -> Typ {
x @ _ if x.starts_with("DynSelect_") => {
Typ::DynSelect(x.strip_prefix("DynSelect_").unwrap().to_string())
}
_ => Typ::Resource(id.to_string()),
_ => Typ::Resource(map_resource_name(id)),
}
}
fn map_resource_name(x: &str) -> String {
match x {
"S3Object" => "s3_object".to_string(),
_ => x.to_string(),
}
}
@@ -468,7 +475,7 @@ def main(test1: str,
Arg {
otyp: None,
name: "s3o".to_string(),
typ: Typ::Resource("S3Object".to_string()),
typ: Typ::Resource("s3_object".to_string()),
default: None,
has_default: false,
oidx: None
@@ -92,6 +92,12 @@ mod test {
fn test_snake_case() {
assert_eq!("s3", to_snake_case("S3"));
assert_eq!("s3", to_snake_case("s3"));
assert_eq!("s3_object", to_snake_case("S3Object"));
assert_eq!("s3_object", to_snake_case("S3object"));
assert_eq!("s3_object", to_snake_case("s3object"));
assert_eq!("abc", to_snake_case("ABC"));
assert_eq!("aa_bc", to_snake_case("AaBC"));
assert_eq!("a_b_c", to_snake_case("A_B_C"));
assert_eq!("s_3", to_snake_case("S_3"));
assert_eq!("type_name_here", to_snake_case("typeNameHere"));
}
+3
View File
@@ -1102,6 +1102,9 @@ Windmill Community Edition {GIT_VERSION}
_ = tokio::time::sleep(Duration::from_secs(12 * 60 * 60)) => {
tracing::info!("Reloading config after 12 hours");
initial_load(&conn, tx.clone(), worker_mode, server_mode, #[cfg(feature = "parquet")] disable_s3_store).await;
if let Err(e) = reload_license_key(&conn).await {
tracing::error!("Failed to reload license key on agent: {e:#}");
}
#[cfg(feature = "enterprise")]
ee_oss::verify_license_key().await;
}
+18
View File
@@ -840,6 +840,24 @@ pub async fn delete_expired_items(db: &DB) -> () {
tracing::error!("Error deleting audit log on CE: {:?}", e);
}
match sqlx::query_scalar!(
"DELETE FROM agent_token_blacklist WHERE expires_at <= now() RETURNING token",
)
.fetch_all(db)
.await
{
Ok(deleted_tokens) => {
if deleted_tokens.len() > 0 {
tracing::info!(
"deleted {} expired blacklisted agent tokens: {:?}",
deleted_tokens.len(),
deleted_tokens
);
}
}
Err(e) => tracing::error!("Error deleting expired blacklisted agent tokens: {:?}", e),
}
let job_retention_secs = *JOB_RETENTION_SECS.read().await;
if job_retention_secs > 0 {
match db.begin().await {
+93 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.499.0
version: 1.500.2
title: Windmill API
contact:
@@ -11240,6 +11240,98 @@ paths:
schema:
type: string
/agent_workers/blacklist_token:
post:
summary: blacklist agent token (requires super admin)
operationId: blacklistAgentToken
tags:
- agent_workers
requestBody:
description: token to blacklist
required: true
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: The agent token to blacklist
expires_at:
type: string
format: date-time
description: Optional expiration date for the blacklist entry
required:
- token
responses:
"200":
description: token blacklisted successfully
/agent_workers/remove_blacklist_token:
post:
summary: remove agent token from blacklist (requires super admin)
operationId: removeBlacklistAgentToken
tags:
- agent_workers
requestBody:
description: token to remove from blacklist
required: true
content:
application/json:
schema:
type: object
properties:
token:
type: string
description: The agent token to remove from blacklist
required:
- token
responses:
"200":
description: token removed from blacklist successfully
/agent_workers/list_blacklisted_tokens:
get:
summary: list blacklisted agent tokens (requires super admin)
operationId: listBlacklistedAgentTokens
tags:
- agent_workers
parameters:
- name: include_expired
in: query
description: Whether to include expired blacklisted tokens
schema:
type: boolean
default: false
responses:
"200":
description: list of blacklisted tokens
content:
application/json:
schema:
type: array
items:
type: object
properties:
token:
type: string
description: The blacklisted token (without prefix)
expires_at:
type: string
format: date-time
description: When the blacklist entry expires
blacklisted_at:
type: string
format: date-time
description: When the token was blacklisted
blacklisted_by:
type: string
description: Email of the user who blacklisted the token
required:
- token
- expires_at
- blacklisted_at
- blacklisted_by
/w/{workspace}/acls/get/{kind}/{path}:
get:
@@ -16,9 +16,6 @@ use crate::db::DB;
#[cfg(not(feature = "private"))]
use axum::Router;
#[cfg(not(feature = "private"))]
use serde::{Deserialize, Serialize};
#[cfg(not(feature = "private"))]
pub fn global_service() -> Router {
Router::new()
@@ -44,15 +41,6 @@ pub fn workspaced_service(
(router, vec![], Some(job_completed_tx))
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg(not(feature = "private"))]
pub struct AgentAuth {
pub worker_group: String,
pub suffix: Option<String>,
pub tags: Vec<String>,
pub exp: Option<usize>,
}
#[cfg(not(feature = "private"))]
pub struct AgentCache {}
+1 -1
View File
@@ -2011,7 +2011,7 @@ async fn upload_s3_file_from_app(
if !has_unnamed_policy {
return Err(Error::BadRequest(
"no policy found for unnamed s3 file uplooad".to_string(),
"no policy found for unnamed s3 file upload".to_string(),
));
}
+8 -8
View File
@@ -3564,7 +3564,7 @@ pub async fn run_flow_by_path_inner(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -3657,7 +3657,7 @@ pub async fn restart_flow(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -3749,7 +3749,7 @@ pub async fn run_script_by_path_inner(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -4417,7 +4417,7 @@ pub async fn run_wait_result_job_by_path_get(
None,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -4557,7 +4557,7 @@ pub async fn run_wait_result_script_by_path_internal(
None,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -4670,7 +4670,7 @@ pub async fn run_wait_result_script_by_hash(
None,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -4784,7 +4784,7 @@ pub async fn run_wait_result_flow_by_path_internal(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
@@ -5625,7 +5625,7 @@ pub async fn run_job_by_hash_inner(
scheduled_for,
None,
run_query.parent_job,
run_query.root_job.or(run_query.parent_job),
run_query.root_job,
run_query.job_id,
false,
false,
-1
View File
@@ -583,7 +583,6 @@ pub async fn transform_json_value<'c>(
job.schedule_path.clone(),
job.flow_step_id.clone(),
job.root_job.map(|x| x.to_string()),
None,
Some(job.scheduled_for.clone()),
)
.await;
+1 -2
View File
@@ -74,8 +74,7 @@ async fn list_contextual_variables(
Some("u/user/triggering_flow_path".to_string()),
Some("c".to_string()),
Some("017e0ad5-f499-73b6-5488-92a61c5196dd".to_string()),
Some("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c".to_string()),
Some(chrono::offset::Utc::now())
Some(chrono::offset::Utc::now()),
)
.await
.to_vec(),
-7
View File
@@ -180,7 +180,6 @@ pub async fn get_reserved_variables(
schedule_path: Option<String>,
step_id: Option<String>,
root_flow_id: Option<String>,
jwt_token: Option<String>,
scheduled_for: Option<chrono::DateTime<Utc>>,
) -> Vec<ContextualVariable> {
let state_path = {
@@ -334,12 +333,6 @@ pub async fn get_reserved_variables(
description: "Script or flow step execution unique path, useful for storing results in an external service".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_OIDC_JWT".to_string(),
value: jwt_token.unwrap_or_else(|| "".to_string()),
description: "OIDC JWT token (EE only)".to_string(),
is_custom: false,
},
ContextualVariable {
name: "WM_WORKER_GROUP".to_string(),
value: WORKER_GROUP.clone(),
+12 -9
View File
@@ -2721,10 +2721,9 @@ pub async fn get_result_by_id(
"SELECT
id As \"id!\",
flow_status->'restarted_from'->'flow_job_id' AS \"restarted_from: Json<Uuid>\"
FROM v2_as_queue
WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $1), $1) = id AND workspace_id = $2",
flow_id,
&w_id
FROM v2_job_status
WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $1), $1) = id",
flow_id
)
.fetch_optional(&db)
.await?;
@@ -2863,9 +2862,10 @@ pub async fn get_result_by_id_from_running_flow_inner(
node_id: &str,
) -> error::Result<JobResult> {
let flow_job_result = sqlx::query!(
"SELECT leaf_jobs->$1::text AS \"leaf_jobs: Json<Box<RawValue>>\", parent_job
FROM v2_as_queue
WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $2), $2) = id AND workspace_id = $3",
"SELECT flow_leaf_jobs->$1::text AS \"leaf_jobs: Json<Box<RawValue>>\", v2_job.parent_job
FROM v2_job_status
LEFT JOIN v2_job ON v2_job.id = v2_job_status.id AND v2_job.workspace_id = $3
WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $2), $2) = v2_job_status.id",
node_id,
flow_id,
w_id,
@@ -2873,11 +2873,13 @@ pub async fn get_result_by_id_from_running_flow_inner(
.fetch_optional(db)
.await?;
// tracing::error!("flow_job_result: {:?} {:?}", flow_job_result, flow_id);
let flow_job_result = windmill_common::utils::not_found_if_none(
flow_job_result,
"Root job of parent runnnig flow",
format!("parent: {}, id: {}", flow_id, node_id),
)?;
// tracing::error!("flow_job_result: {:?}, {:?}", flow_job_result.leaf_jobs, flow_job_result.parent_job);
let job_result = flow_job_result
.leaf_jobs
@@ -4230,10 +4232,10 @@ pub async fn push<'c, 'd>(
INSERT INTO v2_job (id, workspace_id, raw_code, raw_lock, raw_flow, tag, parent_job,
created_by, permissioned_as, runnable_id, runnable_path, args, kind, trigger,
script_lang, same_worker, pre_run_error, permissioned_as_email, visible_to_owner,
flow_innermost_root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,
flow_innermost_root_job, root_job, concurrent_limit, concurrency_time_window_s, timeout, flow_step_id,
cache_ttl, priority, trigger_kind, script_entrypoint_override, preprocessed)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19, $20, $21, $22, $23, $24, $25, $26,
$19, $20, $38, $21, $22, $23, $24, $25, $26,
CASE WHEN $14::VARCHAR IS NOT NULL THEN 'schedule'::job_trigger_kind END,
($12::JSONB)->>'_ENTRYPOINT_OVERRIDE', $27)
),
@@ -4289,6 +4291,7 @@ pub async fn push<'c, 'd>(
job_authed.is_operator,
folders.as_slice(),
job_authed.groups.as_slice(),
root_job.or(parent_job)
)
.execute(&mut *tx)
.warn_after_seconds(1)
@@ -1566,7 +1566,6 @@ pub async fn start_worker(
None,
None,
None,
None,
)
.await;
let context_envs = build_envs_map(context.to_vec()).await;
-1
View File
@@ -434,7 +434,6 @@ pub async fn get_reserved_variables(
job.schedule_path(),
job.flow_step_id.clone(),
job.flow_innermost_root_job.clone().map(|x| x.to_string()),
None,
Some(job.scheduled_for.clone()),
)
.await
+1 -3
View File
@@ -11,8 +11,7 @@ use crate::{
start_child_process, OccupancyMetrics,
},
handle_child::handle_child,
DENO_CACHE_DIR, DENO_PATH, DISABLE_NSJAIL, HOME_ENV, NPM_CONFIG_REGISTRY,
PATH_ENV, TZ_ENV,
DENO_CACHE_DIR, DENO_PATH, DISABLE_NSJAIL, HOME_ENV, NPM_CONFIG_REGISTRY, PATH_ENV, TZ_ENV,
};
use windmill_common::client::AuthedClient;
@@ -534,7 +533,6 @@ pub async fn start_worker(
None,
None,
None,
None,
)
.await;
let context_envs = build_envs_map(context.to_vec()).await;
@@ -2077,7 +2077,6 @@ pub async fn start_worker(
None,
None,
None,
None,
)
.await
.to_vec();
@@ -2197,7 +2196,6 @@ for line in sys.stdin:
None,
None,
None,
None,
)
.await;
+3 -1
View File
@@ -858,13 +858,14 @@ pub fn start_interactive_worker_shell(
.await;
}
_ => {
tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE)).await;
tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 10)).await;
}
}
}
Err(err) => {
tracing::error!(worker = %worker_name, hostname = %hostname, "Failed to pull jobs: {}", err);
tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 20)).await;
}
};
}
@@ -1861,6 +1862,7 @@ pub async fn run_worker(
}
Err(err) => {
tracing::error!(worker = %worker_name, hostname = %hostname, "Failed to pull jobs: {}", err);
tokio::time::sleep(Duration::from_millis(*SLEEP_QUEUE * 5)).await;
}
};
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.499.0";
export const VERSION = "v1.500.2";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -63,7 +63,7 @@ export {
// }
// });
export const VERSION = "1.499.0";
export const VERSION = "1.500.2";
const command = new Command()
.name("wmill")
+6 -6
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.499.0",
"version": "1.500.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.499.0",
"version": "1.500.2",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -77,7 +77,7 @@
"windmill-parser-wasm-java": "^1.478.1",
"windmill-parser-wasm-nu": "^1.474.1",
"windmill-parser-wasm-php": "^1.429.0",
"windmill-parser-wasm-py": "^1.477.1",
"windmill-parser-wasm-py": "^1.499.0",
"windmill-parser-wasm-regex": "^1.492.1",
"windmill-parser-wasm-rust": "^1.429.0",
"windmill-parser-wasm-ts": "^1.486.1",
@@ -12952,9 +12952,9 @@
"integrity": "sha512-SGJAtNpfdRZftkGboxWsm/yQDnJBJodwPQUbX2cWk/aoNook6ULesZwsYtBC9WN1VH6TIskLiVPohMmu6jtXmw=="
},
"node_modules/windmill-parser-wasm-py": {
"version": "1.477.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.477.1.tgz",
"integrity": "sha512-EY3mSMWpqFPzd7fsLg2/hSfQFU8HpW9nplFwm4JHHCDbcTpBzlvzjPJoHAAGO5kMzowAxjqi5ai/mXjeUWuiSg=="
"version": "1.499.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.499.0.tgz",
"integrity": "sha512-ur5SU+YTuYm1Hgo+SWohYRsmkVxIZPME7GzzRivPhkK5XBQcc7PsxlMjYkIvFJwufz+jX2t6p0aYNIdDXpMNbA=="
},
"node_modules/windmill-parser-wasm-regex": {
"version": "1.492.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.499.0",
"version": "1.500.2",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -144,7 +144,7 @@
"windmill-parser-wasm-java": "^1.478.1",
"windmill-parser-wasm-nu": "^1.474.1",
"windmill-parser-wasm-php": "^1.429.0",
"windmill-parser-wasm-py": "^1.477.1",
"windmill-parser-wasm-py": "^1.499.0",
"windmill-parser-wasm-regex": "^1.492.1",
"windmill-parser-wasm-rust": "^1.429.0",
"windmill-parser-wasm-ts": "^1.486.1",
+4 -3
View File
@@ -48,6 +48,7 @@
import type { FlowPropPickerConfig, PropPickerContext } from './prop_picker'
import type { PickableProperties } from './flows/previousResults'
import { Triggers } from './triggers/triggers.svelte'
import { TestSteps } from './flows/testSteps.svelte'
let flowCopilotContext: FlowCopilotContext = {
shouldUpdatePropertyType: writable<{
@@ -436,7 +437,7 @@
const moving = writable<{ id: string } | undefined>(undefined)
const history = initHistory(flowStore.val)
const testStepStore = writable<Record<string, any>>({})
const testSteps = new TestSteps()
const selectedIdStore = writable('settings-metadata')
const triggersCount = writable<TriggersCount | undefined>(undefined)
@@ -455,7 +456,7 @@
pathStore: writable(''),
flowStateStore,
flowStore,
testStepStore,
testSteps,
saveDraft: () => {},
initialPathStore: writable(''),
fakeInitialPath: '',
@@ -715,7 +716,7 @@
noEditor
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
$testStepStore['preprocessor'] = ev.detail.args ?? {}
testSteps.setStepArgs('preprocessor', ev.detail.args ?? {})
$selectedIdStore = 'preprocessor'
} else {
previewArgsStore.val = ev.detail.args ?? {}
+65 -97
View File
@@ -1,26 +1,4 @@
<script lang="ts">
import { SELECT_INPUT_DEFAULT_STYLE } from '$lib/defaults'
import type { Script } from '$lib/gen'
import { deepEqual } from 'fast-equals'
import SelectLegacy from './apps/svelte-select/lib/SelectLegacy.svelte'
import DarkModeObserver from './DarkModeObserver.svelte'
import ResultJobLoader from './ResultJobLoader.svelte'
import Tooltip from './Tooltip.svelte'
import { Loader2 } from 'lucide-svelte'
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
export let value: any = undefined
export let helperScript:
| { type: 'inline'; path?: string; lang: Script['language']; code: string }
| { type: 'hash'; hash: string }
| undefined = undefined
export let entrypoint: string
export let args: Record<string, any> = {}
export let name: string
let darkMode: boolean = false
let rawCode = JSON.stringify(value, null, 2)
<script lang="ts" module>
function validSelectObject(x): string | undefined {
if (typeof x != 'object') {
return JSON.stringify(x) + ' is not an object'
@@ -34,115 +12,105 @@
}
return
}
async function getItemsFromOptions(text: string) {
return new Promise((resolve, reject) => {
</script>
<script lang="ts">
import type { Script } from '$lib/gen'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { deepEqual } from 'fast-equals'
import ResultJobLoader from './ResultJobLoader.svelte'
import Select from './select/Select.svelte'
import Tooltip from './Tooltip.svelte'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import { readFieldsRecursively } from '$lib/utils'
interface Props {
value?: any
helperScript?:
| { type: 'inline'; path?: string; lang: Script['language']; code: string }
| { type: 'hash'; hash: string }
entrypoint: string
args?: Record<string, any>
name: string
}
let { value = $bindable(), helperScript, entrypoint, args: _args, name }: Props = $props()
let args = $state(structuredClone($state.snapshot(_args)))
$effect(() => {
readFieldsRecursively(_args, { excludeField: [name] })
untrack(() => !deepEqual(args, _args) && (args = $state.snapshot(_args)))
})
async function getItemsFromOptions() {
return new Promise<{ label: string; value: any }[]>((resolve, reject) => {
let cb = {
done(res) {
if (!res || !Array.isArray(res)) {
reject('Result was not an array')
return
}
if (res.length == 0) {
resolve([])
}
if (res.length == 0) resolve([])
if (res.every((x) => typeof x == 'string')) {
res = res.map((x) => ({
label: x,
value: x
}))
res = res.map((x) => ({ label: x, value: x }))
} else if (res.find((x) => validSelectObject(x) != undefined)) {
reject(validSelectObject(res.find((x) => validSelectObject(x) != undefined)))
} else {
if (text != undefined && text != '') {
res = res.filter((x) => x['label'].includes(text))
}
if (filterText != undefined && filterText != '')
res = res.filter((x) => x['label'].includes(filterText))
resolve(res)
}
},
cancel() {
reject()
},
error(err) {
reject(err)
}
cancel: () => reject(),
error: (err) => reject(err)
}
helperScript?.type == 'inline'
? resultJobLoader?.runPreview(
helperScript?.path ?? 'NO_PATH',
helperScript.code,
helperScript.lang,
{ ...args, text, _ENTRYPOINT_OVERRIDE: entrypoint },
{ ...args, filterText, _ENTRYPOINT_OVERRIDE: entrypoint },
undefined,
cb
)
: resultJobLoader?.runScriptByHash(
helperScript?.hash ?? 'NO_HASH',
{
...args,
text,
_ENTRYPOINT_OVERRIDE: entrypoint
},
{ ...args, filterText, _ENTRYPOINT_OVERRIDE: entrypoint },
cb
)
})
}
let lastArgs = structuredClone({ ...stateSnapshot(args), [name]: undefined })
$: (entrypoint || helperScript) && refreshOptions()
let _items = usePromise(getItemsFromOptions)
let items = $derived(_items.value)
$effect(() => {
;[args, name, entrypoint, helperScript, filterText]
untrack(() => _items.refresh())
})
$: args && changeArgs()
let timeout: NodeJS.Timeout | undefined = undefined
function changeArgs() {
timeout && clearTimeout(timeout)
timeout = setTimeout(() => {
let argsWithoutSelf = { ...stateSnapshot(args), [name]: undefined }
if (deepEqual(argsWithoutSelf, lastArgs)) {
return
}
refreshOptions()
lastArgs = structuredClone(argsWithoutSelf)
timeout = undefined
}, 1000)
}
function refreshOptions() {
error = undefined
renderCount += 1
}
let error: string | undefined = undefined
let resultJobLoader: ResultJobLoader
let renderCount = 0
let resultJobLoader: ResultJobLoader | undefined = $state()
let filterText: string = $state('')
let open: boolean = $state(false)
</script>
{#if helperScript}
<DarkModeObserver bind:darkMode />
<ResultJobLoader bind:this={resultJobLoader} />
<div class="w-full flex-col flex">
<div class="w-full">
{#key renderCount}
<SelectLegacy
on:error={(e) => {
error = e.detail.details
}}
on:change={(e) => {
value = e.detail.value
}}
{value}
computeOnClick={value == undefined}
loadOptions={getItemsFromOptions}
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
/>
{/key}
</div>
{#if error}
<div class="text-red-400 text-2xs">error: <Tooltip>{JSON.stringify(error)}</Tooltip></div>
<Select
bind:value
bind:open
{items}
bind:filterText
loading={!open && _items.status === 'loading'}
clearable
noItemsMsg={_items.status === 'loading' ? 'Loading...' : 'No items found'}
/>
{#if _items.error}
<div class="text-red-400 text-2xs">
error: <Tooltip>{JSON.stringify(_items.error)}</Tooltip>
</div>
{/if}
</div>
{:else}
@@ -153,7 +121,7 @@
{#await import('$lib/components/JsonEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default code={rawCode} bind:value />
<Module.default code={JSON.stringify(value, null, 2)} bind:value />
{/await}
</div>
{/if}
+23 -3
View File
@@ -78,6 +78,7 @@
} from './splitPanes/SplitPanesLayout.svelte'
import type { PanesLayout } from './splitPanes/types'
import { setTabStateContext, TabsState } from './common/tabs/tabsState.svelte'
import { TestSteps } from './flows/testSteps.svelte'
import { aiChatManager } from './copilot/chat/AIChatManager.svelte'
interface Props {
@@ -556,7 +557,7 @@
payloadData: undefined
})
const testStepStore = writable<Record<string, any>>({})
const testSteps = new TestSteps()
function select(selectedId: string) {
selectedIdStore.set(selectedId)
@@ -574,7 +575,7 @@
flowStateStore,
flowStore,
pathStore,
testStepStore,
testSteps,
saveDraft,
initialPathStore,
fakeInitialPath,
@@ -798,6 +799,9 @@
let flowPreviewButtons: FlowPreviewButtons | undefined = $state()
let forceTestTab: Record<string, boolean> = $state({})
let highlightArg: Record<string, string | undefined> = $state({})
run(() => {
initialPathStore.set(initialPath)
})
@@ -1052,7 +1056,7 @@
{newFlow}
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
$testStepStore['preprocessor'] = ev.detail.args ?? {}
testSteps.setStepArgs('preprocessor', ev.detail.args ?? {})
$selectedIdStore = 'preprocessor'
}
}}
@@ -1060,8 +1064,24 @@
previewArgsStore.val = JSON.parse(JSON.stringify(e.detail))
flowPreviewButtons?.openPreview(true)
}}
onTestUpTo={() => {
flowPreviewButtons?.testUpTo()
}}
{savedFlow}
onDeployTrigger={handleDeployTrigger}
onEditInput={(moduleId, key) => {
selectedIdStore.set(moduleId)
// Use new prop-based system
forceTestTab[moduleId] = true
highlightArg[moduleId] = key
// Reset the force flag after a short delay to allow re-triggering
setTimeout(() => {
forceTestTab[moduleId] = false
highlightArg[moduleId] = undefined
}, 500)
}}
{forceTestTab}
{highlightArg}
/>
{:else}
<CenteredPage>Loading...</CenteredPage>
@@ -379,7 +379,14 @@
id="flow-editor-test-flow-drawer"
shortCut={{ Icon: CornerDownLeft }}
>
Test flow
{#if previewMode == 'upTo'}
Test up to
<Badge baseClass="ml-1" color="indigo">
{$selectedId}
</Badge>
{:else}
Test flow
{/if}
</Button>
</div>
{/if}
@@ -1,8 +1,8 @@
<script lang="ts">
import { AgentWorkersService } from '$lib/gen'
import { AgentWorkersService, type ListBlacklistedAgentTokensResponse } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { Copy } from 'lucide-svelte'
import { Alert } from './common'
import { Copy, Trash2, RefreshCw } from 'lucide-svelte'
import { Alert, Button, Tab, Tabs } from './common'
import Section from './Section.svelte'
import TagsToListenTo from './TagsToListenTo.svelte'
import { enterpriseLicense, superadmin } from '$lib/stores'
@@ -15,6 +15,10 @@
let selectedTags: string[] = $state(!$enterpriseLicense ? ['agent_test'] : [])
let workerGroup: string = $state('agent')
let token: string = $state('')
let blacklistToken: string = $state('')
let selectedTab: 'create' | 'blacklist' = $state('create')
let blacklistedTokens: ListBlacklistedAgentTokensResponse | undefined = $state(undefined)
let isLoadingBlacklist: boolean = $state(false)
async function refreshToken(workerGroup: string, selectedTags: string[]) {
try {
@@ -32,109 +36,283 @@
}
}
async function loadBlacklistedTokens() {
isLoadingBlacklist = true
try {
blacklistedTokens = await AgentWorkersService.listBlacklistedAgentTokens({
includeExpired: true
})
} catch (error) {
sendUserToast('Error loading blacklisted tokens: ' + error.toString(), true)
blacklistedTokens = []
} finally {
isLoadingBlacklist = false
}
}
async function addToBlacklist() {
if (!blacklistToken.trim()) {
sendUserToast('Please enter a token to blacklist', true)
return
}
try {
await AgentWorkersService.blacklistAgentToken({
requestBody: {
token: blacklistToken
}
})
sendUserToast('Token successfully added to blacklist')
blacklistToken = ''
// Refresh the blacklist after adding a new token
await loadBlacklistedTokens()
} catch (error) {
sendUserToast('Error blacklisting token: ' + error.body, true)
}
}
async function removeFromBlacklist(tokenToRemove: string) {
try {
const response = await fetch('/api/agent_workers/remove_blacklist_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token: tokenToRemove })
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(errorText || 'Failed to remove token from blacklist')
}
sendUserToast('Token successfully removed from blacklist')
// Refresh the blacklist after removing a token
await loadBlacklistedTokens()
} catch (error) {
sendUserToast('Error removing token from blacklist: ' + error.toString(), true)
}
}
$effect(() => {
if (selectedTags.length > 0 && $superadmin) {
refreshToken(workerGroup, selectedTags)
}
})
$effect(() => {
if (selectedTab === 'blacklist' && $enterpriseLicense && $superadmin) {
loadBlacklistedTokens()
}
})
</script>
<div class="flex flex-col gap-y-4">
<Alert type="info" title="HTTP agent workers "
>Use HTTP agent workers only when the workers need to be deployed remotely OR with only HTTP
connectivity OR in untrusted environments. HTTP agent workers have more latency and less
capabilities than normal workers.</Alert
>
<Section
label="Worker group"
tooltip="This is only used to give a name prefix to the agent worker and to group workers in the workers page, no worker group config is passed to an agent worker."
>
<input class="max-w-md" type="text" bind:value={workerGroup} />
</Section>
<Section label="Tags to listen to" eeOnly>
{#if !$enterpriseLicense}
<div class="text-sm text-secondary mb-2 max-w-md">
Agent workers are only available in the enterprise edition. For evaluation purposes, you can
only use the tag `agent_test` tag and it is limited to 100 jobs.
</div>
{/if}
<TagsToListenTo disabled={!$enterpriseLicense} bind:worker_tags={selectedTags} {customTags} />
</Section>
<Tabs bind:selected={selectedTab}>
<Tab value="create">Create</Tab>
<Tab value="blacklist">Blacklist</Tab>
{#snippet content()}
<div class="flex flex-col gap-y-4 pt-2">
{#if selectedTab === 'create'}
<Alert type="info" title="HTTP agent workers "
>Use HTTP agent workers only when the workers need to be deployed remotely OR with only
HTTP connectivity OR in untrusted environments. HTTP agent workers have more latency and
less capabilities than normal workers.</Alert
>
<div class="flex flex-col gap-y-4 mt-4">
<Section
label="Worker group"
tooltip="This is only used to give a name prefix to the agent worker and to group workers in the workers page, no worker group config is passed to an agent worker."
>
<input class="max-w-md" type="text" bind:value={workerGroup} />
</Section>
<Section label="Tags to listen to" eeOnly>
{#if !$enterpriseLicense}
<div class="text-sm text-secondary mb-2 max-w-md">
Agent workers are only available in the enterprise edition. For evaluation purposes,
you can only use the tag `agent_test` tag and it is limited to 100 jobs.
</div>
{/if}
<TagsToListenTo
disabled={!$enterpriseLicense}
bind:worker_tags={selectedTags}
{customTags}
/>
</Section>
<Section label="Generated JWT token" primary>
{#if !$enterpriseLicense}
<div class="text-sm text-secondary mb-2 max-w-md">
Agent workers are only available in the enterprise edition. For evaluation purposes, you can
only use the tag `agent_test` tag and it is limited to 100 jobs.
</div>
{/if}
<div class="relative max-w-md group">
<!-- svelte-ignore event_directive_deprecated -->
<input
on:click|preventDefault|stopPropagation={() => {
if (token) {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
}
}}
placeholder="Select tags to generate a JWT token"
type="text"
disabled
value={token}
class="w-full pr-10 pl-3 py-2 text-sm text-gray-600 bg-gray-50 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-100 transition truncate"
/>
<Section label="Generated JWT token" primary>
{#if !$enterpriseLicense}
<div class="text-sm text-secondary mb-2 max-w-md">
Agent workers are only available in the enterprise edition. For evaluation purposes,
you can only use the tag `agent_test` tag and it is limited to 100 jobs.
</div>
{/if}
<div class="relative max-w-md group">
<input
onclick={(e) => {
e.preventDefault()
e.stopPropagation()
if (token) {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
}
}}
placeholder="Select tags to generate a JWT token"
type="text"
disabled
value={token}
class="w-full pr-10 pl-3 py-2 text-sm text-gray-600 bg-gray-50 border border-gray-300 rounded-lg cursor-pointer hover:bg-gray-100 transition truncatere"
/>
<!-- svelte-ignore event_directive_deprecated -->
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 group-hover:text-blue-600 hover:scale-105 transition"
aria-label="Copy token to clipboard"
on:click|preventDefault|stopPropagation={() => {
if (token) {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
}
}}
>
<Copy size={18} />
</button>
</div>
<button
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 group-hover:text-blue-600 hover:scale-105 transition"
aria-label="Copy token to clipboard"
onclick={(e) => {
e.preventDefault()
e.stopPropagation()
if (token) {
navigator.clipboard.writeText(token)
sendUserToast('Copied to clipboard')
}
}}
>
<Copy size={18} />
</button>
</div>
<div class="flex flex-col gap-2 text-sm mt-3 leading-relaxed">
Set the following environment variables:
<ul class="list-disc list-inside mt-1">
<li><code>MODE=agent</code></li>
<li><code>AGENT_TOKEN=&lt;token&gt;</code></li>
<li><code>BASE_INTERNAL_URL=&lt;base url&gt;</code></li>
</ul>
<p class="text-sm leading-relaxed">
to a worker to have it act as an HTTP agent worker.
<code>INIT_SCRIPT</code>, if needed, must be passed as an env variable.
</p>
<Alert type="warning" size="sm" title="Agent Worker Limitations">
Ensure at least one normal worker is running and listening to the tags
<code>flow</code> and <code>dependency</code>
(or <code>flow-&lt;workspace&gt;</code> and <code>dependency-&lt;workspace&gt;</code> if
using workspace-specific default tags), because agent workers
<strong>cannot run dependency jobs</strong>
nor execute the
<strong>flow state machine</strong>. They can, however, run subjobs within flows.
</Alert>
<CollapseLink text="Automate JWT token generation" small>
<div class="text-xs mt-2">
Use the following API endpoint with a superadmin bearer token:
<code class="block mt-1 mb-2">POST /api/agent_workers/create_agent_token</code>
<pre class=" p-2 rounded-lg text-xs overflow-auto">
<div class="flex flex-col gap-2 text-sm mt-3 leading-relaxed">
Set the following environment variables:
<ul class="list-disc list-inside mt-1">
<li><code>MODE=agent</code></li>
<li><code>AGENT_TOKEN=&lt;token&gt;</code></li>
<li><code>BASE_INTERNAL_URL=&lt;base url&gt;</code></li>
</ul>
<p class="text-sm leading-relaxed">
to a worker to have it act as an HTTP agent worker.
<code>INIT_SCRIPT</code>, if needed, must be passed as an env variable.
</p>
<Alert type="warning" size="sm" title="Agent Worker Limitations">
Ensure at least one normal worker is running and listening to the tags
<code>flow</code> and <code>dependency</code>
(or <code>flow-&lt;workspace&gt;</code> and
<code>dependency-&lt;workspace&gt;</code>
if using workspace-specific default tags), because agent workers
<strong>cannot run dependency jobs</strong>
nor execute the
<strong>flow state machine</strong>. They can, however, run subjobs within flows.
</Alert>
<CollapseLink text="Automate JWT token generation" small>
<div class="text-xs mt-2">
Use the following API endpoint with a superadmin bearer token:
<code class="block mt-1 mb-2">POST /api/agent_workers/create_agent_token</code>
<pre class=" p-2 rounded-lg text-xs overflow-auto">
<code
>{`
>{`
"worker_group": "agent",
"tags": ["tag1", "tag2"],
"exp": 1717334400
`}</code
>
</pre>
The JSON response will contain the generated JWT token.
>
</pre>
The JSON response will contain the generated JWT token.
</div>
</CollapseLink>
</div>
</Section>
</div>
</CollapseLink>
{:else if selectedTab === 'blacklist'}
<div class="flex flex-col gap-y-4 mt-4">
<Section label="Agent Token Blacklist" eeOnly>
{#if !$enterpriseLicense}
<div class="text-sm text-secondary mb-2 max-w-md">
Token blacklist management is only available in the enterprise edition.
</div>
{:else}
<div class="text-sm text-secondary mb-4 max-w-md">
Add tokens to the blacklist to prevent them from being used by agent workers.
Blacklisted tokens may take up to 5 minutes to be effective because of caching.
</div>
<div class="flex flex-col gap-3 w-full mb-6">
<div>
<label class="block text-sm font-medium mb-1" for="blacklistTokenInput"
>Token</label
>
<input
id="blacklistTokenInput"
class="w-full"
type="text"
bind:value={blacklistToken}
placeholder="jwt_agent_eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ3b3JrZXJfZ3JvdXAiOiJhZ2VudCIsInN1ZmZpeCI6bnVsbCwidGFncyI6WyJiYXNoIl0sImV4cCI6MTg0NDk1NDYxMX0.JQWb-_ERGaomukbl_cEPPmmCAEepTR79d9oIrKREscE"
/>
</div>
<div class="flex">
<Button color="red" on:click={addToBlacklist} disabled={!$superadmin}
>Blacklist</Button
>
</div>
{#if !$superadmin}
<div class="text-xs text-amber-600">
Only superadmins can manage the token blacklist.
</div>
{/if}
</div>
<!-- Blacklisted Tokens List -->
<div class="border-t pt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-medium">Blacklisted Tokens</h3>
<button
class="p-2 text-gray-500 hover:text-blue-600 hover:bg-gray-100 rounded-lg transition"
onclick={loadBlacklistedTokens}
disabled={isLoadingBlacklist}
title="Refresh blacklist"
>
<RefreshCw size={16} class={isLoadingBlacklist ? 'animate-spin' : ''} />
</button>
</div>
{#if isLoadingBlacklist}
<div class="text-center py-4 text-gray-500"> Loading blacklisted tokens... </div>
{:else if blacklistedTokens?.length === 0}
<div class="text-center py-4 text-gray-500">
No tokens are currently blacklisted.
</div>
{:else}
<div class="space-y-2">
{#each blacklistedTokens ?? [] as blacklistedToken}
<div
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border"
>
<div class="flex-1 min-w-0">
<div class="font-mono text-xs text-gray-700 pr-4 break-all">
{blacklistedToken.token}
</div>
{#if blacklistedToken.expires_at}
<div class="text-xs text-gray-500 mt-1">
Expires: {new Date(blacklistedToken.expires_at).toLocaleString()}
</div>
{/if}
</div>
{#if $superadmin}
<button
class="ml-3 p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition"
onclick={() => removeFromBlacklist(blacklistedToken.token)}
title="Remove from blacklist"
>
<Trash2 size={16} />
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/if}
</Section>
</div>
{/if}
</div>
</Section>
</div>
{/snippet}
</Tabs>
@@ -1,22 +1,13 @@
<script lang="ts">
import { run } from 'svelte/legacy'
import type { Schema } from '$lib/common'
import { ScriptService, type FlowModule, type Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getScriptByPath } from '$lib/scripts'
import { type FlowModule, type Job } from '$lib/gen'
import { CornerDownLeft, Loader2 } from 'lucide-svelte'
import { getContext } from 'svelte'
import Button from './common/button/Button.svelte'
import type { FlowEditorContext } from './flows/types'
import TestJobLoader from './TestJobLoader.svelte'
import ModulePreviewForm from './ModulePreviewForm.svelte'
import { evalValue } from './flows/utils'
import type { PickableProperties } from './flows/previousResults'
import ModuleTest from './ModuleTest.svelte'
import { getContext } from 'svelte'
import type { FlowEditorContext } from './flows/types'
interface Props {
mod: FlowModule
@@ -26,6 +17,7 @@
testIsLoading?: boolean
noEditor?: boolean
scriptProgress?: any
focusArg?: string
}
let {
@@ -35,90 +27,25 @@
testJob = $bindable(undefined),
testIsLoading = $bindable(false),
noEditor = false,
scriptProgress = $bindable(undefined)
scriptProgress = $bindable(undefined),
focusArg = undefined
}: Props = $props()
const { flowStore, flowStateStore, testStepStore, pathStore } =
getContext<FlowEditorContext>('FlowEditorContext')
// Test
let testJobLoader: TestJobLoader | undefined = $state()
let jobProgressReset: () => void = () => {}
let stepArgs: Record<string, any> | undefined = $state(
Object.fromEntries(
Object.keys(schema.properties ?? {}).map((k) => [
k,
evalValue(k, mod, $testStepStore, pickableProperties, false)
])
)
)
run(() => {
$testStepStore[mod.id] = stepArgs
})
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
let moduleTest: ModuleTest | undefined = $state()
export function runTestWithStepArgs() {
runTest(stepArgs)
}
export async function runTest(args: any) {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset()
const val = mod.value
// let jobId: string | undefined = undefined
if (val.type == 'rawscript') {
await testJobLoader?.runPreview(
val.path ?? ($pathStore ?? '') + '/' + mod.id,
val.content,
val.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore.val?.tag ?? val.tag
)
} else if (val.type == 'script') {
const script = val.hash
? await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash: val.hash })
: await getScriptByPath(val.path)
await testJobLoader?.runPreview(
val.path,
script.content,
script.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore.val?.tag ?? (val.tag_override ? val.tag_override : script.tag),
script.lock,
val.hash ?? script.hash
)
} else if (val.type == 'flow') {
await testJobLoader?.runFlowByPath(val.path, args)
} else {
throw Error('Not supported module type')
}
}
function jobDone() {
if (testJob && !testJob.canceled && testJob.type == 'CompletedJob' && `result` in testJob) {
if ($flowStateStore[mod.id]) {
$flowStateStore[mod.id].previewResult = testJob.result
$flowStateStore[mod.id].previewSuccess = testJob.success
$flowStateStore[mod.id].previewJobId = testJob.id
$flowStateStore[mod.id].previewWorkspaceId = testJob.workspace_id
$flowStateStore = $flowStateStore
}
}
testJob = undefined
moduleTest?.runTestWithStepArgs()
}
</script>
<TestJobLoader
toastError={noEditor}
on:done={() => jobDone()}
<ModuleTest
{mod}
{noEditor}
bind:testJob
bind:testIsLoading
bind:scriptProgress
bind:this={testJobLoader}
bind:isLoading={testIsLoading}
bind:job={testJob}
bind:this={moduleTest}
/>
<div class="p-4">
@@ -130,7 +57,7 @@
<div class="w-full justify-center flex">
{#if testIsLoading}
<Button size="sm" on:click={testJobLoader?.cancelJob} btnClasses="w-full" color="red">
<Button size="sm" on:click={moduleTest?.cancelJob} btnClasses="w-full" color="red">
<Loader2 size={16} class="animate-spin mr-1" />
Cancel
</Button>
@@ -139,7 +66,7 @@
color="dark"
btnClasses="truncate"
size="sm"
on:click={() => runTest(stepArgs)}
on:click={runTestWithStepArgs}
shortCut={{
Icon: CornerDownLeft
}}
@@ -149,5 +76,5 @@
{/if}
</div>
<ModulePreviewForm {pickableProperties} {mod} {schema} bind:args={stepArgs} />
<ModulePreviewForm {pickableProperties} {mod} {schema} {focusArg} />
</div>
@@ -5,77 +5,91 @@
import { RefreshCw } from 'lucide-svelte'
import ArgInput from './ArgInput.svelte'
import { Button } from './common'
import { getContext, untrack } from 'svelte'
import { getContext, onMount, untrack } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import { evalValue } from './flows/utils'
import type { FlowModule } from '$lib/gen'
import type { PickableProperties } from './flows/previousResults'
import type SimpleEditor from './SimpleEditor.svelte'
import { getResourceTypes } from './resourceTypesStore'
import { twMerge } from 'tailwind-merge'
interface Props {
schema: Schema | { properties?: Record<string, any>; required?: string[] }
args?: Record<string, any>
mod: FlowModule
pickableProperties: PickableProperties | undefined
isValid?: boolean
autofocus?: boolean
focusArg?: string
}
let {
schema,
args = $bindable({}),
mod,
pickableProperties,
isValid = $bindable(true),
autofocus = false
autofocus = false,
focusArg = undefined
}: Props = $props()
const { testStepStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { testSteps, flowStateStore, flowStore, previewArgs } =
getContext<FlowEditorContext>('FlowEditorContext')
let inputCheck: { [id: string]: boolean } = $state({})
$effect(() => {
isValid = allTrue(inputCheck) ?? false
})
$effect(() => {
if (args == undefined || typeof args !== 'object') {
args = {}
}
})
function removeExtraKey() {
const nargs = {}
Object.keys(args ?? {}).forEach((key) => {
if (keys.includes(key)) {
nargs[key] = args[key]
}
})
args = nargs
}
let keys: string[] = $state([])
$effect(() => {
let lkeys = Object.keys(schema?.properties ?? {})
if (schema?.properties && JSON.stringify(lkeys) != JSON.stringify(keys)) {
keys = lkeys
untrack(() => removeExtraKey())
untrack(() => testSteps?.removeExtraKey(mod.id, keys))
}
})
function plugIt(argName: string) {
args[argName] = structuredClone(
$state.snapshot(evalValue(argName, mod, testStepStore, pickableProperties, true))
testSteps?.setEvaluatedStepArg(
mod.id,
argName,
$state.snapshot(evalValue(argName, mod, pickableProperties, true))
)
try {
editor?.[argName]?.setCode(JSON.stringify(args[argName], null, 4))
} catch {
//ignore
}
}
let editor: Record<string, SimpleEditor | undefined> = $state({})
// Animation and highlighting for focusArg
let animateArg: string | undefined = $state(undefined)
$effect(() => {
if (focusArg) {
// Add a slight delay to ensure the form is rendered
setTimeout(() => {
const argElement = document.querySelector(`[data-arg="${focusArg}"]`)
if (argElement) {
// Add highlight animation
animateArg = focusArg
argElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Focus the input if it exists
const input = argElement.querySelector('input, textarea, select') as
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| null
if (input) {
input.focus()
}
// Remove highlight after animation
setTimeout(() => {
animateArg = undefined
}, 2000)
}
}, 200)
}
})
let resourceTypes: string[] | undefined = $state(undefined)
async function loadResourceTypes() {
@@ -83,21 +97,34 @@
}
loadResourceTypes()
let args = $state(<Record<string, any>>{})
onMount(() => {
testSteps?.updateStepArgs(mod.id, $flowStateStore, flowStore?.val, previewArgs?.val)
args = testSteps?.getStepArgs(mod.id) ?? { value: {} }
})
</script>
<div class="w-full pt-2">
<div class="w-full pt-2" data-popover>
{#if keys.length > 0}
{#each keys as argName, i (argName)}
{#if Object.keys(schema.properties ?? {}).includes(argName)}
<div class="flex gap-2">
{#if typeof args == 'object' && schema?.properties?.[argName]}
<div
class={twMerge(
'flex gap-2',
animateArg === argName && 'animate-pulse ring-2 ring-offset-2 ring-blue-500 rounded'
)}
data-arg={argName}
>
{#if typeof args.value == 'object' && schema?.properties?.[argName]}
<ArgInput
{resourceTypes}
minW={false}
autofocus={i == 0 && autofocus}
autofocus={autofocus && !focusArg && i == 0}
label={argName}
description={schema.properties[argName].description}
bind:value={args[argName]}
bind:value={args.value[argName]}
type={schema.properties[argName].type}
oneOf={schema.properties[argName].oneOf}
required={schema?.required?.includes(argName)}
@@ -117,15 +144,19 @@
placeholder={schema.properties[argName].placeholder}
/>
{/if}
<div class="pt-6 mt-0.5">
<Button
on:click={() => plugIt(argName)}
size="sm"
variant="border"
color="light"
title="Re-evaluate input step"><RefreshCw size={14} /></Button
>
</div>
{#if testSteps?.isArgManuallySet(mod.id, argName)}
<div class="pt-6 mt-0.5">
<Button
on:click={() => {
plugIt(argName)
}}
size="sm"
variant="border"
color="light"
title="Re-evaluate input step"><RefreshCw size={14} /></Button
>
</div>
{/if}
</div>
{/if}
{/each}
@@ -41,7 +41,7 @@
onUpdateMock
}: Props = $props()
const { testStepStore } = getContext<FlowEditorContext>('FlowEditorContext')
const { testSteps } = getContext<FlowEditorContext>('FlowEditorContext')
let selectedJob: Job | undefined = $state(undefined)
let fetchingLastJob = false
@@ -49,7 +49,7 @@
let jobProgressReset: () => void = $state(() => {})
let nlastJob = $derived.by(() => {
if (testJob) {
if (testJob && testJob.type === 'CompletedJob') {
return { ...testJob, preview: true }
}
if (lastJob) {
@@ -90,7 +90,7 @@
{disableHistory}
>
{#snippet copilot_fix()}
{#if lang && editor && diffEditor && $testStepStore[mod.id] && selectedJob?.type === 'CompletedJob' && !selectedJob.success && getStringError(selectedJob.result)}
{#if lang && editor && diffEditor && testSteps.getStepArgs(mod.id) && selectedJob?.type === 'CompletedJob' && !selectedJob.success && getStringError(selectedJob.result)}
<ScriptFix {lang} />
{/if}
{/snippet}
@@ -0,0 +1,137 @@
<script lang="ts" module>
type testModuleState = {
loading: boolean
instances: number
cancel?: () => void
}
let testModulesState = $state<Record<string, testModuleState>>({})
</script>
<script lang="ts">
import { ScriptService, type FlowModule, type Job } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { getScriptByPath } from '$lib/scripts'
import { getContext, onMount } from 'svelte'
import type { FlowEditorContext } from './flows/types'
import TestJobLoader from './TestJobLoader.svelte'
interface Props {
mod: FlowModule
testJob?: Job | undefined
testIsLoading?: boolean
noEditor?: boolean
scriptProgress?: any
}
let {
mod,
testJob = $bindable(undefined),
testIsLoading = $bindable(false),
noEditor = false,
scriptProgress = $bindable(undefined)
}: Props = $props()
const { flowStore, flowStateStore, pathStore, testSteps, previewArgs } =
getContext<FlowEditorContext>('FlowEditorContext')
let testJobLoader: TestJobLoader | undefined = $state(undefined)
let jobProgressReset: () => void = () => {}
export function runTestWithStepArgs() {
runTest(testSteps.getStepArgs(mod.id)?.value)
}
export function loadArgsAndRunTest() {
testSteps?.updateStepArgs(mod.id, $flowStateStore, flowStore?.val, previewArgs?.val)
runTest(testSteps.getStepArgs(mod.id)?.value)
}
export async function runTest(args: any) {
// Not defined if JobProgressBar not loaded
if (jobProgressReset) jobProgressReset()
testModulesState[mod.id].cancel = testJobLoader?.cancelJob
const val = mod.value
// let jobId: string | undefined = undefined
if (val.type == 'rawscript') {
await testJobLoader?.runPreview(
val.path ?? ($pathStore ?? '') + '/' + mod.id,
val.content,
val.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore?.val?.tag ?? val.tag
)
} else if (val.type == 'script') {
const script = val.hash
? await ScriptService.getScriptByHash({ workspace: $workspaceStore!, hash: val.hash })
: await getScriptByPath(val.path)
await testJobLoader?.runPreview(
val.path,
script.content,
script.language,
mod.id === 'preprocessor' ? { _ENTRYPOINT_OVERRIDE: 'preprocessor', ...args } : args,
flowStore?.val?.tag ?? (val.tag_override ? val.tag_override : script.tag),
script.lock,
val.hash ?? script.hash
)
} else if (val.type == 'flow') {
await testJobLoader?.runFlowByPath(val.path, args)
} else {
throw Error('Not supported module type')
}
}
function jobDone() {
if (testJob && !testJob.canceled && testJob.type == 'CompletedJob' && `result` in testJob) {
if ($flowStateStore[mod.id]) {
$flowStateStore[mod.id].previewResult = testJob.result
$flowStateStore[mod.id].previewSuccess = testJob.success
$flowStateStore[mod.id].previewJobId = testJob.id
$flowStateStore[mod.id].previewWorkspaceId = testJob.workspace_id
$flowStateStore = $flowStateStore
}
}
testJob = undefined
}
export function cancelJob() {
testModulesState[mod.id]?.cancel?.()
}
$effect(() => {
testIsLoading = testModulesState[mod.id]?.loading ?? false
})
onMount(() => {
testModulesState[mod.id] = {
...(testModulesState[mod.id] ?? { loading: false, instances: 0 }),
loading: testIsLoading,
instances: testModulesState[mod.id]!.instances + 1
}
return () => {
testModulesState[mod.id]!.instances -= 1
if (testModulesState[mod.id]!.instances < 1) {
delete testModulesState[mod.id]
}
}
})
</script>
<TestJobLoader
toastError={noEditor}
on:done={() => jobDone()}
bind:scriptProgress
bind:this={testJobLoader}
bind:isLoading={
() => testModulesState[mod.id]?.loading ?? false,
(v) =>
(testModulesState[mod.id] = {
...testModulesState[mod.id],
loading: v ?? false,
instances: testModulesState[mod.id]?.instances ?? 0
})
}
bind:job={testJob}
/>
File diff suppressed because it is too large Load Diff
@@ -1 +0,0 @@
export { default as default } from './SelectLegacy.svelte'
@@ -49,6 +49,7 @@
portal?: string
}
| undefined = undefined
export let dropdownBtnClasses: string = ''
type MenuItem = {
label: string
@@ -309,7 +310,8 @@
'rounded-md m-0 p-0 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'
size === 'xs2' ? '!w-8' : '!w-10',
dropdownBtnClasses
)}
>
<ChevronDown size={lucideIconSize} />
@@ -61,6 +61,76 @@ You should only use them if you need them to satisfy the user's instructions. Al
const TS_INLINE_TYPE_INSTRUCTION = `When using resource types, you should use RT.ResourceType as parameter type. For other parameters, you should inline the objects types instead of defining them separately. This is because Windmill requires the types (other than resource types) to be inlined to generate a user friendly UI from the parameters.`
const TS_WINDMILL_CLIENT_CONTEXT = `
The windmill client (wmill) can be used to interact with Windmill from the script. Import it with \`import * as wmill from "windmill-client"\`. Key functions include:
// Resource operations
wmill.getResource(path?: string): Promise<any> // Get resource value by path
wmill.setResource(value: any, path?: string): Promise<void> // Set resource value
// State management (persistent across executions)
wmill.getState(): Promise<any> // Get shared state
wmill.setState(state: any): Promise<void> // Set shared state
// Variables
wmill.getVariable(path: string): Promise<string> // Get variable value
wmill.setVariable(path: string, value: string): Promise<void> // Set variable value
// Script execution
wmill.runScript(path: string, args?: Record<string, any>): Promise<any> // Run script synchronously
wmill.runScriptAsync(path: string, args?: Record<string, any>): Promise<string> // Run script async, returns job ID
wmill.waitJob(jobId: string): Promise<any> // Wait for job completion and get result
// S3 file operations (if S3 is configured)
wmill.loadS3File(s3object: S3Object): Promise<Uint8Array> // Load file content from S3
wmill.writeS3File(s3object: S3Object, content: string | Blob): Promise<S3Object> // Write file to S3
// Flow operations
wmill.setFlowUserState(key: string, value: any): Promise<void> // Set flow user state
wmill.getFlowUserState(key: string): Promise<any> // Get flow user state
wmill.getResumeUrls(): Promise<{approvalPage: string, resume: string, cancel: string}> // Get approval URLs
// Utilities
wmill.getWorkspace(): string // Get current workspace
wmill.databaseUrlFromResource(path: string): Promise<string> // Get database URL from resource`
const PYTHON_WINDMILL_CLIENT_CONTEXT = `
The windmill client (wmill) can be used to interact with Windmill from the script. Import it with \`import wmill\`. Key functions include:
// Resource operations
wmill.get_resource(path: str) -> dict | None // Get resource value by path
wmill.set_resource(path: str, value: Any, resource_type: str = "any") -> None // Set resource value
// State management (persistent across executions)
wmill.get_state() -> Any // Get shared state (deprecated, use flow user state)
wmill.set_state(value: Any) -> None // Set shared state
wmill.get_flow_user_state(key: str) -> Any // Get flow user state
wmill.set_flow_user_state(key: str, value: Any) -> None // Set flow user state
// Variables
wmill.get_variable(path: str) -> str // Get variable value
wmill.set_variable(path: str, value: str, is_secret: bool = False) -> None // Set variable value
// Script execution
wmill.run_script(path: str, args: dict = None, timeout = None) -> Any // Run script synchronously
wmill.run_script_async(path: str, args: dict = None, scheduled_in_secs: int = None) -> str // Run script async, returns job ID
wmill.wait_job(job_id: str, timeout = None) -> Any // Wait for job completion and get result
// S3 file operations (if S3 is configured)
wmill.load_s3_file(s3object: S3Object, s3_resource_path: str = None) -> bytes // Load file content from S3
wmill.write_s3_file(s3object: S3Object, file_content: bytes, s3_resource_path: str = None) -> S3Object // Write file to S3
// Flow operations
wmill.run_flow_async(path: str, args: dict = None) -> str // Run flow asynchronously
wmill.get_resume_urls(approver: str = None) -> dict // Get approval URLs for flow steps
// Utilities
wmill.whoami() -> dict // Get current user information
wmill.get_job_status(job_id: str) -> str // Get job status ("RUNNING" | "WAITING" | "COMPLETED")
wmill.set_progress(value: int) -> None // Set job progress (0-100)`
const PYTHON_RESOURCE_TYPE_SYSTEM = `On Windmill, credentials and configuration are stored in resources and passed as parameters to main.
If you need credentials, you should add a parameter to \`main\` with the corresponding resource type.
You need to **redefine** the type of the resources that are needed before the main function as TypedDict, but only include them if they are actually needed to achieve the function purpose.
@@ -108,7 +178,9 @@ export const SUPPORTED_CHAT_SCRIPT_LANGUAGES = [
'snowflake',
'mssql',
'graphql',
'powershell'
'powershell',
'csharp',
'java'
]
export function getLangContext(
@@ -124,7 +196,9 @@ export function getLangContext(
: TS_RESOURCE_TYPE_SYSTEM +
(allowResourcesFetch
? `To query the RT namespace, you can use the \`search_resource_types\` tool.\n`
: '')) + TS_INLINE_TYPE_INSTRUCTION
: '')) +
TS_INLINE_TYPE_INSTRUCTION +
TS_WINDMILL_CLIENT_CONTEXT
const mainFunctionName = isPreprocessor ? 'preprocessor' : 'main'
@@ -152,7 +226,8 @@ export function getLangContext(
(isPreprocessor
? PYTHON_PREPROCESSOR_INSTRUCTION
: PYTHON_RESOURCE_TYPE_SYSTEM +
`${allowResourcesFetch ? `\nTo query the available resource types, you can use the \`search_resource_types\` tool.` : ''}`)
`${allowResourcesFetch ? `\nTo query the available resource types, you can use the \`search_resource_types\` tool.` : ''}`) +
PYTHON_WINDMILL_CLIENT_CONTEXT
)
case 'php':
return (
@@ -210,6 +285,10 @@ If you want to handle async functions (e.g., using tokio), you need to keep the
return 'The user is coding in GraphQL. If needed, add the needed arguments as query parameters.'
case 'powershell':
return 'The user is coding in PowerShell. On Windmill, arguments can be obtained by calling the param function on the first line of the script like that: `param($ParamName1, $ParamName2 = "default value", [{type}]$ParamName3, ...)`'
case 'csharp':
return 'The user is coding in C#. On Windmill, it is expected the script contains a public static Main method inside a class. The class name is irrelevant. NuGet packages can be added using the format: #r "nuget: PackageName, Version" at the top of the script. The Main method signature should be: public static ReturnType Main(parameter types...)'
case 'java':
return 'The user is coding in Java. On Windmill, it is expected the script contains a Main public class and a public static main() method. The return type can be Object or void. Dependencies can be added using the format: //requirements://groupId:artifactId:version at the top of the script. The method signature should be: public static Object main(parameter types...)'
default:
return ''
}
@@ -49,6 +49,12 @@ export const EDIT_PROMPT = {
"frontend": {
"prompt": "Here's my client-side javascript code: \n```javascript\n{code}\n```\n<contextual_information>\nYou can access the context object with the ctx global variable. \nThe app state is a store that can be used to store data. You can access and update the state object with the state global variable like this: state.foo = 'bar'\nYou can use the goto function to navigate to a specific URL: goto(path: string, newTab?: boolean)\nYou can use the openModal and closeModal functions to open and close a modal: openModal(id: string), closeModal(id: string)\nYou can use the setTab function to manually set the tab of a Tab component: setTab(id: string, index: string)\nYou can use the recompute function to recompute a component: recompute(id: string)\nYou can use the getAgGrid function to get the ag-grid instance of a table: getAgGrid(id: string)\nYou can use the setSelectedIndex function to select a row in a table or an AG Grid table: setSelectedIndex(id: string, index: number)\nThe setValue function is meant to set or force the value of a component: setValue(id: string, value: any)\nYou can validate a specific field of a form: validate(id: string, key: string)\nYou can validate all fields of a form: validateAll(id: string, key: string)\nYou can invalidate a specific field of a form: invalidate(id: string, key: string, error: string)\n</contextual_information>\nMy instructions: {description}"
},
"csharp": {
"prompt": "Here's my C# code: \n```csharp\n{code}\n```\n<contextual_information>\nYou have to write C# code with a public static Main method inside a class. The class name is irrelevant. Specify the parameter types. Do not call the main function. You should generally return the result.\nNuGet packages can be added using the format: #r \"nuget: PackageName, Version\" at the top of the script.\nThe Main method signature should be: public static ReturnType Main(parameter types...)\nArguments are used to generate the input specification and create the frontend UI for the script.\n</contextual_information>\nMy instructions: {description}"
},
"java": {
"prompt": "Here's my Java code: \n```java\n{code}\n```\n<contextual_information>\nYou have to write Java code with a Main public class and a public static main() method. The return type can be Object or void. Do not call the main function. You should generally return the result.\nDependencies can be added using the format: //requirements://groupId:artifactId:version at the top of the script.\nThe method signature should be: public static Object main(parameter types...)\nArguments are used to generate the input specification and create the frontend UI for the script.\n</contextual_information>\nMy instructions: {description}"
},
"transformer": {
"prompt": "Here's my client-side javascript code: \n```javascript\n{code}\n```\n\n<contextual_information>\nThe code should process the variable `result` according to my instructions.\nThe variable `result` is available globally.\nAt the end of the code, the processed result has to be returned.\n\n<helpers>\nYou can access the context object with the ctx global variable. \nThe app state is a store that can be used to store data. You can access and update the state object with the state global variable like this: state.foo = 'bar'\nYou can use the goto function to navigate to a specific URL: goto(path: string, newTab?: boolean)\nYou can use the openModal and closeModal functions to open and close a modal: openModal(id: string), closeModal(id: string)\nYou can use the setTab function to manually set the tab of a Tab component: setTab(id: string, index: string)\nYou can use the recompute function to recompute a component: recompute(id: string)\nYou can use the getAgGrid function to get the ag-grid instance of a table: getAgGrid(id: string)\nYou can use the setSelectedIndex function to select a row in a table or an AG Grid table: setSelectedIndex(id: string, index: number)\nThe setValue function is meant to set or force the value of a component: setValue(id: string, value: any)\nYou can validate a specific field of a form: validate(id: string, key: string)\nYou can validate all fields of a form: validateAll(id: string, key: string)\nYou can invalidate a specific field of a form: invalidate(id: string, key: string, error: string)\n</helpers>\n</contextual_information>\nMy instructions: {description}"
}
@@ -45,6 +45,12 @@ export const FIX_PROMPT = {
},
"php": {
"prompt": "Here's my php code: \n```php\n{code}\n```\n<contextual_information>\nYou have to write a function in php called \"main\". Specify the parameter types. Do not call the main function. You should generally return the result. The script must start with `<?php`.\nYou can take as parameters resources which are classes containing credentials or configuration information. For Windmill to correctly detect the resources to be passed, the resource type name has to be exactly as specified in the following list:\n<resourceTypes>\n{resourceTypes}\n</resourceTypes>\nYou need to define the type of the resources that are needed before the main function, but only include them if they are actually needed to achieve the function purpose.\nBefore defining each type, check if the class already exists using class_exists.\nThe resource type name has to be exactly as specified.\nIf you need to import libraries, you need to specify them as comments in the following manner before the main function:\n```\n// require:\n// mylibrary/mylibrary\n// myotherlibrary/myotherlibrary@optionalversion\n```\nNo need to require autoload, it is already done.\n<contextual_information>\nI get the following error: {error}\nFix my code."
},
"csharp": {
"prompt": "Here's my C# code: \n```csharp\n{code}\n```\n<contextual_information>\nYou have to write C# code with a public static Main method inside a class. The class name is irrelevant. Specify the parameter types. Do not call the main function. You should generally return the result.\nNuGet packages can be added using the format: #r \"nuget: PackageName, Version\" at the top of the script.\nThe Main method signature should be: public static ReturnType Main(parameter types...)\nArguments are used to generate the input specification and create the frontend UI for the script.\n</contextual_information>\nI get the following error: {error}\nFix my code."
},
"java": {
"prompt": "Here's my Java code: \n```java\n{code}\n```\n<contextual_information>\nYou have to write Java code with a Main public class and a public static main() method. The return type can be Object or void. Do not call the main function. You should generally return the result.\nDependencies can be added using the format: //requirements://groupId:artifactId:version at the top of the script.\nThe method signature should be: public static Object main(parameter types...)\nArguments are used to generate the input specification and create the frontend UI for the script.\n</contextual_information>\nI get the following error: {error}\nFix my code."
}
}
};
@@ -51,6 +51,12 @@ export const GEN_PROMPT = {
},
"transformer": {
"prompt": "Write client-side javascript code that should process the variable `result` according to the following instructions: {description}.\nThe variable `result` is available globally.\nAt the end of the code, the processed result has to be returned.\n\n<contextual_information>\nYou can access the context object with the ctx global variable. \nThe app state is a store that can be used to store data. You can access and update the state object with the state global variable like this: state.foo = 'bar'\nYou can use the goto function to navigate to a specific URL: goto(path: string, newTab?: boolean)\nYou can use the openModal and closeModal functions to open and close a modal: openModal(id: string), closeModal(id: string)\nYou can use the setTab function to manually set the tab of a Tab component: setTab(id: string, index: string)\nYou can use the recompute function to recompute a component: recompute(id: string)\nYou can use the getAgGrid function to get the ag-grid instance of a table: getAgGrid(id: string)\nYou can use the setSelectedIndex function to select a row in a table or an AG Grid table: setSelectedIndex(id: string, index: number)\nThe setValue function is meant to set or force the value of a component: setValue(id: string, value: any)\nYou can validate a specific field of a form: validate(id: string, key: string)\nYou can validate all fields of a form: validateAll(id: string, key: string)\nYou can invalidate a specific field of a form: invalidate(id: string, key: string, error: string)\n</contextual_information>"
},
"csharp": {
"prompt": "<contextual_information>\nYou have to write C# code with a public static Main method inside a class. The class name is irrelevant. Specify the parameter types. Do not call the main function. You should generally return the result.\nNuGet packages can be added using the format: #r \"nuget: PackageName, Version\" at the top of the script.\nThe Main method signature should be: public static ReturnType Main(parameter types...)\nArguments are used to generate the input specification and create the frontend UI for the script.\n</contextual_information>\nMy instructions: {description}"
},
"java": {
"prompt": "<contextual_information>\nYou have to write Java code with a Main public class and a public static main() method. The return type can be Object or void. Do not call the main function. You should generally return the result.\nDependencies can be added using the format: //requirements://groupId:artifactId:version at the top of the script.\nThe method signature should be: public static Object main(parameter types...)\nArguments are used to generate the input specification and create the frontend UI for the script.\n</contextual_information>\nMy instructions: {description}"
}
}
};
@@ -31,6 +31,10 @@
})
| undefined
onDeployTrigger?: (trigger: Trigger) => void
onTestUpTo?: ((id: string) => void) | undefined
onEditInput?: ((moduleId: string, key: string) => void) | undefined
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
}
let {
@@ -43,7 +47,11 @@
smallErrorHandler = false,
newFlow = false,
savedFlow = undefined,
onDeployTrigger = () => {}
onDeployTrigger = () => {},
onTestUpTo = undefined,
onEditInput = undefined,
forceTestTab,
highlightArg
}: Props = $props()
let flowModuleSchemaMap: FlowModuleSchemaMap | undefined = $state()
@@ -92,6 +100,8 @@
}
aiChatManager.generateStep(detail.moduleId, detail.lang, detail.instructions)
}}
{onTestUpTo}
{onEditInput}
/>
{/if}
</div>
@@ -112,6 +122,8 @@
on:applyArgs
on:testWithArgs
{onDeployTrigger}
{forceTestTab}
{highlightArg}
/>
{/if}
</Pane>
@@ -25,6 +25,8 @@
})
| undefined
onDeployTrigger?: (trigger: Trigger) => void
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
}
let {
@@ -33,7 +35,9 @@
newFlow = false,
disabledFlowInputs = false,
savedFlow = undefined,
onDeployTrigger = () => {}
onDeployTrigger = () => {},
forceTestTab,
highlightArg
}: Props = $props()
const {
@@ -138,6 +142,8 @@
previousModule={flowStore.val.value.modules[index - 1]}
{enableAi}
savedModule={savedFlow?.value.modules[index]}
{forceTestTab}
{highlightArg}
/>
{/each}
{/key}
@@ -77,6 +77,8 @@
noEditor: boolean
enableAi: boolean
savedModule?: FlowModule | undefined
forceTestTab?: boolean
highlightArg?: string
}
let {
@@ -89,7 +91,9 @@
scriptTemplate = 'script',
noEditor,
enableAi,
savedModule = undefined
savedModule = undefined,
forceTestTab = false,
highlightArg = undefined
}: Props = $props()
let tag: string | undefined = $state(undefined)
@@ -268,6 +272,20 @@
onDestroy(() => {
$currentEditor = undefined
})
// Handle force test tab prop with animation
$effect(() => {
if (forceTestTab) {
selected = 'test'
// Add a smooth transition to the test tab
setTimeout(() => {
const testTab = document.querySelector('[value="test"]')
if (testTab) {
testTab.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}, 100)
}
})
</script>
<svelte:window onkeydown={onKeyDown} />
@@ -524,6 +542,7 @@
bind:testJob
bind:testIsLoading
bind:scriptProgress
focusArg={highlightArg}
/>
{:else if selected === 'advanced'}
<Tabs
@@ -40,6 +40,8 @@
parentModule?: FlowModule | undefined
// Pointer to previous module, for easy access to testing results
previousModule?: FlowModule | undefined
forceTestTab?: Record<string, boolean>
highlightArg?: Record<string, string | undefined>
}
let {
@@ -48,7 +50,9 @@
enableAi = false,
savedModule = undefined,
parentModule = $bindable(),
previousModule = undefined
previousModule = undefined,
forceTestTab,
highlightArg
}: Props = $props()
function initializePrimaryScheduleForTriggerScript(module: FlowModule) {
@@ -196,6 +200,8 @@
{scriptTemplate}
{enableAi}
{savedModule}
forceTestTab={forceTestTab?.[flowModule.id]}
highlightArg={highlightArg?.[flowModule.id]}
/>
{/if}
{:else if flowModule.value.type === 'forloopflow' || flowModule.value.type == 'whileloopflow'}
@@ -210,6 +216,8 @@
? savedModule.value.modules[index]
: undefined}
{enableAi}
{forceTestTab}
{highlightArg}
/>
{/each}
{:else if flowModule.value.type === 'branchone'}
@@ -229,6 +237,8 @@
? savedModule.value.default[index]
: undefined}
{enableAi}
{forceTestTab}
{highlightArg}
/>
{/each}
{/if}
@@ -252,6 +262,8 @@
? savedModule.value.branches[branchIndex]?.modules[index]
: undefined}
{enableAi}
{forceTestTab}
{highlightArg}
/>
{/each}
{/if}
@@ -271,6 +283,8 @@
savedModule={savedModule?.value.type === 'branchall'
? savedModule.value.branches[branchIndex]?.modules[index]
: undefined}
{forceTestTab}
{highlightArg}
/>
{/each}
{/if}
@@ -70,6 +70,12 @@
'triggers'
].includes($selectedId) ||
$selectedId?.includes('branch')
export function testUpTo() {
if (upToDisabled) return
previewMode = 'upTo'
previewOpen = true
}
</script>
{#if !upToDisabled}
@@ -78,10 +84,7 @@
disabled={upToDisabled}
color="light"
variant="border"
on:click={() => {
previewMode = 'upTo'
previewOpen = !previewOpen
}}
on:click={testUpTo}
startIcon={{ icon: Play }}
>
Test up to&nbsp;
@@ -14,7 +14,9 @@
Square,
SkipForward,
Pin,
X
X,
Play,
Loader2
} from 'lucide-svelte'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import { fade } from 'svelte/transition'
@@ -23,6 +25,7 @@
import { twMerge } from 'tailwind-merge'
import IdEditorInput from '$lib/components/IdEditorInput.svelte'
import { dfs } from '../dfs'
import { dfs as dfsPreviousResults } from '../previousResults'
import { Drawer } from '$lib/components/common'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import { getDependeeAndDependentComponents } from '../flowExplorer'
@@ -32,6 +35,8 @@
import OutputPicker from '$lib/components/flows/propPicker/OutputPicker.svelte'
import OutputPickerInner from '$lib/components/flows/propPicker/OutputPickerInner.svelte'
import type { FlowState } from '$lib/components/flows/flowState'
import { Button } from '$lib/components/common'
import ModuleTest from '$lib/components/ModuleTest.svelte'
interface Props {
selected?: boolean
@@ -63,7 +68,10 @@
alwaysShowOutputPicker?: boolean
loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined
icon?: import('svelte').Snippet
onTestUpTo?: ((id: string) => void) | undefined
inputTransform?: Record<string, any> | undefined
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -91,7 +99,10 @@
alwaysShowOutputPicker = false,
loopStatus = undefined,
icon,
onUpdateMock
onTestUpTo,
inputTransform,
onUpdateMock,
onEditInput
}: Props = $props()
let pickableIds: Record<string, any> | undefined = $state(undefined)
@@ -116,11 +127,15 @@
let newId: string = $state(id ?? '')
let moduleTest: ModuleTest | undefined = $state(undefined)
let testIsLoading = $state(false)
let hover = $state(false)
let connectingData: any | undefined = $state(undefined)
let lastJob: any | undefined = $state(undefined)
let outputPicker: OutputPicker | undefined = $state(undefined)
let historyOpen = $state(false)
let testJob: any | undefined = $state(undefined)
let outputPickerBarOpen = $state(false)
let flowStateStore = $derived(flowEditorContext?.flowStateStore)
@@ -158,10 +173,24 @@
flowStateStore && $flowStateStore && untrack(() => updateLastJob($flowStateStore))
})
let nlastJob = $derived.by(() => {
if (testJob) {
return { ...testJob, preview: true }
}
if (lastJob) {
return { ...lastJob, preview: false }
}
return undefined
})
let isConnectingCandidate = $derived(
!!id && !!$flowPropPickerConfig && !!pickableIds && Object.keys(pickableIds).includes(id)
)
const outputPickerVisible = $derived(
editMode && (isConnectingCandidate || alwaysShowOutputPicker) && !!id
)
const icon_render = $derived(icon)
</script>
@@ -223,22 +252,36 @@
</Drawer>
{/if}
{#if deletable && id && flowEditorContext?.flowStore && outputPickerVisible}
{@const flowStore = flowEditorContext?.flowStore.val}
{@const mod = flowStore?.value ? dfsPreviousResults(id, flowStore, false)[0] : undefined}
{#if mod && $flowStateStore[id]}
<ModuleTest bind:this={moduleTest} {mod} bind:testIsLoading bind:testJob />
{/if}
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full module flex rounded-sm cursor-pointer max-w-full outline-offset-0 outline-slate-500 dark:outline-gray-400',
selected ? 'outline outline-2' : 'active:outline active:outline-2',
'flex relative'
)}
style="width: 275px; height: 38px; background-color: {hover && bgHoverColor
class={classNames('w-full module flex rounded-sm cursor-pointer max-w-full ', 'flex relative')}
style="width: 275px; height: 34px; background-color: {hover && bgHoverColor
? bgHoverColor
: bgColor};"
onmouseenter={() => (hover = true)}
onmouseleave={() => (hover = false)}
onpointerdown={stopPropagation(preventDefault(() => dispatch('pointerdown')))}
>
<div class="absolute text-sm right-12 -bottom-3 flex flex-row gap-1 z-10">
<div
class={classNames(
'absolute rounded-sm outline-offset-0 outline-slate-500 dark:outline-gray-400',
selected ? 'outline outline-2' : 'active:outline active:outline-2'
)}
style={`width: 275px; height: ${outputPickerVisible ? '51px' : '34px'};`}
></div>
<div
class="absolute text-sm right-2 flex flex-row gap-1 z-10 transition-all duration-100"
style={`bottom: ${outputPickerBarOpen ? '-38px' : '-12px'}`}
>
{#if retry}
<Popover notClickable>
<div
@@ -358,13 +401,18 @@
{/snippet}
</FlowModuleSchemaItemViewer>
{#if editMode && (isConnectingCandidate || alwaysShowOutputPicker)}
{#if outputPickerVisible}
<OutputPicker
bind:this={outputPicker}
{selected}
{hover}
{isConnectingCandidate}
{historyOpen}
{inputTransform}
id={id ?? ''}
bind:bottomBarOpen={outputPickerBarOpen}
{loopStatus}
{onEditInput}
>
{#snippet children({ allowCopy, isConnecting, selectConnection })}
<OutputPickerInner
@@ -372,9 +420,9 @@
prefix={'results'}
connectingData={isConnecting ? connectingData : undefined}
{mock}
{lastJob}
onSelect={selectConnection}
lastJob={nlastJob}
moduleId={id}
onSelect={selectConnection}
{onUpdateMock}
{path}
{loopStatus}
@@ -382,6 +430,7 @@
bind:derivedHistoryOpen={historyOpen}
historyOffset={{ mainAxis: 12, crossAxis: -9 }}
clazz="p-1"
isLoading={testIsLoading}
/>
{/snippet}
</OutputPicker>
@@ -389,10 +438,60 @@
</div>
{#if deletable}
<div
class="absolute top-1/2 -translate-y-1/2 -translate-x-[100%] -left-[0] flex items-center w-fit px-2 h-9 min-w-14"
>
{#if (hover || selected) && outputPickerVisible}
<div transition:fade={{ duration: 100 }}>
{#if !testIsLoading}
<Button
size="sm"
color="dark"
title="Run"
btnClasses="p-1.5"
on:click={() => {
outputPicker?.toggleOpen(true)
moduleTest?.loadArgsAndRunTest()
}}
dropdownItems={[
{
label: 'Test up to here',
onClick: () => {
if (id) {
onTestUpTo?.(id)
}
}
}
]}
dropdownBtnClasses="!w-4 px-1"
>
{#if testIsLoading}
<Loader2 size={12} class="animate-spin" />
{:else}
<Play size={12} />
{/if}
</Button>
{:else}
<Button
size="xs"
color="red"
variant="contained"
btnClasses="!h-[25.5px] !w-[44.5px] !p-1.5 gap-0.5"
on:click={async () => {
moduleTest?.cancelJob()
}}
>
<Loader2 size={10} class="animate-spin mr-0.5" />
<X size={14} />
</Button>
{/if}
</div>
{/if}
</div>
<button
class="absolute -top-[10px] -right-[10px] rounded-full h-[20px] w-[20px] trash center-center text-secondary
outline-[1px] outline dark:outline-gray-500 outline-gray-300 bg-surface duration-0 hover:bg-red-400 hover:text-white
{hover || selected ? '' : '!hidden'}"
outline-[1px] outline dark:outline-gray-500 outline-gray-300 bg-surface duration-0 hover:bg-red-400 hover:text-white
{hover || selected ? '' : '!hidden'}"
title="Delete"
onclick={stopPropagation(
preventDefault((event) => dispatch('delete', { id, type: modType }))
@@ -48,6 +48,8 @@
newFlow?: boolean
smallErrorHandler?: boolean
workspace?: string | undefined
onTestUpTo?: ((id: string) => void) | undefined
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -58,7 +60,9 @@
disableSettings = false,
newFlow = false,
smallErrorHandler = false,
workspace = $workspaceStore
workspace = $workspaceStore,
onTestUpTo,
onEditInput
}: Props = $props()
let flowTutorials: FlowTutorials | undefined = $state(undefined)
@@ -343,6 +347,8 @@
{flowInputsStore}
{workspace}
editMode
{onTestUpTo}
{onEditInput}
onDelete={(id) => {
dependents = getDependentComponents(id, flowStore.val)
const cb = () => {
@@ -39,10 +39,12 @@
editMode?: boolean
onSelectedIteration: onSelectedIteration
onSelect: (id: string | FlowModule) => void
onTestUpTo?: ((id: string) => void) | undefined
onUpdateMock?: (detail: {
id: string
mock: { enabled: boolean; return_value?: unknown }
}) => void
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -59,7 +61,9 @@
flowJobs,
editMode = false,
onSelect,
onUpdateMock
onTestUpTo,
onUpdateMock,
onEditInput
}: Props = $props()
const { selectedId } = getContext<{
@@ -155,6 +159,7 @@
: ''}
alwaysShowOutputPicker={!mod.id.startsWith('subflow:')}
loopStatus={{ type: 'self', flow: mod.value.type }}
{onTestUpTo}
>
{#snippet icon()}
<div>
@@ -175,6 +180,7 @@
label={mod.summary || 'Run one branch'}
{bgColor}
{bgHoverColor}
{onTestUpTo}
>
{#snippet icon()}
<div>
@@ -195,6 +201,7 @@
label={mod.summary || `Run all branches${mod.value.parallel ? ' (parallel)' : ''}`}
{bgColor}
{bgHoverColor}
{onTestUpTo}
>
{#snippet icon()}
<div>
@@ -236,6 +243,9 @@
isTrigger={isTriggerStep(mod)}
alwaysShowOutputPicker={!mod.id.startsWith('subflow:') && mod.id !== 'preprocessor'}
loopStatus={parentLoop ? { type: 'inside', flow: parentLoop.type } : undefined}
inputTransform={mod.value.type !== 'identity' ? mod.value.input_transforms : undefined}
{onTestUpTo}
{onEditInput}
>
{#snippet icon()}
<div>
@@ -27,6 +27,7 @@
editMode?: boolean
icon?: import('svelte').Snippet
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -47,11 +48,25 @@
earlyStop = false,
editMode = false,
icon,
onUpdateMock
onUpdateMock,
onEditInput
}: Props = $props()
const outputPickerVisible = $derived(
(alwaysPluggable || (inputJson && Object.keys(inputJson).length > 0)) && editMode
)
</script>
<VirtualItemWrapper {label} {bgColor} {bgHoverColor} {selected} {selectable} {id} on:select>
<VirtualItemWrapper
{label}
{bgColor}
{bgHoverColor}
{selected}
{selectable}
{id}
outputPickerVisible={outputPickerVisible ?? false}
on:select
>
{#snippet children({ hover })}
<div class="flex flex-col w-full">
<div
@@ -80,8 +95,14 @@
</div>
{/if}
</div>
{#if (alwaysPluggable || (inputJson && Object.keys(inputJson).length > 0)) && editMode}
<OutputPicker {selected} {hover} isConnectingCandidate={true} variant="virtual">
{#if outputPickerVisible}
<OutputPicker
{selected}
{hover}
id={id ?? ''}
isConnectingCandidate={true}
variant="virtual"
>
{#snippet children({ allowCopy, isConnecting, selectConnection })}
<OutputPickerInner
{allowCopy}
@@ -95,6 +116,8 @@
rightMargin
historyOffset={{ mainAxis: 12, crossAxis: -9 }}
clazz="p-1"
{onEditInput}
selectionId={id ?? label ?? ''}
/>
{/snippet}
</OutputPicker>
@@ -2,6 +2,7 @@
import type { FlowModule } from '$lib/gen'
import { classNames } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
label: string | undefined
@@ -12,6 +13,7 @@
bgColor: string
bgHoverColor?: string
children?: import('svelte').Snippet<[any]>
outputPickerVisible?: boolean
}
let {
@@ -22,7 +24,8 @@
onTop = false,
bgColor,
bgHoverColor = '',
children
children,
outputPickerVisible = false
}: Props = $props()
const dispatch = createEventDispatcher<{
@@ -41,14 +44,8 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={classNames(
'w-full flex relative rounded-sm',
selectable ? 'cursor-pointer active:outline active:outline-2' : '',
selected ? 'outline outline-2' : '',
onTop ? 'z-[901]' : '',
'outline-offset-1 outline-gray-600 dark:outline-gray-400'
)}
style="width: 275px; max-height: 38px; background-color: {hover && bgHoverColor && selectable
class={classNames('w-full flex relative rounded-sm', onTop ? 'z-[901]' : '')}
style="width: 275px; max-height: 34px; background-color: {hover && bgHoverColor && selectable
? bgHoverColor
: bgColor};"
onpointerdown={() => {
@@ -64,5 +61,15 @@
}}
title={label ? label + ' ' : ''}
id={`flow-editor-virtual-${encodeURIComponent(label || label || '')}`}
>{@render children?.({ hover })}</div
>
<div
class={twMerge(
'absolute outline-gray-600 dark:outline-gray-400 rounded-sm',
selected ? 'outline outline-2' : '',
selectable ? 'cursor-pointer active:outline active:outline-2' : ''
)}
style={`width: 275px; height: ${outputPickerVisible ? '50px' : '34px'};`}
>
</div>
{@render children?.({ hover })}
</div>
@@ -88,7 +88,10 @@ function getFlowInput(
}
} else {
let parentFlowInput = getFlowInput(parentModules, flowState, args, schema)
if (parentModule.value.type === 'forloopflow' || parentModule.value.type === 'whileloopflow') {
if (
parentModule.value.type === 'forloopflow' ||
parentModule.value.type === 'whileloopflow'
) {
let parentFlowInputIter = { ...parentFlowInput }
if (parentFlowInputIter.hasOwnProperty('iter')) {
parentFlowInputIter['iter_parent'] = parentFlowInputIter['iter']
@@ -205,8 +208,8 @@ export function getStepPropPicker(
return [
id,
module?.mock?.enabled
? module.mock.return_value ?? {}
: flowState[id]?.previewResult ?? {}
? (module.mock.return_value ?? {})
: (flowState[id]?.previewResult ?? {})
]
})
.reverse()
@@ -323,3 +326,46 @@ export function filterNestedObject(obj: any, nestedKeys: string[]) {
}
return {}
}
/**
* Get the ID of the previous module within the same container (loop or branch)
* based on the same logic used in FlowModuleWrapper.svelte
*/
export function getPreviousModule(moduleId: string, flow: OpenFlow): FlowModule | undefined {
function searchInModules(modules: FlowModule[]): FlowModule | undefined | null {
for (let i = 0; i < modules.length; i++) {
const module = modules[i]
if (module.id === moduleId) {
// Found the module, return previous module ID if it exists
return i > 0 ? modules[i - 1] : undefined
}
// Search in submodules based on module type
if (module.value.type === 'forloopflow' || module.value.type === 'whileloopflow') {
const result = searchInModules(module.value.modules)
if (result !== null) return result
} else if (module.value.type === 'branchone') {
// Search in default branch
const defaultResult = searchInModules(module.value.default)
if (defaultResult !== null) return defaultResult
// Search in each branch
for (const branch of module.value.branches) {
const branchResult = searchInModules(branch.modules)
if (branchResult !== null) return branchResult
}
} else if (module.value.type === 'branchall') {
// Search in each branch
for (const branch of module.value.branches) {
const branchResult = searchInModules(branch.modules)
if (branchResult !== null) return branchResult
}
}
}
return null // Continue searching (module not found in this branch)
}
const result = searchInModules(flow.value.modules)
return result === null ? undefined : result
}
@@ -0,0 +1,66 @@
<script lang="ts">
import type { FlowEditorContext } from '../types'
import { getContext, onMount } from 'svelte'
import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte'
import { twMerge } from 'tailwind-merge'
import { DollarSign, Pencil, RefreshCw, SquareFunction } from 'lucide-svelte'
interface Props {
inputTransform: Record<string, any> | undefined
id: string
onEditInput?: (moduleId: string, key: string) => void
}
let { inputTransform, id, onEditInput }: Props = $props()
const { flowStore, flowStateStore, testSteps, previewArgs } =
getContext<FlowEditorContext | undefined>('FlowEditorContext') || {}
onMount(() => {
testSteps?.updateStepArgs(id, $flowStateStore, flowStore?.val, previewArgs?.val)
})
const input = $derived(testSteps?.getStepArgs(id)?.value)
</script>
<div class="p-4 pr-6 h-full overflow-y-auto">
<ObjectViewer json={input} {inputTransform} {metaData} {editKey} />
</div>
{#snippet metaData(key: string)}
{#if inputTransform?.[key]}
<span
class={twMerge(
'inline-flex items-center h-4 border px-1 rounded-[0.275rem] rounded-l-none border-l-0 gap-0.5',
'text-2xs'
)}
title={inputTransform[key].type === 'javascript' ? inputTransform[key].expr : 'Static'}
>
{#if inputTransform[key].type === 'javascript'}
<SquareFunction size={14} class="text-blue-500 -my-1 dark:text-blue-400" />
{:else if inputTransform[key].type === 'static'}
<DollarSign size={12} class="text-tertiary font-mono -my-1" />
{/if}
{#if testSteps?.isArgManuallySet(id, key)}
<button
onclick={() => {
testSteps?.evalArg(id, key, $flowStateStore, flowStore?.val, previewArgs?.val)
}}
title="Re-evaluate input"
class="-my-1 ml-0.5 hover:text-primary dark:hover:text-primary dark:text-gray-500 text-gray-300"
>
<RefreshCw size={12} />
</button>
{/if}
</span>
{/if}
{/snippet}
{#snippet editKey(key: string)}
<button
onclick={() => onEditInput?.(id, key)}
class="h-4 w-fit items-center text-gray-300 dark:text-gray-500 hover:text-primary dark:hover:text-primary px-1 rounded-[0.275rem] align-baseline"
>
<Pencil size={12} class="-my-1 inline-flex items-center" />
</button>
{/snippet}
@@ -1,10 +1,12 @@
<script lang="ts">
import { ChevronDown } from 'lucide-svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import { twMerge } from 'tailwind-merge'
import { getContext } from 'svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
import InputPickerInner from './InputPickerInner.svelte'
import { ChevronDown, Plug } from 'lucide-svelte'
import { useSvelteFlow } from '@xyflow/svelte'
interface Props {
selected?: boolean
@@ -13,6 +15,11 @@
variant?: 'default' | 'virtual'
historyOpen?: boolean
children?: import('svelte').Snippet<[any]>
inputTransform?: Record<string, any> | undefined
id: string
bottomBarOpen?: boolean
loopStatus?: { type: 'inside' | 'self'; flow: 'forloopflow' | 'whileloopflow' } | undefined
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -21,7 +28,12 @@
isConnectingCandidate = false,
variant = 'default',
historyOpen = false,
children
children,
inputTransform,
id,
bottomBarOpen = $bindable(false),
loopStatus,
onEditInput
}: Props = $props()
const context = getContext<PropPickerContext>('PropPickerContext')
@@ -29,6 +41,11 @@
const MIN_WIDTH = 375
const MIN_HEIGHT = 375
let outputOpen = $state(false)
let inputOpen = $state(false)
const zoom = $derived.by(useSvelteFlow().getZoom)
let showConnecting = $derived(
isConnectingCandidate && $flowPropPickerConfig?.insertionMode === 'connect'
)
@@ -40,98 +57,178 @@
}
}
let inputPopover: Popover | undefined = $state(undefined)
let popover: Popover | undefined = $state(undefined)
const virtualItemClasses = {
bar: 'dark:hover:bg-[#525d6f] dark:bg-[#414958] bg-[#d7dfea] hover:bg-slate-300',
handle:
'dark:group-hover:bg-[#525d6f] dark:hover:bg-[#525d6f] dark:bg-[#414958] bg-[#d7dfea] hover:bg-slate-300 group-hover:bg-slate-300'
bar: 'dark:hover:bg-[#525d6f] dark:bg-[#414958] bg-[#d7dfea] hover:bg-slate-300'
}
const defaultClasses = {
bar: 'bg-surface-disabled hover:bg-surface-hover dark:bg-[#454e5f] dark:hover:bg-[#576278]',
handle:
'group-hover:bg-surface-hover hover:bg-surface-hover bg-surface-disabled dark:bg-[#454e5f] dark:hover:bg-[#576278] dark:group-hover:bg-[#576278]'
bar: 'bg-surface-disabled hover:bg-surface-hover dark:bg-[#454e5f] dark:hover:bg-[#576278]'
}
export function toggleOpen() {
if (popover?.isOpened()) {
export function toggleOpen(forceOpen: boolean = false) {
if (popover?.isOpened() && !forceOpen) {
popover?.close()
} else {
popover?.open()
}
}
$effect(() => {
bottomBarOpen = inputOpen || outputOpen || selected || hover || showConnecting
})
const showInput = $derived(
variant === 'default' && !showConnecting && loopStatus?.type !== 'self'
)
function updatePositioning(historyOpen: boolean, zoom: number) {
inputPopover?.updatePositioning({
placement: 'bottom',
gutter: 0,
offset: { mainAxis: 3, crossAxis: 69 * zoom },
overflowPadding: historyOpen ? 250 : 8
})
popover?.updatePositioning({
placement: 'bottom',
gutter: 0,
offset: { mainAxis: 3, crossAxis: showInput ? -69 * zoom : 0 },
overflowPadding: historyOpen ? 250 : 8
})
}
$effect(() => {
updatePositioning(historyOpen, zoom)
})
</script>
<Popover
floatingConfig={{
placement: 'bottom',
overflowPadding: historyOpen ? 250 : 8
}}
usePointerDownOutside
closeOnOutsideClick={false}
on:click={(e) => {
<div
class="relative h-1 w-[275px]"
onpointerdown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
bind:this={popover}
allowFullScreen
contentClasses="overflow-hidden resize rounded-md"
contentStyle={`width: calc(${MIN_WIDTH}px); min-width: calc(${MIN_WIDTH}px); height: calc(${MIN_HEIGHT}px); min-height: calc(${MIN_HEIGHT}px);`}
extraProps={{ 'data-prop-picker': true }}
closeOnOtherPopoverOpen
class="outline-none"
>
{#snippet trigger({ isOpen })}
<div
class={twMerge(
'bg-slate-200',
`w-[275px] h-[4px] flex flex-row items-center justify-center cursor-pointer`,
variant === 'virtual' ? virtualItemClasses.bar : defaultClasses.bar,
'shadow-[inset_0_1px_5px_0_rgba(0,0,0,0.05)] rounded-b-sm',
'group'
)}
onpointerdown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
data-prop-picker
title={`${isOpen ? 'Close' : 'Open'} step output`}
>
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 w-10 h-[14px]">
<AnimatedButton
animate={showConnecting}
wrapperClasses="relative w-full h-full center-center"
baseRadius="6px"
marginWidth="1px"
<!-- Invisible hover area to maintain consistent height -->
<div class="absolute w-full h-[20px]"></div>
<div
class={twMerge(
'bg-slate-200 absolute w-full',
variant === 'virtual'
? `${virtualItemClasses.bar} ${bottomBarOpen ? 'bg-slate-300 dark:bg-[#525d6f]' : ''}`
: `${defaultClasses.bar} ${bottomBarOpen ? 'bg-surface-hover dark:bg-[#576278]' : ''}`,
'shadow-[inset_0_1px_5px_0_rgba(0,0,0,0.05)] rounded-b-sm',
'group transition-all duration-100',
'flex flex-row items-center justify-center',
'h-1 hover:h-[20px]',
bottomBarOpen && 'h-[20px]'
)}
data-prop-picker
>
<div class="flex flex-row items-center justify-center w-full h-full">
{#if showInput}
<Popover
floatingConfig={{
placement: 'bottom',
gutter: 0,
offset: { mainAxis: 3, crossAxis: 69 },
overflowPadding: historyOpen ? 250 : 8
}}
usePointerDownOutside
closeOnOutsideClick={false}
on:click={(e) => {
e.preventDefault()
e.stopPropagation()
}}
allowFullScreen
contentClasses="overflow-hidden resize"
contentStyle={`width: calc(${MIN_WIDTH}px); min-width: calc(${MIN_WIDTH}px); height: calc(${MIN_HEIGHT}px); min-height: calc(${MIN_HEIGHT}px); `}
extraProps={{ 'data-prop-picker': true }}
closeOnOtherPopoverOpen
disableFocusTrap
class="flex-1 h-full"
bind:isOpen={inputOpen}
bind:this={inputPopover}
>
<div
class={twMerge(
'w-full h-full rounded-t-md shadow-[inset_0_1px_5px_0_rgba(0,0,0,0.05)]',
`hidden group-hover:center-center`,
variant === 'virtual' ? virtualItemClasses.handle : defaultClasses.handle,
isOpen || selected || hover || showConnecting ? 'center-center' : 'hidden',
showConnecting ? 'text-blue-500 bg-surface rounded-b-md' : 'text-secondary'
{#snippet trigger({ isOpen })}
<button
class={twMerge(
'h-full center-center transition-opacity duration-150 w-full',
bottomBarOpen ? 'opacity-100' : 'opacity-0',
'text-2xs font-normal w-full h-full border-t-2 border-transparent',
inputOpen ? 'border-primary' : 'hover:border-primary/20'
)}
>
In
</button>
{/snippet}
{#snippet content()}
<InputPickerInner {inputTransform} {id} {onEditInput} />
{/snippet}
</Popover>
{/if}
<Popover
floatingConfig={{
placement: 'bottom',
gutter: 0,
offset: { mainAxis: 3, crossAxis: showInput ? -69 : 0 },
overflowPadding: historyOpen ? 250 : 8
}}
usePointerDownOutside
closeOnOutsideClick={false}
on:click={(e) => {
e.preventDefault()
e.stopPropagation()
}}
bind:this={popover}
allowFullScreen
contentClasses="overflow-hidden resize"
contentStyle={`width: calc(${MIN_WIDTH}px); min-width: calc(${MIN_WIDTH}px); height: calc(${MIN_HEIGHT}px); min-height: calc(${MIN_HEIGHT}px); `}
extraProps={{ 'data-prop-picker': true }}
closeOnOtherPopoverOpen
class="flex-1 h-full"
bind:isOpen={outputOpen}
>
{#snippet trigger({ isOpen })}
<AnimatedButton
animate={showConnecting}
wrapperClasses={twMerge(
'h-full center-center transition-opacity duration-150 w-full',
bottomBarOpen ? 'opacity-100' : 'opacity-0'
)}
baseRadius="2px"
marginWidth="1px"
>
<ChevronDown
size={12}
class="h-fit transition-transform duration-100"
style={`transform: rotate(${isOpen ? '180deg' : '0deg'})`}
/>
</div>
</AnimatedButton>
</div>
<button
class={twMerge(
'text-2xs font-normal w-full h-full border-t-2 border-transparent',
outputOpen ? 'border-primary' : 'hover:border-primary/20',
showConnecting ? 'bg-surface-hover rounded-sm border-0' : ''
)}
>
{#if showInput}
Out
{:else if showConnecting}
<Plug size={12} class="w-full text-blue-500" />
{:else}
<ChevronDown size={12} class="w-full" />
{/if}
</button>
</AnimatedButton>
{/snippet}
{#snippet content()}
{@render children?.({
allowCopy: !$flowPropPickerConfig,
isConnecting: showConnecting,
selectConnection
})}
{/snippet}
</Popover>
</div>
{/snippet}
{#snippet content()}
{@render children?.({
allowCopy: !$flowPropPickerConfig,
isConnecting: showConnecting,
selectConnection
})}
{/snippet}
</Popover>
</div>
</div>
<style>
@keyframes moveGradient {
@@ -16,7 +16,7 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { Pin, History, Pen, Check, X, Loader2 } from 'lucide-svelte'
import { Pin, History, Pen, Check, X, Loader2, Pencil } from 'lucide-svelte'
import ObjectViewer from '$lib/components/propertyPicker/ObjectViewer.svelte'
import StepHistory from './StepHistory.svelte'
import { Popover } from '$lib/components/meltComponents'
@@ -62,6 +62,8 @@
copilot_fix?: import('svelte').Snippet
onSelect?: (key: string) => void
onUpdateMock?: (mock: { enabled: boolean; return_value?: unknown }) => void
onEditInput?: (moduleId: string, key: string) => void
selectionId?: string
}
let {
@@ -91,7 +93,9 @@
clazz,
copilot_fix,
onSelect,
onUpdateMock
onUpdateMock,
onEditInput,
selectionId
}: Props = $props()
type SelectedJob =
@@ -127,6 +131,8 @@
selectedJob = job
} else if (lastJob && 'result' in lastJob) {
selectedJob = lastJob
} else {
selectedJob = undefined
}
}
@@ -136,8 +142,9 @@
}
selectJob(lastJob)
if (lastJob.preview) {
if (lastJob.preview && mock?.enabled) {
preview = 'job'
lastJob.preview = false
}
})
@@ -589,6 +596,7 @@
onSelect?.(e.detail)
}}
{allowCopy}
{editKey}
/>
{:else if jsonView}
{#await import('$lib/components/JsonEditor.svelte')}
@@ -694,6 +702,15 @@
</div>
</div>
{#snippet editKey(key: string)}
<button
onclick={() => onEditInput?.(selectionId ?? '', key)}
class="h-4 w-fit items-center text-gray-300 dark:text-gray-500 hover:text-primary dark:hover:text-primary px-1 rounded-[0.275rem] align-baseline"
>
<Pencil size={12} class="-my-1 inline-flex items-center" />
</button>
{/snippet}
<style>
.dbl-click-editable {
cursor: text;
@@ -0,0 +1,183 @@
import type { FlowModule, OpenFlow } from '$lib/gen'
import type { FlowState } from './flowState'
import {
dfs,
getPreviousModule,
getStepPropPicker,
type PickableProperties
} from './previousResults'
import { evalValue, type ModuleArgs } from './utils'
export class TestSteps {
#stepsEvaluated = $state<Record<string, ModuleArgs>>({})
#steps = $state<Record<string, { value: any }>>({})
constructor() {}
setStepArgsManually(moduleId: string, args: Record<string, any>) {
if (!this.#steps[moduleId]) {
this.#steps[moduleId] = { value: {} }
}
this.#steps[moduleId].value = args
}
getStepArgs(moduleId: string): ModuleArgs | undefined {
return this.#steps[moduleId]
}
setStepArgs(moduleId: string, args: Record<string, any>) {
if (!this.#steps[moduleId]) {
this.#steps[moduleId] = { value: {} }
}
this.#steps[moduleId].value = args
}
getStepArg(moduleId: string, argName: string): any | undefined {
return this.#steps[moduleId]?.[argName]
}
setEvaluatedStepArg(moduleId: string, argName: string, value: any) {
if (!this.#steps[moduleId]) {
this.#steps[moduleId] = { value: {} }
}
if (!this.#stepsEvaluated[moduleId]) {
this.#stepsEvaluated[moduleId] = { value: {} }
}
this.#steps[moduleId].value[argName] = $state.snapshot(value)
this.#stepsEvaluated[moduleId].value[argName] = $state.snapshot(value)
}
isArgManuallySet(moduleId: string, argName: string): boolean {
return (
JSON.stringify(this.#steps[moduleId]?.value?.[argName]) !==
JSON.stringify(this.#stepsEvaluated[moduleId]?.value?.[argName])
)
}
getManuallyEditedArgs(moduleId: string): string[] {
const manuallyEditedArgs: string[] = []
const moduleArgs = this.#steps[moduleId]?.value ?? {}
Object.keys(moduleArgs).forEach((argName) => {
if (this.isArgManuallySet(moduleId, argName)) {
manuallyEditedArgs.push(argName)
}
})
return manuallyEditedArgs
}
/*
Evaluate the arg value from the flow state and replace the test value.
*/
evalArg(
moduleId: string,
argName: string,
flowState: FlowState | undefined,
flow: OpenFlow | undefined,
previewArgs: Record<string, any> | undefined
) {
if (!flowState || !flow) {
return
}
const modules = dfs(moduleId, flow, true)
const previousModule = getPreviousModule(moduleId, flow)
if (modules.length < 1) {
return
}
let parentModule: FlowModule | undefined = undefined
if (modules.length > 1) {
parentModule = modules[modules.length - 1]
}
const stepPropPicker = getStepPropPicker(
flowState,
parentModule,
previousModule,
moduleId,
flow,
previewArgs,
false
)
const pickableProperties = stepPropPicker.pickableProperties
const argSnapshot = $state.snapshot(evalValue(argName, modules[0], pickableProperties, false))
this.#stepsEvaluated[moduleId].value[argName] = argSnapshot
this.#steps[moduleId].value[argName] = structuredClone(argSnapshot)
}
initializeFromSchema(
mod: FlowModule,
schema: { properties?: Record<string, any> },
pickableProperties: PickableProperties | undefined
) {
const args = Object.fromEntries(
Object.keys(schema.properties ?? {}).map((k) => [
k,
evalValue(k, mod, pickableProperties, false)
])
)
const manuallyEditedArgs = this.getManuallyEditedArgs(mod.id)
if (!this.#steps[mod.id]) {
this.#steps[mod.id] = { value: {} }
}
if (!this.#stepsEvaluated[mod.id]) {
this.#stepsEvaluated[mod.id] = { value: {} }
}
this.#stepsEvaluated[mod.id].value = $state.snapshot(args)
// Preserve manually edited args
const argsSnapshot = $state.snapshot(args)
Object.keys(argsSnapshot).forEach((key) => {
if (manuallyEditedArgs.includes(key)) {
argsSnapshot[key] = this.#steps[mod.id]?.value?.[key]
}
})
this.#steps[mod.id].value = argsSnapshot
}
updateStepArgs(
id: string,
flowState: FlowState | undefined,
flow: OpenFlow | undefined,
previewArgs: Record<string, any> | undefined
) {
if (!flowState || !flow) {
return
}
const modules = dfs(id, flow, true)
const previousModule = getPreviousModule(id, flow)
if (modules.length < 1) {
return
}
let parentModule: FlowModule | undefined = undefined
if (modules.length > 1) {
parentModule = modules[modules.length - 1]
}
const stepPropPicker = getStepPropPicker(
flowState,
parentModule,
previousModule,
id,
flow,
previewArgs,
false
)
const pickableProperties = stepPropPicker.pickableProperties
this.initializeFromSchema(modules[0], flowState[id]?.schema ?? {}, pickableProperties)
}
removeExtraKey(moduleId: string, keys: string[]) {
if (!this.#stepsEvaluated[moduleId]) {
return
}
const nargs = {}
Object.keys(this.#stepsEvaluated[moduleId]?.value ?? {}).forEach((key) => {
if (keys.includes(key)) {
nargs[key] = this.#stepsEvaluated[moduleId]?.value?.[key]
}
})
this.#stepsEvaluated[moduleId].value = nargs
}
}
+2 -1
View File
@@ -7,6 +7,7 @@ import type { FlowBuilderWhitelabelCustomUi } from '../custom_ui'
import type Editor from '../Editor.svelte'
import type SimpleEditor from '../SimpleEditor.svelte'
import type { StateStore } from '$lib/utils'
import type { TestSteps } from './testSteps.svelte'
export type FlowInput = Record<
string,
@@ -69,7 +70,7 @@ export type FlowEditorContext = {
flowStore: StateStore<ExtendedOpenFlow>
flowInputEditorState: Writable<FlowInputEditorState>
flowStateStore: Writable<FlowState>
testStepStore: Writable<Record<string, any>>
testSteps: TestSteps
saveDraft: () => void
initialPathStore: Writable<string>
fakeInitialPath: string
+18 -21
View File
@@ -29,6 +29,8 @@ return ${eval_string}
}`
}
export type ModuleArgs = { value: Record<string, any> }
function make_context_evaluator(eval_string, context): (context) => any {
let template = create_context_function_template(eval_string, context)
let functor = Function(template)
@@ -38,34 +40,29 @@ function make_context_evaluator(eval_string, context): (context) => any {
export function evalValue(
k: string,
mod: FlowModule,
testStepStore: Record<string, any>,
pickableProperties: PickableProperties | undefined,
showError: boolean
) {
): any {
let inputTransforms = (mod.value['input_transforms'] ?? {}) as Record<string, InputTransform>
let v = testStepStore[mod.id]?.[k]
let v: any
let t = inputTransforms?.[k]
if (!v) {
if (t.type == 'static') {
v = t.value
} else {
try {
let context = {
flow_input: pickableProperties?.flow_input,
results: pickableProperties?.priorIds
}
v = make_context_evaluator(t.expr, context)(context)
} catch (e) {
if (showError) {
sendUserToast(`Error evaluating ${k}: ${e.message}`, true)
}
v = undefined
if (t.type == 'static') {
v = t.value
} else {
try {
let context = {
flow_input: pickableProperties?.flow_input,
results: pickableProperties?.priorIds
}
v = make_context_evaluator(t.expr, context)(context)
} catch (e) {
if (showError) {
sendUserToast(`Error evaluating ${k}: ${e.message}`, true)
}
v = undefined
}
}
if (v == NEVER_TESTED_THIS_FAR) {
return undefined
}
return v
}
@@ -107,7 +107,9 @@
onChangeId?: (detail: { id: string; newId: string; deps: Record<string, string[]> }) => void
onMove?: (id: string) => void
onUpdateMock?: (detail: { mock: FlowModule['mock']; id: string }) => void
onTestUpTo?: ((id: string) => void) | undefined
onSelectedIteration?: onSelectedIteration
onEditInput?: (moduleId: string, key: string) => void
}
let {
@@ -145,7 +147,9 @@
workspace = $workspaceStore ?? 'NO_WORKSPACE',
editMode = false,
allowSimplifiedPoll = true,
expandedSubflows = $bindable({})
expandedSubflows = $bindable({}),
onTestUpTo = undefined,
onEditInput = undefined
}: Props = $props()
setContext<{
@@ -301,6 +305,12 @@
},
updateMock: (detail) => {
onUpdateMock?.(detail)
},
testUpTo: (id: string) => {
onTestUpTo?.(id)
},
editInput: (moduleId: string, key: string) => {
onEditInput?.(moduleId, key)
}
}
@@ -52,6 +52,8 @@ export type GraphEventHandlers = {
expandSubflow: (id: string, path: string) => void
minimizeSubflow: (id: string) => void
updateMock: (detail: { mock: FlowModule['mock']; id: string }) => void
testUpTo: (id: string) => void
editInput: (moduleId: string, key: string) => void
}
export type SimplifiableFlow = { simplifiedFlow: boolean }
@@ -57,7 +57,7 @@
)
</script>
<EdgeLabel x={sourceX} y={sourceY + 22} class="base-edge" style="">
<EdgeLabel x={sourceX} y={sourceY + 28} class="base-edge" style="">
{#if data?.insertable && !$useDataflow && !data?.moving}
<div
class={twMerge('edgeButtonContainer nodrag nopan top-0')}
@@ -76,6 +76,7 @@
cache={data.cache}
earlyStop={data.earlyStop}
editMode={data.editMode}
onEditInput={data.eventHandlers.editInput}
/>
{/snippet}
</NodeWrapper>
@@ -89,9 +89,11 @@
onSelectedIteration={(e) => {
data.eventHandlers.selectedIteration(e)
}}
onTestUpTo={data.eventHandlers.testUpTo}
onUpdateMock={(detail) => {
data.eventHandlers.updateMock(detail)
}}
onEditInput={data.eventHandlers.editInput}
/>
<div class="absolute -bottom-10 left-1/2 transform -translate-x-1/2 z-10">
@@ -14,7 +14,7 @@
<NodeWrapper offset={data.offset} enableSourceHandle enableTargetHandle>
{#snippet children({ darkMode })}
<VirtualItem
label="No branches"
label={data.label ?? 'No branches'}
id={data.id}
hideId={true}
selectable={true}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { FlowStatusModule } from '$lib/gen'
export const NODE = {
width: 275,
height: 38,
height: 34,
gap: {
horizontal: 40,
vertical: 50
@@ -16,6 +16,7 @@
import { createEventDispatcher } from 'svelte'
import { Button } from '$lib/components/common'
import DocLink from '$lib/components/apps/editor/settingsPanel/DocLink.svelte'
import type { FloatingConfig } from '@melt-ui/svelte/internal/actions/floating'
export let closeButton: boolean = false
export let displayArrow: boolean = false
@@ -33,6 +34,7 @@
export let extraProps: Record<string, any> = {}
export let disabled: boolean = false
export let documentationLink: string | undefined = undefined
export let disableFocusTrap: boolean = false
let fullScreen = false
const dispatch = createEventDispatcher()
@@ -45,6 +47,7 @@
} = createPopover({
forceVisible: true,
portal,
disableFocusTrap,
onOpenChange: ({ curr, next }) => {
if (curr != next) {
dispatch('openChange', next)
@@ -94,6 +97,12 @@
return isOpen
}
export function updatePositioning(pos: FloatingConfig) {
if (positioning) {
$positioning = pos
}
}
async function getMenuElements(): Promise<HTMLElement[]> {
return Array.from(document.querySelectorAll('[data-popover]')) as HTMLElement[]
}
@@ -3,7 +3,7 @@
import { copyToClipboard, truncate } from '$lib/utils'
import { createEventDispatcher, untrack } from 'svelte'
import { createEventDispatcher, untrack, type Snippet } from 'svelte'
import { computeKey, keepByKeyOrValue } from './utils'
import { NEVER_TESTED_THIS_FAR } from '../flows/models'
import Portal from '$lib/components/Portal.svelte'
@@ -13,6 +13,7 @@
import { workspaceStore } from '$lib/stores'
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
import Popover from '../Popover.svelte'
import { twMerge } from 'tailwind-merge'
interface Props {
json: any
@@ -27,6 +28,8 @@
prefix?: string
expandedEvenOnLevel0?: string | undefined
connecting?: boolean
metaData?: Snippet<[any]>
editKey?: Snippet<[any]>
}
let {
@@ -41,7 +44,9 @@
collapseLevel = undefined,
prefix = '',
expandedEvenOnLevel0 = undefined,
connecting = false
connecting = false,
metaData,
editKey
}: Props = $props()
let jsonFiltered = $state(json)
@@ -248,11 +253,16 @@
color="light"
variant="border"
wrapperClasses="p-0 whitespace-nowrap w-fit"
btnClasses="font-mono h-4 py-1 text-2xs font-thin px-1 rounded-[0.275rem]"
btnClasses={twMerge(
'font-mono h-4 py-1 text-2xs',
'font-thin px-1 rounded-[0.275rem]',
metaData ? 'rounded-r-none border-r-0.5' : ''
)}
title={computeFullKey(key, rawKey)}
>
<span class={pureViewer ? 'cursor-auto' : ''}>{!isArray ? key : index} </span>
<span class={pureViewer ? 'cursor-auto' : ''}>{!isArray ? key : index}</span>
</Button>
{@render metaData?.(key)}
</AnimatedButton>
<span class="text-2xs -ml-0.5 text-tertiary">:</span>
@@ -273,6 +283,7 @@
{:else}
{@render renderScalar(key, jsonFiltered[key])}
{/if}
{@render editKey?.(key)}
</li>
{/each}
{#if keys.length > keyLimit}
@@ -522,6 +522,7 @@
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
contentClasses="p-4"
closeButton
usePointerDownOutside
>
{#snippet trigger()}
<Button color="dark" size="xs" nonCaptureEvent={true} startIcon={{ icon: Filter }}>
@@ -74,7 +74,7 @@
})
let valueEntry = $derived(
value.map((v) => processedItems.find((item) => item.value === v)!).filter(Boolean)
value.map((v) => processedItems.find((item) => item.value === v) ?? { value: v, label: v })
)
function onAddValue(item: ProcessedItem<Value>) {
@@ -4,7 +4,7 @@
import CloseButton from '../common/CloseButton.svelte'
import { Loader2 } from 'lucide-svelte'
import { untrack } from 'svelte'
import { processItems, type ProcessedItem } from './utils.svelte'
import { getLabel, processItems, type ProcessedItem } from './utils.svelte'
import SelectDropdown from './SelectDropdown.svelte'
import { deepEqual } from 'fast-equals'
@@ -14,7 +14,7 @@
items,
placeholder = 'Please select',
value = $bindable(),
filterText: _filterTextBind = $bindable(undefined),
filterText = $bindable(''),
class: className = '',
clearable = false,
listAutoWidth = true,
@@ -27,6 +27,7 @@
RightIcon,
createText,
noItemsMsg,
open = $bindable(false),
groupBy,
sortBy,
onFocus,
@@ -50,6 +51,7 @@
RightIcon?: any
createText?: string
noItemsMsg?: string
open?: boolean
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onFocus?: () => void
@@ -60,8 +62,6 @@
let disabled = $derived(_disabled || loading)
let filterText = $state<string>('')
let open = $state<boolean>(false)
let inputEl: HTMLInputElement | undefined = $state()
let processedItems: ProcessedItem<Value>[] = $derived.by(() => {
@@ -69,13 +69,6 @@
return untrack(() => processItems(args))
})
$effect(() => {
if (_filterTextBind !== undefined) filterText = _filterTextBind
})
$effect(() => {
if (_filterTextBind !== undefined) _filterTextBind = filterText
})
$effect(() => {
if (filterText) open = true
})
@@ -128,12 +121,12 @@
{disabled}
type="text"
bind:value={() => filterText, (v) => (filterText = v)}
placeholder={loading ? 'Loading...' : (valueEntry?.label ?? placeholder)}
placeholder={loading ? 'Loading...' : (valueEntry?.label ?? getLabel({ value }) ?? placeholder)}
style={containerStyle}
class={twMerge(
'!bg-surface text-ellipsis',
open ? '' : 'cursor-pointer',
valueEntry && !loading ? '!placeholder-primary' : '',
value && !loading ? '!placeholder-primary' : '',
(clearable || RightIcon) && !disabled && value ? '!pr-8' : '',
inputClass ?? ''
)}
@@ -137,7 +137,7 @@
onSelectValue(item)
}}
>
{item.label}
{item.label || '\xa0'}
</button>
</li>
{/each}
+42 -27
View File
@@ -494,36 +494,51 @@ export function filterDraftTriggers(
triggersState: Triggers
): FlowWithDraftAndDraftTriggers | NewScriptWithDraftAndDraftTriggers {
const deployedTriggers = triggersState.triggers.filter((t) => !t.draftConfig && !t.isDraft)
let newSavedValue = savedValue
const filterMatchingTriggers = (savedTriggers: Trigger[], deployedTriggers: Trigger[]) => {
return savedTriggers.filter(
(savedTrigger) =>
!deployedTriggers.some(
(deployedTrigger) =>
deployedTrigger.path === savedTrigger.draftConfig?.path &&
deployedTrigger.type === savedTrigger.type
)
)
// Early return if no deployed triggers or no draft triggers to filter
if (deployedTriggers.length === 0 || !savedValue?.draft?.draft_triggers?.length) {
return savedValue
}
const savedDraftTriggersFiltered = filterMatchingTriggers(
newSavedValue?.draft?.draft_triggers ?? [],
deployedTriggers
const deployedTriggerKeys = new Set(deployedTriggers.map((t) => `${t.path}:${t.type}`))
const originalSavedDraftTriggers = savedValue.draft.draft_triggers
const keptTriggers: Trigger[] = []
const removedTriggers: Trigger[] = []
// Single pass to separate kept vs removed triggers
for (const savedTrigger of originalSavedDraftTriggers) {
const triggerKey = `${savedTrigger.draftConfig?.path}:${savedTrigger.type}`
if (deployedTriggerKeys.has(triggerKey)) {
removedTriggers.push(savedTrigger)
} else {
keptTriggers.push(savedTrigger)
}
}
// Early return if nothing was filtered
if (removedTriggers.length === 0) {
return savedValue
}
// Update saved value
const newSavedValue = {
...savedValue,
draft: {
...savedValue.draft,
draft_triggers: keptTriggers.length > 0 ? keptTriggers : undefined
}
} as typeof savedValue
const removedTriggerKeys = new Set(removedTriggers.map((t) => `${t.draftConfig?.path}:${t.type}`))
// Remove filtered triggers from triggersState
triggersState.setTriggers(
triggersState.triggers.filter((trigger) => {
const triggerKey = `${trigger.draftConfig?.path}:${trigger.type}`
return !removedTriggerKeys.has(triggerKey)
})
)
if (newSavedValue?.draft?.draft_triggers) {
newSavedValue = {
...newSavedValue,
draft: {
...newSavedValue.draft,
draft_triggers:
savedDraftTriggersFiltered.length > 0 ? savedDraftTriggersFiltered : undefined
}
} as typeof newSavedValue
}
triggersState.setTriggers([
...triggersState.triggers.filter((t) => !t.draftConfig),
...savedDraftTriggersFiltered
])
return newSavedValue
}
@@ -1,16 +1,14 @@
<script lang="ts">
import { getContext, onMount } from 'svelte'
import SelectLegacy from '../apps/svelte-select/lib/SelectLegacy.svelte'
import { SELECT_INPUT_DEFAULT_STYLE } from '$lib/defaults'
import DarkModeObserver from '../DarkModeObserver.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import { AppService, type ListableApp } from '$lib/gen'
import { canWrite } from '$lib/utils'
import type { AppViewerContext } from '../apps/types'
import Alert from '../common/alert/Alert.svelte'
import Select from '../select/Select.svelte'
export let value = ''
export let selecteValue = value
let darkMode = false
const { appPath } = getContext<AppViewerContext>('AppViewerContext')
@@ -33,38 +31,23 @@
onMount(() => {
loadApps()
if (selecteValue === '') {
selecteValue = $appPath
value = $appPath
}
if (value === '') value = $appPath
})
</script>
<DarkModeObserver bind:darkMode />
<div class="flex flex-col gap-2 w-full">
<SelectLegacy
class="grow shrink max-w-full"
on:change={(e) => {
value = e.detail.value
}}
on:clear={() => {
value = ''
}}
bind:value={selecteValue}
items={apps.map((app) => {
return {
value: app.path,
label: app.path === $appPath ? `${app.path} (current app)` : app.path
}
})}
<Select
clearable
onClear={() => (value = '')}
bind:value
items={apps.map((app) => ({
value: app.path,
label: app.path === $appPath ? `${app.path} (current app)` : app.path
}))}
placeholder="Pick an app"
inputStyles={SELECT_INPUT_DEFAULT_STYLE.inputStyles}
containerStyles={darkMode
? SELECT_INPUT_DEFAULT_STYLE.containerStylesDark
: SELECT_INPUT_DEFAULT_STYLE.containerStyles}
portal={false}
disablePortal
/>
{#if !appPath}
<Alert title="Current app not selectable" size="xs" type="warning" collapsible>
-1
View File
@@ -438,7 +438,6 @@ interface Process {
WM_STATE_PATH: string;
WM_FLOW_STEP_ID: string;
WM_OBJECT_PATH: string;
WM_OIDC_JWT: string;
WM_WORKER_GROUP: string;
}
}
-1
View File
@@ -174,7 +174,6 @@ declare module 'process' {
WM_STATE_PATH: string
WM_FLOW_STEP_ID: string
WM_OBJECT_PATH: string
WM_OIDC_JWT: string
WM_WORKER_GROUP: string
}
interface HRTime {
+8 -2
View File
@@ -1378,7 +1378,11 @@ export type StateStore<T> = {
val: T
}
export function readFieldsRecursively(obj: any): void {
export type ReadFieldsRecursivelyOptions = {
excludeField?: string[]
}
export function readFieldsRecursively(obj: any, options: ReadFieldsRecursivelyOptions = {}): void {
if (Array.isArray(obj)) {
// <= in case a new object is added. should read as undefined
for (let i = 0; i <= obj.length; i++) {
@@ -1387,7 +1391,9 @@ export function readFieldsRecursively(obj: any): void {
}
}
} else if (obj !== null && typeof obj === 'object') {
Object.keys(obj).forEach((key) => readFieldsRecursively(obj[key]))
Object.keys(obj).forEach((key) => {
if (!options.excludeField?.includes(key)) readFieldsRecursively(obj[key], options)
})
}
}
@@ -1265,9 +1265,7 @@
<ResourcePicker
resourceType="git_repository"
initialValue={gitSyncRepository.git_repo_resource_path}
on:change={(ev) => {
gitSyncRepository.git_repo_resource_path = ev.detail
}}
bind:value={gitSyncRepository.git_repo_resource_path}
/>
<Button
disabled={emptyString(gitSyncRepository.script_path)}
+4 -3
View File
@@ -24,6 +24,7 @@
import type { FlowPropPickerConfig, PropPickerContext } from '$lib/components/prop_picker'
import type { PickableProperties } from '$lib/components/flows/previousResults'
import { Triggers } from '$lib/components/triggers/triggers.svelte'
import { TestSteps } from '$lib/components/flows/testSteps.svelte'
let token = $page.url.searchParams.get('wm_token') ?? undefined
let workspace = $page.url.searchParams.get('workspace') ?? undefined
@@ -73,7 +74,7 @@
const moving = writable<{ id: string } | undefined>(undefined)
const history = initHistory(flowStore.val)
const testStepStore = writable<Record<string, any>>({})
const testSteps = new TestSteps()
const selectedIdStore = writable('settings-metadata')
const triggersCount = writable<TriggersCount | undefined>(undefined)
setContext<TriggerContext>('TriggerContext', {
@@ -92,7 +93,7 @@
pathStore: writable(''),
flowStateStore,
flowStore,
testStepStore,
testSteps,
saveDraft: () => {},
initialPathStore: writable(''),
fakeInitialPath: '',
@@ -288,7 +289,7 @@
noEditor
on:applyArgs={(ev) => {
if (ev.detail.kind === 'preprocessor') {
$testStepStore['preprocessor'] = ev.detail.args ?? {}
testSteps.setStepArgs('preprocessor', ev.detail.args ?? {})
$selectedIdStore = 'preprocessor'
} else {
previewArgsStore.val = ev.detail.args ?? {}
+2 -2
View File
@@ -4,8 +4,8 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.499.0"
wmill_pg = ">=1.499.0"
wmill = ">=1.500.2"
wmill_pg = ">=1.500.2"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.499.0
version: 1.500.2
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.499.0'
ModuleVersion = '1.500.2'
# Supported PSEditions
# CompatiblePSEditions = @()

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