Merge branch 'main' into feat/asset-graph-view

This commit is contained in:
Ruben Fiszel
2026-04-30 17:18:28 +00:00
90 changed files with 4069 additions and 498 deletions
@@ -145,6 +145,10 @@ jobs:
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
# Tests' poll-time stack frames (deep nested async fn chains in
# debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky
# overflows under parallel-test contention.
RUST_MIN_STACK: 4194304
VCPKGRS_DYNAMIC: 1
OPENSSL_DIR: ${{ env.VCPKG_INSTALLATION_ROOT }}\installed\x64-windows-static
DENO_PATH: ${{ steps.runtime-paths.outputs.DENO_PATH }}
+5
View File
@@ -244,6 +244,11 @@ jobs:
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
# Tests' poll-time stack frames (deep nested async fn chains in
# debug builds) reach ~1.8MB, leaving very thin headroom on the
# default 2MB thread stack. 4MB gives ~2x buffer against flaky
# overflows under parallel-test contention.
RUST_MIN_STACK: 4194304
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [1.693.3](https://github.com/windmill-labs/windmill/compare/v1.693.2...v1.693.3) (2026-04-30)
### Bug Fixes
* avoid named prepared statements in datatable/PG executor ([#8988](https://github.com/windmill-labs/windmill/issues/8988)) ([7bdfc0e](https://github.com/windmill-labs/windmill/commit/7bdfc0ea0636a93a893ab5f9f58a590e6a0b7be8))
* improve raw app builder queue behavior for bigger apps ([e7deaf9](https://github.com/windmill-labs/windmill/commit/e7deaf988254eebbf99febc8a644622c9f809bd8))
* sanitize underscores in agent worker suffix ([#8992](https://github.com/windmill-labs/windmill/issues/8992)) ([568d9cc](https://github.com/windmill-labs/windmill/commit/568d9cc8a086178314e15e3c27fb55ff4817f5cd))
* **workspaces:** split get_settings into admin-only + public endpoint ([#8990](https://github.com/windmill-labs/windmill/issues/8990)) ([4483d0c](https://github.com/windmill-labs/windmill/commit/4483d0cab91e8a9df026b09c7908291f3fbbfd63))
## [1.693.2](https://github.com/windmill-labs/windmill/compare/v1.693.1...v1.693.2) (2026-04-30)
### Bug Fixes
* avoid effect_update_depth_exceeded when clicking flow node on runs page ([#8986](https://github.com/windmill-labs/windmill/issues/8986)) ([3ebfc2b](https://github.com/windmill-labs/windmill/commit/3ebfc2b0af38f7eb17774de08a214d7813e07952))
* OAuth popup login reliability + auto-login Safari edge cases ([#8971](https://github.com/windmill-labs/windmill/issues/8971)) ([3c3c034](https://github.com/windmill-labs/windmill/commit/3c3c03455d68fde982937787992e20c3f8eeeaaf))
## [1.693.1](https://github.com/windmill-labs/windmill/compare/v1.693.0...v1.693.1) (2026-04-29)
### Bug Fixes
* include labels when loading flow with draft for editing ([#8981](https://github.com/windmill-labs/windmill/issues/8981)) ([485d1d1](https://github.com/windmill-labs/windmill/commit/485d1d1e3785b5ed7e5f1a5ee127c0fed15fba3e)), closes [#8963](https://github.com/windmill-labs/windmill/issues/8963)
## [1.693.0](https://github.com/windmill-labs/windmill/compare/v1.692.0...v1.693.0) (2026-04-29)
### Features
* add ai chat schedule and trigger tools ([#8961](https://github.com/windmill-labs/windmill/issues/8961)) ([b883f9a](https://github.com/windmill-labs/windmill/commit/b883f9a9d2e38a5981860de268fa6227cdd645de))
* add delete_after_secs and sensitive_inputs for raw app runnables ([#8975](https://github.com/windmill-labs/windmill/issues/8975)) ([1169d9b](https://github.com/windmill-labs/windmill/commit/1169d9bfd315e43194f9e5a2bc55af843476ef68))
* add min release age instance settings for bun and uv ([#8956](https://github.com/windmill-labs/windmill/issues/8956)) ([1d279e7](https://github.com/windmill-labs/windmill/commit/1d279e7a1e77fd183bb0c99d2430cfd9dc0a617c))
* edit scopes on existing API tokens ([#8967](https://github.com/windmill-labs/windmill/issues/8967)) ([e9e72fb](https://github.com/windmill-labs/windmill/commit/e9e72fbbf83363ba1426b3dde3d657de02ba50b3))
* OTEL span status on failed jobs + Python stderr severity classification ([#8918](https://github.com/windmill-labs/windmill/issues/8918)) ([cec8484](https://github.com/windmill-labs/windmill/commit/cec84849b9aea92d355dd346546969027c603498))
* support restart from steps inside BranchOne, ForLoop, Subflow ([#8955](https://github.com/windmill-labs/windmill/issues/8955)) ([c956428](https://github.com/windmill-labs/windmill/commit/c95642863e366c106d529a57540a75df9480397c))
* support S3Object input args in native SQL scripts ([#8954](https://github.com/windmill-labs/windmill/issues/8954)) ([c0eeea9](https://github.com/windmill-labs/windmill/commit/c0eeea9c833f9be3981389a19d0964400fd2bda8))
* workspace-shared ui/ folder reusable across raw apps ([#8974](https://github.com/windmill-labs/windmill/issues/8974)) ([de0b6b1](https://github.com/windmill-labs/windmill/commit/de0b6b15285612ef00b94e332dcb203aed88f2cc))
### Bug Fixes
* **cli:** debounce wmill dev flow round-trip 200ms ([#8977](https://github.com/windmill-labs/windmill/issues/8977)) ([5861dca](https://github.com/windmill-labs/windmill/commit/5861dcad589df99f57e730d8d9cdb3eabc3424e4))
* prevent React app editor from overwriting files on theme switch ([#8965](https://github.com/windmill-labs/windmill/issues/8965)) ([70b90c4](https://github.com/windmill-labs/windmill/commit/70b90c41dc28d1a850bce69836cde920c1404c3b))
* show skipped label on flow progress bar ([#8973](https://github.com/windmill-labs/windmill/issues/8973)) ([8627d3c](https://github.com/windmill-labs/windmill/commit/8627d3c5aeabfac12a9f06211f8e7db020e758f9))
* split flow prompts for frontend chat ([#8968](https://github.com/windmill-labs/windmill/issues/8968)) ([4098793](https://github.com/windmill-labs/windmill/commit/4098793db22249c5b4467c2adb72131407f5d6d3))
* strip additionalProperties from google schemas ([#8964](https://github.com/windmill-labs/windmill/issues/8964)) ([77d9a53](https://github.com/windmill-labs/windmill/commit/77d9a534235a1ff8cbfcdb037a65735db987e2fe))
### Performance Improvements
* optimize datatable app chat schemas ([#8960](https://github.com/windmill-labs/windmill/issues/8960)) ([34b549c](https://github.com/windmill-labs/windmill/commit/34b549cfe2e2e060561eabe369a99cfb4d9c9568))
## [1.692.0](https://github.com/windmill-labs/windmill/compare/v1.691.1...v1.692.0) (2026-04-27)
+64
View File
@@ -129,6 +129,70 @@
- does not duplicate the greeting logic in a new inline script
- wires the name input into the reused script
- id: wac-typescript-order-workflow
prompt: |-
Create a Windmill Workflow-as-Code TypeScript script at `f/evals/order_workflow.ts`.
It should take an `orderId` string, load the order in a durable task, checkpoint a processing timestamp with `step`, and return `{ orderId, processedAt, status }`.
cliExpect:
requiredSkills:
- write-workflow-as-code
requiredSkillsBeforeFirstMutation:
- write-workflow-as-code
forbiddenSkills:
- write-flow
- write-script-bun
- write-script-python3
judgeChecklist:
- creates the requested TypeScript WAC script at f/evals/order_workflow.ts
- uses the Workflow-as-Code SDK from windmill-client
- wraps the entrypoint with workflow
- uses a durable task for loading the order
- uses step to checkpoint the processing timestamp
- does not create an OpenFlow flow.yaml or flow folder
- id: wac-python-approval-workflow
prompt: |-
Create a Windmill Workflow-as-Code Python script at `f/evals/approval_workflow.py`.
It should take a `request_id` string, prepare an approval summary in a task, create resume URLs inside a durable step, wait for approval, and return the approval result.
cliExpect:
requiredSkills:
- write-workflow-as-code
requiredSkillsBeforeFirstMutation:
- write-workflow-as-code
forbiddenSkills:
- write-flow
- write-script-bun
- write-script-python3
judgeChecklist:
- creates the requested Python WAC script at f/evals/approval_workflow.py
- imports Workflow-as-Code helpers from wmill
- decorates an async entrypoint with @workflow
- uses @task for the approval summary work
- gets resume URLs inside step before waiting for approval
- uses wait_for_approval
- does not create an OpenFlow flow.yaml or flow folder
- id: wac-not-openflow-disambiguation
prompt: |-
Create this as Workflow-as-Code, not an OpenFlow YAML flow: a TypeScript script at `f/evals/fanout_workflow.ts`.
It should take an array of customer IDs, process each customer with a WAC task, run the independent customer tasks in parallel, and return the collected results.
cliExpect:
requiredSkills:
- write-workflow-as-code
requiredSkillsBeforeFirstMutation:
- write-workflow-as-code
forbiddenSkills:
- write-flow
- write-script-bun
- write-script-python3
judgeChecklist:
- creates the requested TypeScript script at f/evals/fanout_workflow.ts
- treats the request as Workflow-as-Code rather than an OpenFlow flow
- uses workflow for the script entrypoint
- uses task for each customer processing unit
- runs independent customer tasks in parallel
- does not create a flow folder or flow.yaml
- id: cli-job-debug-guidance
prompt: |-
A Windmill job failed.
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3",
"query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
@@ -32,6 +32,11 @@
"ordinal": 5,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 6,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
@@ -47,8 +52,9 @@
true,
false,
false,
true,
true
]
},
"hash": "1bf4a93cb85c6eed313a2f393da9408dd2aa4e47ef7a38a0d3ccca944a09f5bb"
"hash": "2b5fc0500beb2f4c7cf5997f9aea48f77e2abe4523180c507a9a90570127be6d"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3",
"query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
@@ -32,6 +32,11 @@
"ordinal": 5,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 6,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
@@ -47,8 +52,9 @@
true,
false,
false,
true,
true
]
},
"hash": "ebc2eed287f93e184ed683feb20432caa6e6682620c90f38b29dd32b9a8fe633"
"hash": "40f0bc9a2555a7c90b3985a190bf3fce18c09693b645f2ac520de6936366f3c8"
}
@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "slack_team_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "slack_name",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "teams_team_id",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "teams_team_name",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "teams_team_guid",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "mute_critical_alerts",
"type_info": "Bool"
},
{
"ordinal": 7,
"name": "deploy_ui",
"type_info": "Jsonb"
},
{
"ordinal": 8,
"name": "large_file_storage",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "datatable",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
true,
true,
true,
true,
true,
true,
true,
true
]
},
"hash": "71eeda25c59d724d6e0c4b2b52078567d6e477a2c62656de1deaef602221edcf"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE token SET scopes = $1\n WHERE email = $2 AND token_prefix = $3\n RETURNING token_prefix",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token_prefix",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"TextArray",
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "a7a20412e303568b271f949642de55e9880ef05786fe59f05de5e025ef315726"
}
+80 -80
View File
@@ -13260,9 +13260,9 @@ dependencies = [
[[package]]
name = "sse-stream"
version = "0.2.2"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c5e6deb40826033bd7b11c7ef25ef71193fabd71f680f40dd16538a2704d2f4"
checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72"
dependencies = [
"bytes",
"futures-util",
@@ -16020,7 +16020,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-nats",
@@ -16101,7 +16101,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"async-trait",
"aws-config",
@@ -16125,7 +16125,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16138,7 +16138,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"argon2",
@@ -16281,7 +16281,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16304,7 +16304,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16317,7 +16317,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16343,7 +16343,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16353,7 +16353,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16370,7 +16370,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"base64 0.22.1",
@@ -16392,7 +16392,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16415,7 +16415,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16431,7 +16431,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16452,7 +16452,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16473,7 +16473,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16487,7 +16487,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-nats",
@@ -16519,7 +16519,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16544,7 +16544,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"flate2",
@@ -16562,7 +16562,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16584,7 +16584,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16604,7 +16604,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16634,7 +16634,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16662,7 +16662,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"lazy_static",
"serde",
@@ -16674,7 +16674,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"argon2",
"axum 0.8.4",
@@ -16699,7 +16699,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16713,7 +16713,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"axum 0.8.4",
"chrono",
@@ -16746,7 +16746,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"chrono",
"lazy_static",
@@ -16760,7 +16760,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"axum 0.8.4",
@@ -16779,7 +16779,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -16880,7 +16880,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16899,7 +16899,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"regex",
"serde",
@@ -16914,7 +16914,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16938,7 +16938,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"futures",
@@ -16955,7 +16955,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16971,7 +16971,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -16992,7 +16992,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17023,7 +17023,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"arc-swap",
@@ -17048,7 +17048,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-stream",
@@ -17082,7 +17082,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"futures",
@@ -17100,7 +17100,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -17109,7 +17109,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -17121,7 +17121,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde_json",
@@ -17133,7 +17133,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"gosyn",
@@ -17145,7 +17145,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -17157,7 +17157,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde_json",
@@ -17169,7 +17169,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"nu-parser",
@@ -17180,7 +17180,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -17191,7 +17191,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -17203,7 +17203,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -17214,7 +17214,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -17236,7 +17236,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde_json",
@@ -17248,7 +17248,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -17262,7 +17262,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -17279,7 +17279,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -17292,7 +17292,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde",
@@ -17304,7 +17304,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -17322,7 +17322,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -17338,7 +17338,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -17354,7 +17354,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde",
@@ -17365,7 +17365,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -17402,7 +17402,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"const_format",
@@ -17440,7 +17440,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17451,7 +17451,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -17481,7 +17481,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17505,7 +17505,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17538,7 +17538,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17571,7 +17571,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17591,7 +17591,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17625,7 +17625,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17661,7 +17661,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17684,7 +17684,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17708,7 +17708,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-nats",
@@ -17732,7 +17732,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17767,7 +17767,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17795,7 +17795,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-trait",
@@ -17818,7 +17818,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17837,7 +17837,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17949,7 +17949,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.692.0"
version = "1.693.3"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.692.0"
version = "1.693.3"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
ea10ff498681bd9100a5f4be2fa1e47986fd5b80
1790e8df54bb00fb6800b8f3a6a2bb4c9787380e
@@ -0,0 +1,2 @@
DROP TRIGGER IF EXISTS token_scopes_update_trigger ON token;
DROP FUNCTION IF EXISTS notify_token_scopes_change();
@@ -0,0 +1,18 @@
-- Invalidate auth cache (across instances) when token scopes change.
-- Reuses the existing notify_token_invalidation channel handled in main.rs.
CREATE OR REPLACE FUNCTION notify_token_scopes_change()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.scopes IS DISTINCT FROM NEW.scopes THEN
INSERT INTO notify_event (channel, payload)
VALUES ('notify_token_invalidation', NEW.token_prefix);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER token_scopes_update_trigger
AFTER UPDATE OF scopes ON token
FOR EACH ROW
EXECUTE FUNCTION notify_token_scopes_change();
+24 -24
View File
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.692.0"
version = "1.693.3"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.692.0"
version = "1.693.3"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+35 -26
View File
@@ -985,33 +985,42 @@ async def main(item: str, qty: int, email: str):
"#
.to_string();
// WAC requires at least 2 workers (parent + task sub-jobs)
let db = &db;
in_test_worker(
db,
async move {
let job = Box::pin(
RunJob::from(JobPayload::Code(RawCode {
language: ScriptLang::Python3,
content,
..RawCode::default()
}))
.arg("item", json!("widget"))
.arg("qty", json!(5))
.arg("email", json!("test@example.com"))
.run_until_complete(db, false, port),
)
.await;
// WAC requires at least 2 workers (parent + task sub-jobs).
//
// Run the heavy `in_test_worker` chain on an isolated OS thread with a
// larger stack: the deep nested async chain (test -> in_test_worker ->
// run_until_complete -> windmill_queue::push -> ...) composes into one
// synchronous poll-stack frame that exceeds the default 2 MB test-thread
// stack in debug builds. `Box::pin` at the call site only moves future
// *state* to the heap; it can't shrink poll-time stack frames.
let db = db.clone();
run_in_isolated_thread(move || async move {
in_test_worker(
&db,
async {
let job = Box::pin(
RunJob::from(JobPayload::Code(RawCode {
language: ScriptLang::Python3,
content,
..RawCode::default()
}))
.arg("item", json!("widget"))
.arg("qty", json!(5))
.arg("email", json!("test@example.com"))
.run_until_complete(&db, false, port),
)
.await;
let result = job.json_result().unwrap();
assert_eq!(result["item"], json!("widget"));
assert_eq!(result["qty"], json!(5));
assert_eq!(result["email"], json!("test@example.com"));
assert_eq!(result["greeting"], json!("hello widget x5"));
},
port,
)
.await;
let result = job.json_result().unwrap();
assert_eq!(result["item"], json!("widget"));
assert_eq!(result["qty"], json!(5));
assert_eq!(result["email"], json!("test@example.com"));
assert_eq!(result["greeting"], json!("hello widget x5"));
},
port,
)
.await;
});
Ok(())
}
+4 -1
View File
@@ -1492,6 +1492,8 @@ pub struct FlowWDraft {
pub visible_to_runner_only: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub on_behalf_of_email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<String>>,
}
async fn get_flow_by_path_w_draft(
@@ -1516,7 +1518,8 @@ async fn get_flow_by_path_w_draft(
draft.value AS draft,
flow.tag,
flow.visible_to_runner_only,
flow.on_behalf_of_email
flow.on_behalf_of_email,
flow.labels
FROM flow
LEFT JOIN draft
ON flow.path = draft.path
@@ -44,21 +44,17 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(email, "test@windmill.dev");
// --- exists_email ---
let resp = authed(client().get(format!(
"{global_base}/exists/test@windmill.dev"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/exists/test@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<bool>().await?, true);
let resp = authed(client().get(format!(
"{global_base}/exists/nonexistent@windmill.dev"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/exists/nonexistent@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
assert_eq!(resp.json::<bool>().await?, false);
@@ -89,20 +85,51 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let new_token = resp.text().await?;
assert!(!new_token.is_empty());
// --- tokens/delete ---
let token_prefix = &new_token[..std::cmp::min(new_token.len(), 10)];
let resp = authed(client().delete(format!(
"{global_base}/tokens/delete/{token_prefix}"
)))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"delete token: {}",
resp.text().await?
);
// --- tokens/update_scopes (set explicit scopes) ---
let resp = authed(client().post(format!("{global_base}/tokens/update_scopes/{token_prefix}")))
.json(&json!({"scopes": ["jobs:run:scripts"]}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "update_scopes: {}", resp.text().await?);
// Verify via tokens/list that scopes were applied.
let resp = authed(client().get(format!("{global_base}/tokens/list")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let tokens = resp.json::<Vec<serde_json::Value>>().await?;
let updated = tokens
.iter()
.find(|t| t["token_prefix"] == *token_prefix)
.expect("token in list");
assert_eq!(updated["scopes"], json!(["jobs:run:scripts"]));
// --- tokens/update_scopes (clear scopes via null = full access) ---
let resp = authed(client().post(format!("{global_base}/tokens/update_scopes/{token_prefix}")))
.json(&json!({"scopes": null}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// --- tokens/update_scopes on nonexistent prefix returns 404 ---
let resp = authed(client().post(format!("{global_base}/tokens/update_scopes/zzznotreal")))
.json(&json!({"scopes": ["jobs:run:scripts"]}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 404);
// --- tokens/delete ---
let resp = authed(client().delete(format!("{global_base}/tokens/delete/{token_prefix}")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "delete token: {}", resp.text().await?);
// --- list_invites ---
let resp = authed(client().get(format!("{global_base}/list_invites")))
@@ -113,12 +140,10 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
resp.json::<Vec<serde_json::Value>>().await?;
// --- username_info ---
let resp = authed(client().get(format!(
"{global_base}/username_info/test@windmill.dev"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/username_info/test@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["username"], "test-user");
@@ -158,13 +183,11 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(body["progress"], 42);
// --- global update user ---
let resp = authed(client().post(format!(
"{global_base}/update/test2@windmill.dev"
)))
.json(&json!({"name": "Updated Test User 2"}))
.send()
.await
.unwrap();
let resp = authed(client().post(format!("{global_base}/update/test2@windmill.dev")))
.json(&json!({"name": "Updated Test User 2"}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
@@ -218,7 +241,9 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
// --- auth: is_first_time_setup (unauthed) ---
let resp = client()
.get(format!("http://localhost:{port}/api/auth/is_first_time_setup"))
.get(format!(
"http://localhost:{port}/api/auth/is_first_time_setup"
))
.send()
.await
.unwrap();
@@ -228,7 +253,9 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
// --- auth: is_smtp_configured (unauthed) ---
let resp = client()
.get(format!("http://localhost:{port}/api/auth/is_smtp_configured"))
.get(format!(
"http://localhost:{port}/api/auth/is_smtp_configured"
))
.send()
.await
.unwrap();
@@ -255,27 +282,20 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
if create_status == 201 {
// --- rename user (only if create succeeded / EE) ---
let resp = authed(client().post(format!(
"{global_base}/rename/newglobaluser@windmill.dev"
)))
.json(&json!({"new_username": "renamed_user"}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"rename user: {}",
resp.text().await?
);
let resp =
authed(client().post(format!("{global_base}/rename/newglobaluser@windmill.dev")))
.json(&json!({"new_username": "renamed_user"}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "rename user: {}", resp.text().await?);
// --- global delete user ---
let resp = authed(client().delete(format!(
"{global_base}/delete/newglobaluser@windmill.dev"
)))
.send()
.await
.unwrap();
let resp =
authed(client().delete(format!("{global_base}/delete/newglobaluser@windmill.dev")))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
@@ -462,12 +482,7 @@ async fn test_user_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"delete user: {}",
resp.text().await?
);
assert_eq!(resp.status(), 200, "delete user: {}", resp.text().await?);
// verify deleted
let resp = authed(client().get(format!("{base}/list_usernames")))
+56 -2
View File
@@ -135,6 +135,10 @@ pub fn global_service() -> Router {
.route("/username_info/{user}", get(get_instance_username_info))
.route("/tokens/create", post(create_token))
.route("/tokens/delete/{token_prefix}", delete(delete_token))
.route(
"/tokens/update_scopes/{token_prefix}",
post(update_token_scopes),
)
.route("/tokens/list", get(list_tokens))
.route("/tokens/impersonate", post(impersonate))
.route("/usage", get(get_usage))
@@ -292,6 +296,7 @@ pub struct TruncatedToken {
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_at: chrono::DateTime<chrono::Utc>,
pub scopes: Option<Vec<String>>,
pub workspace_id: Option<String>,
}
// NewToken is re-exported from windmill-api-auth above
@@ -2243,7 +2248,7 @@ async fn list_tokens(
sqlx::query_as!(
TruncatedToken,
"SELECT label, token_prefix, expiration, created_at, \
last_used_at, scopes FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)
last_used_at, scopes, workspace_id FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
email,
per_page as i64,
@@ -2255,7 +2260,7 @@ async fn list_tokens(
sqlx::query_as!(
TruncatedToken,
"SELECT label, token_prefix, expiration, created_at, \
last_used_at, scopes FROM token WHERE email = $1
last_used_at, scopes, workspace_id FROM token WHERE email = $1
ORDER BY created_at DESC LIMIT $2 OFFSET $3",
email,
per_page as i64,
@@ -2305,6 +2310,55 @@ async fn delete_token(
))
}
#[derive(Deserialize)]
struct UpdateTokenScopesRequest {
scopes: Option<Vec<String>>,
}
async fn update_token_scopes(
Extension(db): Extension<DB>,
authed: ApiAuthed,
Path(token_prefix): Path<String>,
Json(req): Json<UpdateTokenScopesRequest>,
) -> Result<String> {
let mut tx = db.begin().await?;
let updated: Option<String> = sqlx::query_scalar!(
"UPDATE token SET scopes = $1
WHERE email = $2 AND token_prefix = $3
RETURNING token_prefix",
req.scopes.as_deref(),
&authed.email,
&token_prefix,
)
.fetch_optional(&mut *tx)
.await?;
let prefix = updated.ok_or_else(|| {
Error::NotFound(format!(
"token {token_prefix} not found or not owned by user"
))
})?;
let scopes_json = serde_json::to_string(&req.scopes).unwrap_or_default();
audit_log(
&mut *tx,
&authed,
"users.token.update_scopes",
ActionKind::Update,
&"global",
Some(&prefix),
Some([("scopes", scopes_json.as_str())].into()),
)
.await?;
tx.commit().await?;
windmill_api_auth::invalidate_token_from_cache(&prefix);
Ok(format!("updated scopes for token {prefix}"))
}
async fn leave_workspace(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
@@ -83,6 +83,7 @@ pub fn workspaced_service() -> Router {
.route("/get_imports/{*importer_path}", get(get_imports))
.route("/get_dependents_amounts", post(get_dependents_amounts))
.route("/get_settings", get(get_settings))
.route("/get_public_settings", get(get_public_settings))
.route(
"/get_copilot_settings_state",
get(get_copilot_settings_state),
@@ -279,6 +280,34 @@ pub struct WorkspaceSettings {
pub public_app_execution_limit_per_minute: Option<i32>,
}
/// Subset of `WorkspaceSettings` that is safe to return to any workspace
/// member. Adding a field here means it will be readable by every authed user
/// in the workspace — anything sensitive (OAuth secrets, GitHub App tokens,
/// billing/customer info, integration credentials, etc.) must NOT be added.
/// The full `WorkspaceSettings` struct is admin-only via `get_settings`.
#[derive(FromRow, Serialize, Debug)]
pub struct WorkspacePublicSettings {
pub workspace_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub slack_team_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slack_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub teams_team_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub teams_team_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub teams_team_guid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub mute_critical_alerts: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deploy_ui: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub large_file_storage: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub datatable: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct CopilotSettingsState {
pub has_instance_ai_config: bool,
@@ -622,6 +651,10 @@ async fn get_settings(
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<WorkspaceSettings> {
// Admin-only: this struct contains OAuth secrets, GitHub App tokens, billing
// info, and other admin-managed integration credentials. Non-admin callers
// should use `get_public_settings`.
require_admin(authed.is_admin, &authed.username)?;
let mut tx = user_db.begin(&authed).await?;
let settings = sqlx::query_as!(
WorkspaceSettings,
@@ -669,12 +702,46 @@ async fn get_settings(
.await
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
let settings = not_found_if_none(settings, "workspace settings", &w_id)?;
tx.commit().await?;
Ok(Json(settings))
}
async fn get_public_settings(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<WorkspacePublicSettings> {
let mut tx = user_db.begin(&authed).await?;
let settings = sqlx::query_as!(
WorkspacePublicSettings,
r#"
SELECT
workspace_id,
slack_team_id,
slack_name,
teams_team_id,
teams_team_name,
teams_team_guid,
mute_critical_alerts,
deploy_ui,
large_file_storage,
datatable
FROM
workspace_settings
WHERE
workspace_id = $1
"#,
&w_id
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting public settings: {e:#}")))?;
let settings = not_found_if_none(settings, "workspace settings", &w_id)?;
tx.commit().await?;
if !authed.is_admin {
settings.slack_oauth_client_secret = None;
}
Ok(Json(settings))
}
+9
View File
@@ -30955,6 +30955,15 @@ paths:
required: true
schema:
type: string
- name: marker_file
description: >-
If provided, the folder is only considered to exist when this exact
sentinel file is present under file_key. Lets callers distinguish a
fully populated folder from a partial upload.
in: query
required: false
schema:
type: string
responses:
'200':
description: S3 folder existence check result
+88 -2
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.692.0
version: 1.693.3
title: Windmill API
contact:
@@ -3027,9 +3027,51 @@ paths:
items:
$ref: "#/components/schemas/WorkspaceInvite"
/w/{workspace}/workspaces/get_public_settings:
get:
summary: get public settings
description: Returns the subset of workspace settings safe to expose to any workspace member. The full settings struct is admin-only via `getSettings`.
operationId: getPublicSettings
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
application/json:
schema:
type: object
properties:
workspace_id:
type: string
slack_name:
type: string
slack_team_id:
type: string
teams_team_id:
type: string
teams_team_name:
type: string
teams_team_guid:
type: string
large_file_storage:
$ref: "#/components/schemas/LargeFileStorage"
datatable:
$ref: "#/components/schemas/DataTableSettings"
deploy_ui:
$ref: "#/components/schemas/WorkspaceDeployUISettings"
mute_critical_alerts:
type: boolean
required:
- workspace_id
/w/{workspace}/workspaces/get_settings:
get:
summary: get settings
summary: get settings (admin only)
description: Returns the full workspace settings including admin-managed integration credentials. Admin-only — non-admin callers should use `getPublicSettings`.
operationId: getSettings
tags:
- workspace
@@ -4834,6 +4876,39 @@ paths:
schema:
type: string
/users/tokens/update_scopes/{token_prefix}:
post:
summary: update scopes of an existing token (owner only)
operationId: updateTokenScopes
tags:
- user
parameters:
- name: token_prefix
in: path
required: true
schema:
type: string
requestBody:
description: new scopes (null or omitted = full access)
required: true
content:
application/json:
schema:
type: object
properties:
scopes:
type: array
items:
type: string
nullable: true
responses:
"200":
description: scopes updated
content:
text/plain:
schema:
type: string
/users/tokens/list:
get:
summary: list token
@@ -18618,6 +18693,15 @@ paths:
required: true
schema:
type: string
- name: marker_file
description: |
If provided, the folder is only considered to exist when this exact
sentinel file is present under file_key. Lets callers distinguish a
fully populated folder from a partial upload.
in: query
required: false
schema:
type: string
responses:
"200":
description: S3 folder existence check result
@@ -22363,6 +22447,8 @@ components:
type: string
email:
type: string
workspace_id:
type: string
required:
- token_prefix
- created_at
@@ -1038,7 +1038,13 @@ pub(crate) async fn tarball_workspace(
slack_name: row.slack_name.clone(),
slack_command_script: row.slack_command_script.clone(),
slack_oauth_client_id: row.slack_oauth_client_id.clone(),
slack_oauth_client_secret: row.slack_oauth_client_secret.clone(),
// Mirror the non-admin redaction in `get_settings`: the OAuth
// client secret is admin-only and must not leak via tarball.
slack_oauth_client_secret: if authed.is_admin {
row.slack_oauth_client_secret.clone()
} else {
None
},
};
serde_json::to_value(settings)
.map(|v| serde_json::to_string_pretty(&v).ok())
+1 -1
View File
@@ -259,11 +259,11 @@ pub async fn require_admin_or_devops(
fn instance_name(hostname: &str) -> String {
hostname
.replace(" ", "")
.replace('_', "")
.split("-")
.last()
.unwrap()
.to_ascii_lowercase()
.to_string()
}
const DEFAULT_WORKER_SUFFIX_LEN: usize = 5;
+30
View File
@@ -395,6 +395,36 @@ pub async fn in_test_worker<Fut: std::future::Future>(
res
}
/// Run an async test body on a freshly-spawned OS thread with a large stack
/// and a fresh current-thread tokio runtime. Use this for tests whose async
/// chain (e.g. nested `in_test_worker` -> `run_until_complete` ->
/// `windmill_queue::push`) blows the default 2 MB test-thread stack in debug
/// builds, where `async fn` state machines are unoptimized and `Box::pin` at
/// the call site only moves *future state* to the heap, not the synchronous
/// poll-time stack frames composed along the chain.
///
/// The chain contains `?Send` futures (trait objects), so `tokio::spawn`
/// is not viable; `std::thread::spawn` sidesteps the shared-stack issue.
pub fn run_in_isolated_thread<F, Fut, R>(f: F) -> R
where
F: FnOnce() -> Fut + Send + 'static,
Fut: std::future::Future<Output = R>,
R: Send + 'static,
{
std::thread::Builder::new()
.stack_size(8 * 1024 * 1024)
.spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("build current-thread runtime");
rt.block_on(f())
})
.expect("spawn isolated test thread")
.join()
.expect("isolated test thread panicked")
}
pub fn spawn_test_worker(
conn: &Connection,
port: u16,
+24 -12
View File
@@ -139,13 +139,17 @@ fn do_postgresql_inner<'a>(
skip_collect: bool,
first_row_only: bool,
s3: Option<S3ModeWorkerData>,
typed_schema: bool,
job_id: Uuid,
workspace_id: &'a str,
log_conn: &'a Connection,
) -> error::Result<BoxFuture<'a, error::Result<Vec<Box<RawValue>>>>> {
let mut query_params = vec![];
let mut param_types = vec![];
let mut param_types: Vec<Type> = vec![];
// Try to resolve a Postgres Type for every arg (using parser-supplied otyp,
// which defaults to "text" for inferred args). If we succeed for all, we can
// send the query as an unnamed prepared statement and avoid named statements
// entirely — see the dispatch comment below.
let mut all_types_resolved = true;
let arg_indices = parse_pg_statement_arg_indices(&query);
@@ -163,8 +167,14 @@ fn do_postgresql_inner<'a>(
let typ = &arg.typ;
let param = convert_val(value, arg_t, typ)?;
query_params.push(param);
if typed_schema {
param_types.push(otyp_to_pg_type(arg_t)?);
if all_types_resolved {
match otyp_to_pg_type(arg_t) {
Ok(t) => param_types.push(t),
Err(_) => {
all_types_resolved = false;
param_types.clear();
}
}
}
i += 1;
}
@@ -173,12 +183,15 @@ fn do_postgresql_inner<'a>(
let result_f = async move {
let mut res: Vec<Box<serde_json::value::RawValue>> = vec![];
// Use query_typed_raw (unnamed prepared statement) when all param types are
// resolved. This avoids named prepared statements ("s0", "s1", ...) which break
// with transaction-mode connection poolers (e.g. PgBouncer/Supabase) since the
// prepare and query can land on different backend connections.
// Fall back to prepare + query_raw for custom/unsupported types.
let rows = if typed_schema {
// Always prefer query_typed_raw (unnamed prepared statement). It is sent as
// a single Parse+Bind+Execute+Sync round-trip, so it survives transaction-mode
// connection poolers (PgBouncer/Supabase pooler/RDS Proxy) where named
// statements ("s0", "s1", ...) can be reported missing because the prepare
// and the execute land on different backend connections. Fall back to
// prepare + query_raw only when an arg has a type unsupported by
// otyp_to_pg_type (e.g. custom enum, geometry, …) — in that case we lose
// pooler safety, but the query at least runs against a direct connection.
let rows = if all_types_resolved {
let typed_params = query_params
.iter()
.zip(param_types.iter())
@@ -387,7 +400,7 @@ pub async fn do_postgresql(
new_client = Some(new_pg_connection(&database, use_iam_auth, conn.as_sql()).await?);
}
let (mut sig, typed_schema) = parse_pgsql_sig_with_typed_schema(&query)
let (mut sig, _) = parse_pgsql_sig_with_typed_schema(&query)
.map_err(|x| Error::ExecutionErr(x.to_string()))?;
// Materialize any `(s3object)` args into JSON text and rebind them as `jsonb` so
@@ -470,7 +483,6 @@ pub async fn do_postgresql(
&& i < queries.len() - 1,
collection_strategy.collect_first_row_only(),
s3.clone(),
typed_schema,
job.id,
&job.workspace_id,
conn,
+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.692.0";
export const VERSION = "v1.693.3";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+7 -1
View File
@@ -16,6 +16,12 @@ const parserPackages = [
];
const parserExternals = parserPackages.flatMap(p => ["--external", p]);
// Forward parser specs from the dev package.json so the published CLI pins
// to the same versions devs install/test against. Falls back to "*" if not
// listed locally.
const cliDeps: Record<string, string> =
JSON.parse(readFileSync("./package.json", "utf-8")).dependencies ?? {};
// Clean output directory
rmSync(outDir, { recursive: true, force: true });
@@ -68,7 +74,7 @@ const packageJson = {
},
dependencies: {
esbuild: "0.28.0",
...Object.fromEntries(parserPackages.map(p => [p, "*"])),
...Object.fromEntries(parserPackages.map(p => [p, cliDeps[p] ?? "*"])),
},
optionalDependencies: {
svelte: "^5.0.0",
+16 -16
View File
@@ -18,18 +18,18 @@
"open": "^10.0.0",
"svelte": "^5.45.2",
"tar-stream": "^3.1.7",
"windmill-parser-wasm-csharp": "*",
"windmill-parser-wasm-go": "*",
"windmill-parser-wasm-java": "*",
"windmill-parser-wasm-nu": "*",
"windmill-parser-wasm-php": "*",
"windmill-parser-wasm-py": "*",
"windmill-parser-wasm-py-imports": "*",
"windmill-parser-wasm-regex": "*",
"windmill-parser-wasm-ruby": "*",
"windmill-parser-wasm-rust": "*",
"windmill-parser-wasm-ts": "^1.659.1",
"windmill-parser-wasm-yaml": "*",
"windmill-parser-wasm-csharp": "1.510.1",
"windmill-parser-wasm-go": "1.510.1",
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.647.1",
"windmill-parser-wasm-py": "1.693.1",
"windmill-parser-wasm-py-imports": "1.693.1",
"windmill-parser-wasm-regex": "1.692.0",
"windmill-parser-wasm-ruby": "1.526.1",
"windmill-parser-wasm-rust": "1.647.1",
"windmill-parser-wasm-ts": "1.693.1",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-yaml-validator": "1.1.1",
"ws": "8.18.0",
"yaml": "^2.7.0",
@@ -295,17 +295,17 @@
"windmill-parser-wasm-php": ["windmill-parser-wasm-php@1.647.1", "", {}, "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg=="],
"windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.628.3", "", {}, "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw=="],
"windmill-parser-wasm-py": ["windmill-parser-wasm-py@1.693.1", "", {}, "sha512-qSXKxDsKjt1UOEK1NnpKJmDrXIx/sZaGHktC6+Sj6CfhLi1X/Qj37FalOq4Es5l2qzm2NPvk4jkCXsV1QdkcHA=="],
"windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.659.1", "", {}, "sha512-nfnf04WBRf8f/mNIwdvggYOgz3erxrFGjKqULYBH+bKFMlKA6V7eB19m6CXOBkq9rjTp0ZFG+rgsR+Us7JEkyQ=="],
"windmill-parser-wasm-py-imports": ["windmill-parser-wasm-py-imports@1.693.1", "", {}, "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g=="],
"windmill-parser-wasm-regex": ["windmill-parser-wasm-regex@1.639.0", "", {}, "sha512-qvYM4sYxB6M0xrqwBljS2fWqOMk6rp++60TRltJnzZDzVaWQrKjTGwNMmfepGAIWy1OGVKp0SCVERhe2P+O6tQ=="],
"windmill-parser-wasm-regex": ["windmill-parser-wasm-regex@1.692.0", "", {}, "sha512-BHGTxrinZJ9ef6hFxbKiBqBEr5uqgG/QySOgMA5r1LswO9n/8fyGswr8JcPT2kGaoeoweV6/RQ+RHVaOhosnKw=="],
"windmill-parser-wasm-ruby": ["windmill-parser-wasm-ruby@1.526.1", "", {}, "sha512-rMBQA8s21wmL2kA5ztRs/ZgVA3ckxe9/NLjxl3iQPL0CX6DlvfaUH0O+AnhpXXDMyBs1Y1SZIhcnbnvsHZ3R8g=="],
"windmill-parser-wasm-rust": ["windmill-parser-wasm-rust@1.647.1", "", {}, "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="],
"windmill-parser-wasm-ts": ["windmill-parser-wasm-ts@1.659.1", "", {}, "sha512-EmXMzOmazC5r29UZh+1TVF9g/N2X51pqK11qDL6xWGeWTIIonhfOZ5nWdGvKQMDUR650fGxehImZzW2v9hNy+w=="],
"windmill-parser-wasm-ts": ["windmill-parser-wasm-ts@1.693.1", "", {}, "sha512-xrPgVWwQbOWJKiz68wBDNMrKVOtCY/utyhzSx0kFYIWm/QH/6L8k6LLjo4DOn+PnlBNRXc7qum0sDyB8IuuJYQ=="],
"windmill-parser-wasm-yaml": ["windmill-parser-wasm-yaml@1.593.0", "", {}, "sha512-Gyx4aR2jsJYuDrD3mCNTmz7LWOQQXPw5yKNCC1xRgUOPfjsD/tINAFfsBLwVOSmlQQcFZO+wHm4KtDtXOcnGVw=="],
+12 -12
View File
@@ -24,12 +24,12 @@
"windmill-parser-wasm-java": "*",
"windmill-parser-wasm-nu": "*",
"windmill-parser-wasm-php": "*",
"windmill-parser-wasm-py": "*",
"windmill-parser-wasm-py-imports": "*",
"windmill-parser-wasm-py": "^1.693.1",
"windmill-parser-wasm-py-imports": "^1.693.1",
"windmill-parser-wasm-regex": "*",
"windmill-parser-wasm-ruby": "*",
"windmill-parser-wasm-rust": "*",
"windmill-parser-wasm-ts": "^1.659.1",
"windmill-parser-wasm-ts": "^1.693.1",
"windmill-parser-wasm-yaml": "*",
"windmill-yaml-validator": "1.1.1",
"ws": "8.18.0",
@@ -1428,14 +1428,14 @@
"integrity": "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg=="
},
"node_modules/windmill-parser-wasm-py": {
"version": "1.628.3",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.628.3.tgz",
"integrity": "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw=="
"version": "1.693.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.693.1.tgz",
"integrity": "sha512-qSXKxDsKjt1UOEK1NnpKJmDrXIx/sZaGHktC6+Sj6CfhLi1X/Qj37FalOq4Es5l2qzm2NPvk4jkCXsV1QdkcHA=="
},
"node_modules/windmill-parser-wasm-py-imports": {
"version": "1.659.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py-imports/-/windmill-parser-wasm-py-imports-1.659.1.tgz",
"integrity": "sha512-nfnf04WBRf8f/mNIwdvggYOgz3erxrFGjKqULYBH+bKFMlKA6V7eB19m6CXOBkq9rjTp0ZFG+rgsR+Us7JEkyQ=="
"version": "1.693.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py-imports/-/windmill-parser-wasm-py-imports-1.693.1.tgz",
"integrity": "sha512-FC0KbREe2G/sa/9kYIR930wmWw+VL6PvEIqg12J3dsJes3A+0x5JIUPT/jeD+c24DrG0ko/Ub7yDnYs56Bem7g=="
},
"node_modules/windmill-parser-wasm-regex": {
"version": "1.639.0",
@@ -1453,9 +1453,9 @@
"integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="
},
"node_modules/windmill-parser-wasm-ts": {
"version": "1.659.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.659.1.tgz",
"integrity": "sha512-EmXMzOmazC5r29UZh+1TVF9g/N2X51pqK11qDL6xWGeWTIIonhfOZ5nWdGvKQMDUR650fGxehImZzW2v9hNy+w=="
"version": "1.693.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.693.1.tgz",
"integrity": "sha512-xrPgVWwQbOWJKiz68wBDNMrKVOtCY/utyhzSx0kFYIWm/QH/6L8k6LLjo4DOn+PnlBNRXc7qum0sDyB8IuuJYQ=="
},
"node_modules/windmill-parser-wasm-yaml": {
"version": "1.593.0",
+12 -12
View File
@@ -27,18 +27,18 @@
"open": "^10.0.0",
"svelte": "^5.45.2",
"tar-stream": "^3.1.7",
"windmill-parser-wasm-csharp": "*",
"windmill-parser-wasm-go": "*",
"windmill-parser-wasm-java": "*",
"windmill-parser-wasm-nu": "*",
"windmill-parser-wasm-php": "*",
"windmill-parser-wasm-py": "*",
"windmill-parser-wasm-py-imports": "*",
"windmill-parser-wasm-regex": "*",
"windmill-parser-wasm-ruby": "*",
"windmill-parser-wasm-rust": "*",
"windmill-parser-wasm-ts": "^1.659.1",
"windmill-parser-wasm-yaml": "*",
"windmill-parser-wasm-csharp": "1.510.1",
"windmill-parser-wasm-go": "1.510.1",
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.647.1",
"windmill-parser-wasm-py": "1.693.1",
"windmill-parser-wasm-py-imports": "1.693.1",
"windmill-parser-wasm-regex": "1.692.0",
"windmill-parser-wasm-ruby": "1.526.1",
"windmill-parser-wasm-rust": "1.647.1",
"windmill-parser-wasm-ts": "1.693.1",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-yaml-validator": "1.1.1",
"ws": "8.18.0",
"yaml": "^2.7.0"
+1
View File
@@ -23,6 +23,7 @@ You are a helpful assistant that can help with Windmill scripts, flows, apps, an
## Script Writing Guide
You MUST use the \`write-script-<language>\` skill to write or modify scripts in the language specified by the user. Use bun by default.
For Workflow-as-Code scripts, use the \`write-workflow-as-code\` skill.
## Flow Writing Guide
+441
View File
@@ -32,6 +32,7 @@ export const SKILLS: SkillMetadata[] = [
{ name: "triggers", description: "MUST use when configuring triggers." },
{ name: "schedules", description: "MUST use when configuring schedules." },
{ name: "resources", description: "MUST use when managing resources." },
{ name: "write-workflow-as-code", description: "MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow." },
{ name: "cli-commands", description: "MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`." },
{ name: "preview", description: "MUST use when opening the Windmill dev page / visual preview of a flow, script, or app. Triggers on words like preview, open, navigate to, visualize, see the flow/app/script, and after writing a flow/script/app for visual verification." },
];
@@ -5876,6 +5877,446 @@ wmill resource-type get postgresql
# Push resources (tell the user to run this, do NOT run it yourself)
wmill sync push
\`\`\`
`,
"write-workflow-as-code": `---
name: write-workflow-as-code
description: MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow.
---
## CLI Commands
Place scripts in a folder.
After writing, tell the user which command fits what they want to do:
- \`wmill script preview <script_path>\` — **default when iterating on a local script.** Runs the local file without deploying.
- \`wmill script run <path>\` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified.
- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
### Preview vs run choose by intent, not habit
If the user says "run the script", "try it", "test it", "does it work" while there are **local edits to the script file**, use \`script preview\`. Do NOT push the script to then \`script run\` it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes.
Only use \`script run\` when:
- The user explicitly says "run the deployed version" / "run what's on the server".
- There is no local script being edited (you're just invoking an existing script).
Only use \`sync push\` when:
- The user explicitly asks to deploy, publish, push, or ship.
- The preview has already validated the change and the user wants it in the workspace.
### After writing offer to test, don't wait passively
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu.
If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview <path> -d '<args>'\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell.
\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill.
Use \`wmill resource-type list --schema\` to discover available resource types.
Workflow-as-Code files use the normal script CLI workflow. There are no separate WAC deploy commands.
# Windmill Workflow-as-Code Writing Guide
## Scope
Use this guide when writing or modifying Windmill Workflow-as-Code (WAC) scripts.
WAC is authored as a Windmill script and deployed with the normal script workflow. It is not an OpenFlow YAML flow.
Supported WAC authoring targets:
- TypeScript scripts that import from \`windmill-client\`
- Python 3 scripts that import from \`wmill\`
## File Shape
TypeScript:
\`\`\`typescript
import {
task,
taskScript,
taskFlow,
step,
sleep,
waitForApproval,
getResumeUrls,
parallel,
workflow,
} from "windmill-client";
const process = task(async (x: string): Promise<string> => {
return \`processed: \${x}\`;
});
export const main = workflow(async (x: string) => {
const result = await process(x);
return { result };
});
\`\`\`
Python:
\`\`\`python
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow
@task()
async def process(x: str) -> str:
return f"processed: {x}"
@workflow
async def main(x: str):
result = await process(x)
return {"result": result}
\`\`\`
Rules:
- Do not call \`main\`.
- TypeScript should export the workflow entrypoint, preferably \`export const main = workflow(async (...) => { ... })\`.
- Python must use \`@workflow\` on an async top-level function, usually \`main\`.
- Define task functions and \`taskScript\`/\`task_script\` or \`taskFlow\`/\`task_flow\` assignments at module top level with stable names.
- Use the exact SDK names. Do not alias \`workflow\`, \`task\`, \`taskScript\`, \`taskFlow\`, \`step\`, \`sleep\`, \`waitForApproval\`, \`task_script\`, \`task_flow\`, or \`wait_for_approval\`; the WAC parser recognizes these names directly.
## Checkpoint And Replay Model
The parent workflow may rerun from the top after any suspension, retry, approval, or child task completion. Completed durable steps are replayed from the checkpoint.
Put every side effect or non-deterministic value behind a durable WAC boundary:
- Use \`task()\` / \`@task()\` for substantial work that should run as its own child job.
- Use \`taskScript()\` / \`task_script()\` for an existing script or a relative module file.
- Use \`taskFlow()\` / \`task_flow()\` for an existing Windmill flow.
- Use \`step(name, fn)\` for lightweight inline work whose result must be checkpointed.
- Use \`sleep(seconds)\` for server-side sleeps that do not hold a worker.
- Use \`waitForApproval()\` / \`wait_for_approval()\` for external approval suspension.
Never put API calls, database writes, notifications, random values, timestamps, or irreversible changes directly in the top-level workflow body. The workflow body can be rerun. Put those operations in a task or in \`step()\`.
Branching on task or step results is safe because those results are checkpointed. Branching on current time, random data, environment reads, or external state is unsafe unless the value is first captured with \`step()\`.
## Tasks
Use \`task()\` / \`@task()\` for inline functions that become workflow steps:
\`\`\`typescript
const enrich = task(async (customerId: string) => {
return await fetchCustomer(customerId);
});
\`\`\`
\`\`\`python
@task(timeout=600, tag="etl")
async def enrich(customer_id: str):
return await fetch_customer(customer_id)
\`\`\`
In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with \`@task()\` or \`@task\`.
For existing scripts:
\`\`\`typescript
const helper = taskScript("./helper.ts");
const existing = taskScript("f/data/extract", { timeout: 600 });
const value = await helper({ input: x });
\`\`\`
\`\`\`python
helper = task_script("./helper.py")
existing = task_script("f/data/extract", timeout=600)
value = await helper(input=x)
\`\`\`
For existing flows:
\`\`\`typescript
const pipeline = taskFlow("f/etl/pipeline");
const output = await pipeline({ input: data });
\`\`\`
\`\`\`python
pipeline = task_flow("f/etl/pipeline")
output = await pipeline(input=data)
\`\`\`
## Inline Steps
Use \`step()\` for lightweight inline values that must not change during replay:
\`\`\`typescript
const urls = await step("get_urls", () => getResumeUrls());
const startedAt = await step("started_at", () => new Date().toISOString());
\`\`\`
\`\`\`python
urls = await step("get_urls", lambda: get_resume_urls())
\`\`\`
Use stable, descriptive step names. Do not generate step names dynamically.
## Parallelism
To run independent work in parallel, start task promises/coroutines before awaiting them together:
\`\`\`typescript
const [a, b] = await Promise.all([process("a"), process("b")]);
const many = await parallel(items, process, { concurrency: 5 });
\`\`\`
\`\`\`python
import asyncio
a, b = await asyncio.gather(process("a"), process("b"))
many = await parallel(items, process, concurrency=5)
\`\`\`
Only parallelize independent steps. Do not read the result of a task before it is awaited.
## Approvals
Generate resume URLs inside \`step()\` before sending them:
\`\`\`typescript
const urls = await step("get_urls", () => getResumeUrls());
await step("notify", () => sendApprovalEmail(urls.approvalPage));
const approval = await waitForApproval({ timeout: 3600 });
\`\`\`
\`\`\`python
urls = await step("get_urls", lambda: get_resume_urls())
await step("notify", lambda: send_approval_email(urls["approvalPage"]))
approval = await wait_for_approval(timeout=3600)
\`\`\`
\`selfApproval: false\` and \`self_approval=False\` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.
## Error Handling
Let task errors fail the workflow unless the user asks for recovery logic.
Python: \`except Exception\` is safe around WAC calls because internal suspension inherits from \`BaseException\`. Avoid bare \`except:\` in workflow code. If the user asks for recovery logic around failed child work, catch \`TaskError\` from \`wmill\` for task failures.
TypeScript: avoid broad \`try/catch\` around WAC SDK calls. The SDK uses an internal suspension error during initial dispatch; catching it can break workflow suspension. If a broad catch is unavoidable, rethrow internal suspension errors before handling business errors.
## TypeScript Workflow-as-Code API (windmill-client)
Import: \`import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"\`
\`\`\`typescript
export interface TaskOptions {
timeout?: number;
tag?: string;
cache_ttl?: number;
priority?: number;
concurrency_limit?: number;
concurrency_key?: string;
concurrency_time_window_s?: number;
}
/**
* Get URLs needed for resuming a flow after this step
* @param approver approver name
* @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.
* This allows pre-approvals that can be consumed by any later suspend step in the same flow.
* @returns approval page UI URL, resume and cancel API URLs for resuming the flow
*/
export async function getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ approvalPage: string; resume: string; cancel: string; }>
/**
* Wrap an async function as a workflow task.
*
* @example
* const extract_data = task(async (url: string) => { ... });
* const run_external = task("f/external_script", async (x: number) => { ... });
*
* Inside a \`workflow()\`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly.
*/
export function task<T extends (...args: any[]) => Promise<any>>(fnOrPath: T | string, maybeFnOrOptions?: T | TaskOptions, maybeOptions?: TaskOptions,): T
/**
* Create a task that dispatches to a separate Windmill script.
*
* @example
* const extract = taskScript("f/data/extract");
* // inside workflow: await extract({ url: "https://..." })
*/
export function taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>
/**
* Create a task that dispatches to a separate Windmill flow.
*
* @example
* const pipeline = taskFlow("f/etl/pipeline");
* // inside workflow: await pipeline({ input: data })
*/
export function taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>
/**
* Mark an async function as a workflow-as-code entry point.
*
* The function must be **deterministic**: given the same inputs it must call
* tasks in the same order on every replay. Branching on task results is fine
* (results are replayed from checkpoint), but branching on external state
* (current time, random values, external API calls) must use \`step()\` to
* checkpoint the value so replays see the same result.
*/
export function workflow<T>(fn: (...args: any[]) => Promise<T>)
export async function step<T>(name: string, fn: () => T | Promise<T>): Promise<T>
export async function sleep(seconds: number): Promise<void>
/**
* Suspend the workflow and wait for an external approval.
*
* Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
*
* Each item is processed by calling \`fn(item)\`, which should be a task().
* Items are dispatched in batches of \`concurrency\` (default: all at once).
*
* @example
* const process = task(async (item: string) => { ... });
* const results = await parallel(items, process, { concurrency: 5 });
*/
export async function parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?: { concurrency?: number },): Promise<R[]>
\`\`\`
## Python Workflow-as-Code API (wmill)
Import: \`from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError\`
\`\`\`python
# Raised when a WAC task step failed.
#
# Attributes:
# step_key: The checkpoint key of the failed step.
# child_job_id: The UUID of the failed child job.
# result: The error result from the child job.
class TaskError(Exception):
def __init__(self, message: str, *, step_key: str = '', child_job_id: str = '', result = None)
# Get URLs needed for resuming a flow after suspension.
#
# Args:
# approver: Optional approver name
# flow_level: If True, generate resume URLs for the parent flow instead of the
# specific step. This allows pre-approvals that can be consumed by any later
# suspend step in the same flow.
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# Decorator that marks a function as a workflow task.
#
# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2
# (async, checkpoint/replay) modes:
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
#
# Usage::
#
# @task
# async def extract_data(url: str): ...
#
# @task(path="f/external_script", timeout=600, tag="gpu")
# async def run_external(x: int): ...
def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill script.
#
# Usage::
#
# extract = task_script("f/data/extract", timeout=600)
#
# @workflow
# async def main():
# data = await extract(url="https://...")
def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill flow.
#
# Usage::
#
# pipeline = task_flow("f/etl/pipeline", priority=10)
#
# @workflow
# async def main():
# result = await pipeline(input=data)
def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Decorator marking an async function as a workflow-as-code entry point.
#
# The function must be **deterministic**: given the same inputs it must call
# tasks in the same order on every replay. Branching on task results is fine
# (results are replayed from checkpoint), but branching on external state
# (current time, random values, external API calls) must use \`\`step()\`\` to
# checkpoint the value so replays see the same result.
def workflow(func)
# Execute \`\`fn\`\` inline and checkpoint the result.
#
# On replay the cached value is returned without re-executing \`\`fn\`\`.
# Use for lightweight deterministic operations (timestamps, random IDs,
# config reads) that should not incur the overhead of a child job.
async def step(name: str, fn)
# Server-side sleep suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`.
# Outside a workflow, falls back to \`\`asyncio.sleep\`\`.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use \`\`get_resume_urls()\`\` (wrapped in \`\`step()\`\`) to obtain
# resume/cancel/approval URLs before calling this function.
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
# Args:
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
# Each item is processed by calling \`\`fn(item)\`\`, which should be a @task.
# Items are dispatched in batches of \`\`concurrency\`\` (default: all at once).
#
# Example::
#
# @task
# async def process(item: str):
# ...
#
# results = await parallel(items, process, concurrency=5)
async def parallel(items, fn, *, concurrency: Optional[int] = None)
\`\`\`
`,
"cli-commands": `---
name: cli-commands
+1 -1
View File
@@ -78,7 +78,7 @@ export {
token,
};
export const VERSION = "1.692.0";
export const VERSION = "1.693.3";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
@@ -0,0 +1,390 @@
import * as wmillclient from "windmill-client";
import { basename, join } from "node:path";
import { existsSync, rmSync } from "fs";
import process from "process";
import { spawn } from 'child_process';
import * as fs_async from 'fs/promises';
import * as fs from 'node:fs';
const UPLOAD_CONCURRENCY = 16;
const CLONE_MARKER_FILE = ".windmill_clone_complete";
type GitRepository = {
url: string;
branch: string;
folder: string;
gpg_key: any;
is_github_app: boolean;
};
export async function main(
resource_path: string,
workspace: string,
git_ssh_identity?: string[],
commit?: string
) {
let clonedRepoPath: string | undefined;
try {
console.log("Starting git clone and Blob storage upload process");
// Get the git repository resource
const repo_resource: GitRepository = await wmillclient.getResource(resource_path);
const cwd = process.cwd();
if (git_ssh_identity) {
process.env.GIT_SSH_COMMAND = await get_git_ssh_cmd(cwd, git_ssh_identity)
}
// Handle GitHub App authentication if needed
if (repo_resource.is_github_app) {
const token = await get_gh_app_token();
repo_resource.url = prependTokenToGitHubUrl(repo_resource.url, token);
}
process.env["HOME"] = ".";
process.env.GIT_TERMINAL_PROMPT = "0";
// Clone the repository
const { repo_name, commitHash } = await git_clone(cwd, repo_resource, commit);
clonedRepoPath = join(cwd, repo_name);
// Remove .git directory to avoid uploading git history
const gitDir = join(clonedRepoPath, ".git");
if (existsSync(gitDir)) {
rmSync(gitDir, { recursive: true, force: true });
console.log("Removed .git directory");
}
// Upload to S3
const s3Path = `gitrepos/${workspace}/${resource_path}/${commitHash}`;
const fileCount = await uploadDirectoryToS3(clonedRepoPath, s3Path, workspace);
return {
success: true,
message: "Repository cloned and uploaded to S3 successfully",
s3_path: s3Path,
commit_hash: commitHash,
file_count: fileCount,
};
} catch (error) {
console.error("Error in git clone and upload:", error);
throw error;
} finally {
// Clean up cloned repository
if (clonedRepoPath && existsSync(clonedRepoPath)) {
rmSync(clonedRepoPath, { recursive: true, force: true });
console.log("Cleaned up cloned repository");
}
}
}
async function get_git_ssh_cmd(cwd: string, git_ssh_identity: string[]): Promise<string> {
const sshIdFiles = await Promise.all(
git_ssh_identity.map(async (varPath, i) => {
const filePath = join(cwd, `./ssh_id_priv_${i}`);
try {
// Get variable value using windmill
let content = await wmillclient.getVariable(varPath);
content += '\n';
// Write file with content
await fs_async.writeFile(filePath, content, { encoding: 'utf8' });
// Set file permissions to 0o600 (read/write for owner only)
await fs_async.chmod(filePath, 0o600);
// Escape single quotes for shell command
const escapedPath = filePath.replace(/'/g, "'\\''");
return ` -i '${escapedPath}'`;
} catch (error) {
console.error(
`Variable ${varPath} not found for git ssh identity: ${error}`
);
return '';
}
})
);
const gitSshCmd = `ssh -o StrictHostKeyChecking=no${sshIdFiles.join('')}`;
return gitSshCmd;
}
async function git_clone(
cwd: string,
repo_resource: GitRepository,
commit?: string,
): Promise<{ repo_name: string; commitHash: string }> {
if (commit) {
return git_clone_at_commit(cwd, repo_resource, commit);
} else {
return git_clone_at_latest(cwd, repo_resource);
}
}
async function git_clone_at_commit(
cwd: string,
repo_resource: GitRepository,
commit: string,
): Promise<{ repo_name: string; commitHash: string }> {
let repo_url = repo_resource.url;
const subfolder = repo_resource.folder ?? "";
let branch = repo_resource.branch ?? "";
const repo_name = basename(repo_url, ".git");
const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
if (azureMatch) {
console.log("Fetching Azure DevOps access token...");
const azureResource = await wmillclient.getResource(azureMatch.groups.url);
const response = await fetch(
`https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
{
method: "POST",
body: new URLSearchParams({
client_id: azureResource.azureClientId,
client_secret: azureResource.azureClientSecret,
grant_type: "client_credentials",
resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
}),
}
);
const { access_token } = await response.json();
repo_url = repo_url.replace(azureMatch[0], access_token);
}
const repoPath = join(cwd, repo_name);
await fs_async.mkdir(repoPath, { recursive: true });
process.chdir(repoPath);
let args = ['init', '--quiet']
if (branch) {
args.push(`--initial-branch=${branch}`)
}
await runCommand(undefined, 'git', ...args);
await runCommand(0, 'git', 'remote', 'add', 'origin', repo_url);
await runCommand(undefined, 'git', 'fetch', '--depth=1', '--quiet', 'origin', commit);
await runCommand(undefined, 'git', 'checkout', '--quiet', 'FETCH_HEAD');
const commitHash = (await runCommand(undefined, "git", "rev-parse", "HEAD")).trim();
// Return to original directory
process.chdir(cwd);
return { repo_name, commitHash };
}
async function git_clone_at_latest(
cwd: string,
repo_resource: GitRepository
): Promise<{ repo_name: string; commitHash: string }> {
let repo_url = repo_resource.url;
const subfolder = repo_resource.folder ?? "";
let branch = repo_resource.branch ?? "";
const repo_name = basename(repo_url, ".git");
// Handle Azure DevOps token if needed
const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
if (azureMatch) {
console.log("Fetching Azure DevOps access token...");
const azureResource = await wmillclient.getResource(azureMatch.groups.url);
const response = await fetch(
`https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
{
method: "POST",
body: new URLSearchParams({
client_id: azureResource.azureClientId,
client_secret: azureResource.azureClientSecret,
grant_type: "client_credentials",
resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
}),
}
);
const { access_token } = await response.json();
repo_url = repo_url.replace(azureMatch[0], access_token);
}
const args = ["clone", "--quiet", "--depth", "1"];
if (subfolder !== "") args.push("--sparse");
if (branch !== "") args.push("--branch", branch);
args.push(repo_url, repo_name);
await runCommand(-1, "git", ...args);
const fullPath = join(cwd, repo_name);
process.chdir(fullPath);
if (subfolder !== "") {
await runCommand(undefined, "git", "sparse-checkout", "add", subfolder);
const subfolderPath = join(fullPath, subfolder);
if (!existsSync(subfolderPath)) {
throw new Error(`Subfolder ${subfolder} does not exist.`);
}
process.chdir(subfolderPath);
}
// Get the commit hash
const commitHash = (await runCommand(undefined, "git", "rev-parse", "HEAD")).trim();
// Return to original directory
process.chdir(cwd);
return { repo_name, commitHash };
}
async function uploadDirectoryToS3(
directoryPath: string,
s3BasePath: string,
workspace: string,
): Promise<number> {
console.log(`Uploading ${directoryPath} -> ${s3BasePath}`);
// Walk once into a flat task list so we can drive a bounded-concurrency pool.
const tasks: { localPath: string; s3Key: string }[] = [];
function walk(dir: string, s3Path: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name);
const s3Key = s3Path ? `${s3Path}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
walk(fullPath, s3Key);
} else if (entry.isFile()) {
tasks.push({ localPath: fullPath, s3Key });
}
}
}
walk(directoryPath, s3BasePath);
console.log(`Discovered ${tasks.length} files to upload`);
let nextIndex = 0;
let uploaded = 0;
let lastReport = 0;
async function worker() {
while (true) {
const idx = nextIndex++;
if (idx >= tasks.length) return;
const { localPath, s3Key } = tasks[idx];
const fileContent = fs.readFileSync(localPath);
const blob = new Blob([fileContent], { type: 'application/octet-stream' });
await wmillclient.HelpersService.gitRepoViewerFileUpload({
workspace,
fileKey: s3Key,
requestBody: blob,
});
uploaded++;
if (uploaded - lastReport >= 25 || uploaded === tasks.length) {
lastReport = uploaded;
console.log(`Uploaded ${uploaded} / ${tasks.length} files`);
}
}
}
await Promise.all(
Array.from({ length: Math.min(UPLOAD_CONCURRENCY, tasks.length) }, () => worker())
);
// Marker is the LAST write — its presence is what the viewer checks for.
const markerKey = `${s3BasePath}/${CLONE_MARKER_FILE}`;
const markerBody = JSON.stringify({
completed_at: new Date().toISOString(),
file_count: tasks.length,
});
await wmillclient.HelpersService.gitRepoViewerFileUpload({
workspace,
fileKey: markerKey,
requestBody: new Blob([markerBody], { type: 'application/json' }),
});
console.log(`Wrote completion marker: ${markerKey}`);
return tasks.length;
}
function runCommand(secret_position: number | undefined, cmd: string, ...args: string[]): Promise<string> {
const nargs = secret_position != undefined ? args.slice() : args;
if (secret_position && secret_position < 0)
secret_position = nargs.length - 1 + secret_position;
let secret: string | undefined = undefined;
if (secret_position != undefined) {
nargs[secret_position] = "***";
secret = args[secret_position];
}
console.log(`Running shell command: '${cmd} ${nargs.join(" ")} ...'`);
return new Promise((resolve, reject) => {
const process = spawn(cmd, args);
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('error', (error) => {
let errorString = error.toString();
if (secret) errorString = errorString.replace(secret, "***");
console.log(`Shell command FAILED: ${cmd}`, errorString);
const e = new Error(
`SH command '${cmd} ${nargs.join(" ")}' failed: ${errorString}`
);
reject(e);
});
process.on('close', (code) => {
if (stdout.length > 0) {
console.log("Shell stdout:", stdout);
}
if (stderr.length > 0) {
console.log("Shell stderr:", stderr);
}
if (code === 0) {
console.log(`Shell command completed successfully: ${cmd}`);
resolve(stdout);
} else {
reject(new Error(`Command failed with code ${code}: ${stderr}`));
}
});
});
}
async function get_gh_app_token() {
const workspace = process.env["WM_WORKSPACE"];
const jobToken = process.env["WM_TOKEN"];
const baseUrl =
process.env["BASE_INTERNAL_URL"] ??
process.env["BASE_URL"] ??
"http://localhost:8000";
const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jobToken}`,
},
body: JSON.stringify({ job_token: jobToken }),
});
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
throw new Error(`GitHub App token error (${response.status}): ${errorBody || response.statusText}`);
}
const data = await response.json();
return data.token;
}
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
const url = new URL(gitHubUrl);
return `https://x-access-token:${installationToken}@${url.hostname}${url.pathname}`;
}
+123
View File
@@ -0,0 +1,123 @@
# Git repo viewer — hub script patch
This documents the changes that need to be applied to the hub script
`clone_repo_and_upload_to_instance_storage` (currently published as
`hub/28182/clone_repo_and_upload_to_instance_storage` and referenced from
`frontend/src/lib/hubPaths.json` as `cloneRepoToS3forGitRepoViewer`).
The repo viewer in the Windmill app expects the hub script to:
1. **Upload files in parallel** with bounded concurrency (sequential per-file
uploads of 400+ files easily blow past any reasonable client-side timeout).
2. **Log progress** so the streaming logs visible in the viewer are useful.
3. **Write a completion marker** as the very last action of a successful run,
so the API and frontend can distinguish a fully-populated S3 directory from
a partial / interrupted upload.
The marker file the frontend looks for is `.windmill_clone_complete` at the
root of the per-commit directory:
```
gitrepos/{workspace}/{resource_path}/{commit_hash}/.windmill_clone_complete
```
The frontend passes `markerFile=.windmill_clone_complete` to the
`checkS3FolderExists` API, which only reports the folder as existing when this
exact file is present.
## Replacement `uploadDirectoryToS3` implementation
Replace the recursive sequential `uploadDirectoryToS3` function with a
batched-concurrent implementation, and write the marker after the walk
completes:
```ts
const UPLOAD_CONCURRENCY = 16
const CLONE_MARKER_FILE = ".windmill_clone_complete"
async function uploadDirectoryToS3(
directoryPath: string,
s3BasePath: string,
workspace: string,
) {
console.log(`Uploading ${directoryPath} -> ${s3BasePath}`)
// Walk the directory once, producing a flat list of (localPath, s3Key) pairs.
const tasks: { localPath: string; s3Key: string }[] = []
function walk(dir: string, s3Path: string) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = join(dir, entry.name)
const s3Key = s3Path ? `${s3Path}/${entry.name}` : entry.name
if (entry.isDirectory()) {
walk(fullPath, s3Key)
} else if (entry.isFile()) {
tasks.push({ localPath: fullPath, s3Key })
}
}
}
walk(directoryPath, s3BasePath)
console.log(`Discovered ${tasks.length} files to upload`)
// Bounded-concurrency upload pool.
let nextIndex = 0
let uploaded = 0
let lastReport = 0
async function worker() {
while (true) {
const idx = nextIndex++
if (idx >= tasks.length) return
const { localPath, s3Key } = tasks[idx]
const fileContent = fs.readFileSync(localPath)
const blob = new Blob([fileContent], {
type: "application/octet-stream",
})
await wmillclient.HelpersService.gitRepoViewerFileUpload({
workspace,
fileKey: s3Key,
requestBody: blob,
})
uploaded++
// Throttled progress log so 400+ files don't drown the log.
if (uploaded - lastReport >= 25 || uploaded === tasks.length) {
lastReport = uploaded
console.log(`Uploaded ${uploaded} / ${tasks.length} files`)
}
}
}
const workers = Array.from(
{ length: Math.min(UPLOAD_CONCURRENCY, tasks.length) },
() => worker(),
)
await Promise.all(workers)
// Write the completion marker LAST. Until this exists, the viewer treats the
// directory as not-yet-cloned.
const markerKey = `${s3BasePath}/${CLONE_MARKER_FILE}`
const markerBody = JSON.stringify({
completed_at: new Date().toISOString(),
file_count: tasks.length,
})
await wmillclient.HelpersService.gitRepoViewerFileUpload({
workspace,
fileKey: markerKey,
requestBody: new Blob([markerBody], { type: "application/json" }),
})
console.log(`Wrote completion marker: ${markerKey}`)
}
```
## Notes for review
- **Concurrency level**: 16 is a starting point; tune based on instance
storage backend. Too high will overwhelm the API; too low won't help.
- **Marker is the last write**: if any upload fails, the marker is never
written and the viewer correctly shows the state as incomplete.
- **No deletion of stale partials**: the script overwrites the same per-commit
paths on retry, so a partial upload + retry naturally heals. Old commit
directories from before this patch are unreachable through the UI but still
consume storage; an instance admin can prune them manually if desired.
- **Error propagation**: keep the existing `try/catch` in `main` so an upload
failure surfaces in the job result and is shown in the new viewer error
banner.
+10 -10
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.692.0",
"version": "1.693.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.692.0",
"version": "1.693.3",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -81,12 +81,12 @@
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.647.1",
"windmill-parser-wasm-py": "1.657.2",
"windmill-parser-wasm-py": "1.693.1",
"windmill-parser-wasm-r": "1.668.1",
"windmill-parser-wasm-regex": "1.692.0",
"windmill-parser-wasm-ruby": "1.526.1",
"windmill-parser-wasm-rust": "1.647.1",
"windmill-parser-wasm-ts": "1.657.2",
"windmill-parser-wasm-ts": "1.693.1",
"windmill-parser-wasm-wac": "1.668.6",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
@@ -13661,9 +13661,9 @@
"integrity": "sha512-u2qaMkupSdhJibxvkLh3r/y36IARvnYNTLXWvOKxcQ0G/BPUB4+yF5o/yf47vv9zUV5WZv4mrdsKDt/pZDYeDg=="
},
"node_modules/windmill-parser-wasm-py": {
"version": "1.657.2",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.657.2.tgz",
"integrity": "sha512-3CN2rziafgCWcZri812+CkzuaE3P3/7dXmV9lSDpK9ma6Esd4zkHRXUFSyRzQE/R7Fxj5mSmSNX6xTff8eX5mw=="
"version": "1.693.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.693.1.tgz",
"integrity": "sha512-qSXKxDsKjt1UOEK1NnpKJmDrXIx/sZaGHktC6+Sj6CfhLi1X/Qj37FalOq4Es5l2qzm2NPvk4jkCXsV1QdkcHA=="
},
"node_modules/windmill-parser-wasm-r": {
"version": "1.668.1",
@@ -13686,9 +13686,9 @@
"integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="
},
"node_modules/windmill-parser-wasm-ts": {
"version": "1.657.2",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.657.2.tgz",
"integrity": "sha512-tiOUVsMKTc85m/a2BKpgAN3xTz+OPrUhcjEBPJNTdzrQOir1G5WeNkQ403BW+d1qI0BAVWT0gZnJ+4AhavBP+w=="
"version": "1.693.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.693.1.tgz",
"integrity": "sha512-xrPgVWwQbOWJKiz68wBDNMrKVOtCY/utyhzSx0kFYIWm/QH/6L8k6LLjo4DOn+PnlBNRXc7qum0sDyB8IuuJYQ=="
},
"node_modules/windmill-parser-wasm-wac": {
"version": "1.668.6",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.692.0",
"version": "1.693.3",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -154,12 +154,12 @@
"windmill-parser-wasm-java": "1.510.1",
"windmill-parser-wasm-nu": "1.510.1",
"windmill-parser-wasm-php": "1.647.1",
"windmill-parser-wasm-py": "1.657.2",
"windmill-parser-wasm-py": "1.693.1",
"windmill-parser-wasm-r": "1.668.1",
"windmill-parser-wasm-regex": "1.692.0",
"windmill-parser-wasm-ruby": "1.526.1",
"windmill-parser-wasm-rust": "1.647.1",
"windmill-parser-wasm-ts": "1.657.2",
"windmill-parser-wasm-ts": "1.693.1",
"windmill-parser-wasm-wac": "1.668.6",
"windmill-parser-wasm-yaml": "1.593.0",
"windmill-sql-datatype-parser-wasm": "1.512.0",
+1 -1
View File
@@ -20,7 +20,7 @@ console.log('Running postinstall for root project');
import { x } from 'tar'
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-3df005f.tar.gz'
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-6715153.tar.gz'
const outputTarPath = path.join(process.cwd(), 'ui_builder.tar.gz')
const extractTo = path.join(process.cwd(), 'static/ui_builder/')
@@ -229,7 +229,7 @@
error = undefined
diffs = []
try {
const forkSettings = await WorkspaceService.getSettings({
const forkSettings = await WorkspaceService.getPublicSettings({
workspace: currentWorkspaceId
})
const datatables = forkSettings.datatable?.datatables ?? {}
@@ -364,7 +364,7 @@
const { schemaName, tableName } = drawerChange
const newTableDef = sourceSchema[schemaName]?.[tableName]
const forkSettings = await WorkspaceService.getSettings({
const forkSettings = await WorkspaceService.getPublicSettings({
workspace: currentWorkspaceId
})
const datatableConfig = forkSettings.datatable ?? { datatables: {} }
@@ -113,7 +113,7 @@
const CHANNEL_KEY = 'channel'
async function loadSlackResources() {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
if (!emptyString(settings.slack_name) && !emptyString(settings.slack_team_id)) {
workspaceConnectedToSlack = true
slack_team_name = settings.slack_name
@@ -124,7 +124,7 @@
}
async function loadTeamsResources() {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
if (!emptyString(settings.teams_team_name) && !emptyString(settings.teams_team_id)) {
workspaceConnectedToTeams = true
} else {
+188 -38
View File
@@ -4,9 +4,14 @@
import { Alert, Button } from './common'
import S3FilePickerInner from './S3FilePickerInner.svelte'
import { workspaceStore } from '$lib/stores'
import { Loader, Loader2 } from 'lucide-svelte'
import { base } from '$lib/base'
import { Loader, Loader2, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte'
import { hubPaths } from '$lib/hub'
import { tryEvery } from '$lib/utils'
import { sleep } from '$lib/utils'
const CLONE_MARKER_FILE = '.windmill_clone_complete'
const POLL_INTERVAL_MS = 1500
const MAX_POLL_DURATION_MS = 30 * 60 * 1000 // 30 minutes
let s3FilePicker: S3FilePickerInner | undefined = $state()
@@ -16,6 +21,12 @@
let pathExists = $state<boolean | null>(null)
let error = $state<string | null>(null)
// Live job state for the running clone
let runningJobId = $state<string | null>(null)
let runningLogs = $state<string>('')
let logsExpanded = $state<boolean>(false)
let cancelRequested = $state<boolean>(false)
interface Props {
gitRepoResourcePath: string
gitSshIdentity?: string[]
@@ -31,47 +42,128 @@
if (!workspace) return
const payload = {
workspace: workspace,
workspace,
resource_path: gitRepoResourcePath,
git_ssh_identity: gitSshIdentity,
commit: commitHash
}
isLoadingRepoClone = true
const jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.cloneRepoToS3forGitRepoViewer,
requestBody: payload,
skipPreprocessor: true
})
error = null
runningLogs = ''
cancelRequested = false
logsExpanded = true
let jobSuccess = false
await tryEvery({
tryCode: async () => {
const testResult = await JobService.getCompletedJob({ workspace, id: jobId })
jobSuccess = !!testResult.success
if (jobSuccess) {
await JobService.getCompletedJobResult({ workspace, id: jobId })
} else {
error = (testResult.result as any).error.message ?? 'Failed to clone'
}
},
timeoutCode: async () => {
let jobId: string
try {
jobId = await JobService.runScriptByPath({
workspace,
path: hubPaths.cloneRepoToS3forGitRepoViewer,
requestBody: payload,
skipPreprocessor: true
})
} catch (err: any) {
error = `Failed to start clone job: ${err?.message ?? err}`
isLoadingRepoClone = false
return
}
runningJobId = jobId
const startedAt = Date.now()
let logOffset = 0
let isCompleted = false
while (!isCompleted) {
if (cancelRequested) {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: `job timed out after 60s` }
requestBody: { reason: 'cancelled by user from git repo viewer' }
})
} catch (err) {}
},
interval: 1000,
timeout: 60000
})
isLoadingRepoClone = false
if (jobSuccess) {
pathExists = true
} catch (_err) {}
error = 'Repository clone cancelled.'
break
}
if (Date.now() - startedAt > MAX_POLL_DURATION_MS) {
try {
await JobService.cancelQueuedJob({
workspace,
id: jobId,
requestBody: { reason: 'git repo clone exceeded 30 min' }
})
} catch (_err) {}
error = `Repository clone exceeded ${Math.round(
MAX_POLL_DURATION_MS / 60000
)} min and was cancelled.`
break
}
try {
const update = await JobService.getJobUpdates({
workspace,
id: jobId,
running: true,
logOffset
})
if (update.new_logs) {
runningLogs += update.new_logs
}
if (typeof update.log_offset === 'number') {
logOffset = update.log_offset
}
if (update.completed) {
isCompleted = true
break
}
} catch (_err) {
// Transient API failure — keep polling.
}
await sleep(POLL_INTERVAL_MS)
}
if (isCompleted) {
try {
const completed = await JobService.getCompletedJob({ workspace, id: jobId })
if (completed.success) {
// Verify the marker so partial uploads aren't shown as complete.
const s3Path = `gitrepos/${workspace}/${gitRepoResourcePath}/${commitHash}/`
try {
const check = await HelpersService.checkS3FolderExists({
workspace,
fileKey: s3Path,
markerFile: CLONE_MARKER_FILE
})
if (check.exists) {
pathExists = true
logsExpanded = false
} else {
pathExists = false
error =
'Clone job finished but the completion marker is missing. The repository may have been synced with an older clone script — re-run to refresh.'
}
} catch (err: any) {
error = `Failed to verify clone completion: ${err?.message ?? err}`
}
} else {
const errMsg =
(completed.result as any)?.error?.message ??
(completed.result as any)?.error ??
'Clone job failed without an error message.'
error = typeof errMsg === 'string' ? errMsg : JSON.stringify(errMsg)
}
} catch (err: any) {
error = `Failed to read clone job result: ${err?.message ?? err}`
}
}
isLoadingRepoClone = false
}
function cancelClone() {
cancelRequested = true
}
async function fetchCommitHash() {
@@ -100,7 +192,8 @@
const s3Path = `gitrepos/${$workspaceStore}/${gitRepoResourcePath}/${commitHash}/`
const pathCheck = await HelpersService.checkS3FolderExists({
workspace: $workspaceStore!,
fileKey: s3Path
fileKey: s3Path,
markerFile: CLONE_MARKER_FILE
})
pathExists = pathCheck.exists && pathCheck.is_folder
} catch (err: any) {
@@ -126,19 +219,76 @@
})
</script>
{#if error}
<Alert type="error" title="Error Loading Repository">
<p>{error}</p>
</Alert>
{#if error && !isLoadingRepoClone}
<div class="p-4 space-y-3">
<Alert type="error" title="Error Loading Repository">
<p class="whitespace-pre-wrap break-words">{error}</p>
{#if runningJobId}
<a
class="inline-flex items-center gap-1 mt-2 text-sm underline"
href={`${base}/run/${runningJobId}?workspace=${$workspaceStore}`}
target="_blank"
rel="noreferrer noopener"
>
View job logs <ExternalLink size={12} />
</a>
{/if}
</Alert>
{#if runningLogs}
<details class="text-xs">
<summary class="cursor-pointer text-secondary">Last clone logs</summary>
<pre
class="mt-2 p-2 bg-surface-secondary rounded max-h-64 overflow-auto whitespace-pre-wrap"
>{runningLogs}</pre
>
</details>
{/if}
<Button onclick={populateS3WithGitRepo} color="blue">Retry</Button>
</div>
{:else if isLoadingCommitHash}
<div class="flex items-center gap-2 p-4">
<Loader2 class="h-4 w-4 animate-spin" />
<span class="text-secondary">Fetching latest commit hash...</span>
</div>
{:else if isLoadingRepoClone}
<div class="flex items-center gap-2 p-4">
<Loader class="h-4 w-4 animate-spin" />
<span class="text-secondary">Cloning repository...</span>
<div class="p-4 space-y-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Loader class="h-4 w-4 animate-spin" />
<span class="text-secondary">Cloning repository...</span>
</div>
<div class="flex items-center gap-2">
{#if runningJobId}
<a
class="inline-flex items-center gap-1 text-xs underline text-secondary"
href={`${base}/run/${runningJobId}?workspace=${$workspaceStore}`}
target="_blank"
rel="noreferrer noopener"
>
Job <ExternalLink size={12} />
</a>
{/if}
<Button size="xs" color="light" variant="border" onclick={cancelClone}>Cancel</Button>
</div>
</div>
<button
type="button"
class="flex items-center gap-1 text-xs text-secondary hover:text-primary"
onclick={() => (logsExpanded = !logsExpanded)}
>
{#if logsExpanded}
<ChevronDown size={12} />
{:else}
<ChevronRight size={12} />
{/if}
Logs
</button>
{#if logsExpanded}
<pre
class="mt-1 p-2 bg-surface-secondary rounded text-xs max-h-64 overflow-auto whitespace-pre-wrap"
>{runningLogs || '(waiting for output...)'}</pre
>
{/if}
</div>
{:else if commitHash && isCheckingPathExists}
<div class="flex items-center gap-2 p-4">
@@ -82,7 +82,7 @@
async function checkS3Storage() {
try {
if ($workspaceStore) {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore })
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore })
s3StorageConfigured = settings.large_file_storage?.s3_resource_path !== undefined
}
} catch (error) {
@@ -371,7 +371,7 @@
function getSettingsForCategory(category: string) {
if (category === 'Auth/OAuth/SAML') {
return scimSamlSetting
return [...(settings[category] ?? []), ...scimSamlSetting]
}
const base = settings[category] ?? []
// In quick setup, reorder Core: base settings (without license_key), then extras from Jobs
@@ -502,27 +502,20 @@
}
export function discardCategory(category: string) {
const categorySettings = getSettingsForCategory(category)
for (const s of categorySettings) {
const v = initialValues[s.key]
$values[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
}
if (category === 'Auth/OAuth/SAML') {
for (const s of scimSamlSetting) {
const v = initialValues[s.key]
$values[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
}
oauths = JSON.parse(JSON.stringify(initialOauths))
requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth
const account_identifier =
initialOauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier
snowflakeAccountIdentifier = account_identifier ?? ''
} else {
const categorySettings = getSettingsForCategory(category)
for (const s of categorySettings) {
const v = initialValues[s.key]
$values[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
}
if (category === 'Registries') {
const v = initialValues['workspace_registries']
$values['workspace_registries'] =
v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
}
} else if (category === 'Registries') {
const v = initialValues['workspace_registries']
$values['workspace_registries'] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
}
}
+52 -2
View File
@@ -96,6 +96,7 @@
let smtpConfigured: boolean | undefined = $state(undefined)
let disablePasswordLogin = $state(false)
let autoRedirecting = $state(false)
let oauthFlowDone = false
type OAuthLogin = {
type: string
@@ -301,18 +302,23 @@
if (data.type === 'error') {
sendUserToast(data.error, true)
} else if (data.type === 'success') {
onLoginSuccess?.()
finishOauthFlow('postMessage')
}
}
function handleStorageEvent(event) {
if (event.key === 'oauth-success') {
try {
processPopupData(JSON.parse(event.newValue))
const data = JSON.parse(event.newValue)
console.log('oauth-success from storage')
// Clean up
localStorage.removeItem('oauth-success')
window.removeEventListener('storage', handleStorageEvent)
if (data?.type === 'success') {
finishOauthFlow('storage')
} else {
processPopupData(data)
}
} catch (e) {
console.error('Could not process oauth-success from storage', e)
}
@@ -321,6 +327,16 @@
}
}
function finishOauthFlow(via: 'postMessage' | 'storage' | 'poll', win?: Window) {
if (oauthFlowDone) return
oauthFlowDone = true
console.log(`oauth: signaled via ${via}`)
if (win && !win.closed) win.close()
window.removeEventListener('message', popupListener)
window.removeEventListener('storage', handleStorageEvent)
onLoginSuccess?.()
}
onDestroy(() => {
window.removeEventListener('message', popupListener)
window.removeEventListener('storage', handleStorageEvent)
@@ -351,6 +367,13 @@
window.removeEventListener('storage', handleStorageEvent)
return false
}
// Safety net for Safari: when the popup is opened without a fresh user
// gesture (auto-login), ITP can partition cookies/localStorage between
// popup and parent, so neither the close cookie, the postMessage, nor
// the localStorage 'oauth-success' signal reaches us. The session
// cookie is set same-origin and isn't subject to that partitioning, so
// polling whoami catches the success and lets us force-close the popup.
pollForLoginSuccess(win)
return true
} else {
localStorage.setItem('closeUponLogin', 'false')
@@ -359,6 +382,33 @@
}
}
function pollForLoginSuccess(win: Window) {
const startedAt = Date.now()
const interval = setInterval(async () => {
if (oauthFlowDone) {
clearInterval(interval)
return
}
if (Date.now() - startedAt > 5 * 60 * 1000) {
clearInterval(interval)
console.log('oauth: poll timed out after 5 minutes')
return
}
if (win.closed) {
clearInterval(interval)
console.log('oauth: popup closed before login completed')
return
}
try {
await UserService.getCurrentEmail()
} catch {
return
}
clearInterval(interval)
finishOauthFlow('poll', win)
}, 1500)
}
function redirectSaml(): boolean {
if (!saml) {
sendUserToast('No SAML login available', true)
@@ -73,7 +73,7 @@
let isSlackConnectedWorkspace = $state(false)
async function getWorspaceSlackSetting() {
const settings = await WorkspaceService.getSettings({
const settings = await WorkspaceService.getPublicSettings({
workspace: $workspaceStore!
})
if (settings.slack_name) {
@@ -18,6 +18,6 @@ async function getDeployUiSettingsInner(): Promise<WorkspaceDeployUISettings> {
if (!get(enterpriseLicense)) {
return ALL_DEPLOYABLE
}
let settings = await WorkspaceService.getSettings({ workspace: get(workspaceStore)! })
let settings = await WorkspaceService.getPublicSettings({ workspace: get(workspaceStore)! })
return settings.deploy_ui ?? ALL_DEPLOYABLE
}
@@ -14,21 +14,24 @@
interface Props {
workspaceId: string
scope: string
initialScope?: string
}
let { workspaceId, scope = $bindable() }: Props = $props()
let { workspaceId, scope = $bindable(), initialScope }: Props = $props()
let selectedMode = $state<'favorites' | 'all' | 'folder' | 'custom'>('favorites')
let selectedFolders = $state<string[]>([])
const parsedInitial = parseInitialScope(initialScope)
let selectedMode = $state<'favorites' | 'all' | 'folder' | 'custom'>(parsedInitial.mode)
let selectedFolders = $state<string[]>(parsedInitial.folders)
let allFolders = $state<string[]>([])
let loadingFolders = $state(false)
let folderNamesCache = new Map<string, string[]>()
let selectedScripts = $state<string[]>([])
let selectedFlows = $state<string[]>([])
let selectedEndpoints = $state<string[]>([])
let customScriptPatterns = $state<string>('')
let customFlowPatterns = $state<string>('')
let newMcpApps = $state<string[]>([])
let selectedScripts = $state<string[]>(parsedInitial.scripts)
let selectedFlows = $state<string[]>(parsedInitial.flows)
let selectedEndpoints = $state<string[]>(parsedInitial.endpoints)
let customScriptPatterns = $state<string>(parsedInitial.scriptPatterns)
let customFlowPatterns = $state<string>(parsedInitial.flowPatterns)
let newMcpApps = $state<string[]>(parsedInitial.hubApps)
let allScripts = $state<string[]>([])
let allFlows = $state<string[]>([])
@@ -47,6 +50,101 @@
.filter((p) => p.length > 0)
}
type ParsedScope = {
mode: 'favorites' | 'all' | 'folder' | 'custom'
folders: string[]
scripts: string[]
flows: string[]
endpoints: string[]
scriptPatterns: string
flowPatterns: string
hubApps: string[]
}
function parseInitialScope(input: string | undefined): ParsedScope {
const empty: ParsedScope = {
mode: 'favorites',
folders: [],
scripts: [],
flows: [],
endpoints: [],
scriptPatterns: '',
flowPatterns: '',
hubApps: []
}
if (!input) return empty
const parts = input.split(/\s+/).filter((p) => p.length > 0)
if (parts.length === 0) return empty
const byKind: Record<string, string[]> = {}
let mode: ParsedScope['mode'] = 'custom'
const hubApps: string[] = []
for (const part of parts) {
if (part === 'mcp:favorites') {
mode = 'favorites'
} else if (part === 'mcp:all') {
mode = 'all'
} else if (part.startsWith('mcp:hub:')) {
hubApps.push(...parsePatterns(part.slice('mcp:hub:'.length)))
} else if (part.startsWith('mcp:scripts:')) {
byKind.scripts = parsePatterns(part.slice('mcp:scripts:'.length))
} else if (part.startsWith('mcp:flows:')) {
byKind.flows = parsePatterns(part.slice('mcp:flows:'.length))
} else if (part.startsWith('mcp:endpoints:')) {
byKind.endpoints = parsePatterns(part.slice('mcp:endpoints:'.length))
}
}
// Detect folder mode: scripts and flows are exclusively `f/X/*` patterns
// for the same set of folders, and endpoints is exactly `*`.
const folderRe = /^f\/([^/]+)\/\*$/
const scriptFolders = (byKind.scripts ?? []).map((p) => p.match(folderRe)?.[1])
const flowFolders = (byKind.flows ?? []).map((p) => p.match(folderRe)?.[1])
const allScriptsAreFolders = scriptFolders.length > 0 && scriptFolders.every((f) => !!f)
const allFlowsAreFolders = flowFolders.length > 0 && flowFolders.every((f) => !!f)
const sameFolders =
allScriptsAreFolders &&
allFlowsAreFolders &&
scriptFolders.length === flowFolders.length &&
scriptFolders.every((f, i) => f === flowFolders[i])
const endpointsIsAll = byKind.endpoints?.length === 1 && byKind.endpoints[0] === '*'
if (mode !== 'favorites' && mode !== 'all' && sameFolders && endpointsIsAll) {
return {
mode: 'folder',
folders: scriptFolders.filter((f): f is string => !!f),
scripts: [],
flows: [],
endpoints: [],
scriptPatterns: '',
flowPatterns: '',
hubApps
}
}
if (mode === 'favorites' || mode === 'all') {
return { ...empty, mode, hubApps }
}
// Custom mode: split each list into "selectable" entries (later filtered
// against allScripts/allFlows once loaded) and free-form patterns.
// We can't know yet which are real paths vs wildcard patterns, so pass
// everything as patterns; once allScripts/allFlows load, $effect will
// move matching entries into selectedScripts/selectedFlows.
return {
mode: 'custom',
folders: [],
scripts: [],
flows: [],
endpoints: byKind.endpoints ?? [],
scriptPatterns: (byKind.scripts ?? []).join(','),
flowPatterns: (byKind.flows ?? []).join(','),
hubApps
}
}
// Compute scope string from selections
$effect(() => {
let scopeParts: string[] = []
@@ -112,9 +210,9 @@
try {
loadingFolders = true
const excludedFolders = ['app_groups', 'app_custom', 'app_themes']
const names = (
await FolderService.listFolderNames({ workspace })
).filter((x) => !excludedFolders.includes(x))
const names = (await FolderService.listFolderNames({ workspace })).filter(
(x) => !excludedFolders.includes(x)
)
folderNamesCache.set(workspace, names)
allFolders = names
} catch {
@@ -261,6 +359,36 @@
}
})
// One-shot: once allScripts/allFlows are loaded, split the
// initial pattern text into known paths (selectedScripts/Flows) vs
// remaining wildcards/unknowns (kept in pattern textbox).
let initialSplitDone = $state(false)
$effect(() => {
if (initialSplitDone || selectedMode !== 'custom') return
if (allScripts.length === 0 && allFlows.length === 0) return
const scriptSet = new Set(allScripts)
const flowSet = new Set(allFlows)
const scriptParts = parsePatterns(customScriptPatterns)
const knownScripts = scriptParts.filter((p) => scriptSet.has(p))
const remainingScripts = scriptParts.filter((p) => !scriptSet.has(p))
const flowParts = parsePatterns(customFlowPatterns)
const knownFlows = flowParts.filter((p) => flowSet.has(p))
const remainingFlows = flowParts.filter((p) => !flowSet.has(p))
if (knownScripts.length > 0) {
selectedScripts = [...new Set([...selectedScripts, ...knownScripts])]
customScriptPatterns = remainingScripts.join(',')
}
if (knownFlows.length > 0) {
selectedFlows = [...new Set([...selectedFlows, ...knownFlows])]
customFlowPatterns = remainingFlows.join(',')
}
initialSplitDone = true
})
const warning = $derived(
selectedMode === 'all'
? 'Create your first scripts or flows to make them available via MCP.'
@@ -6,8 +6,7 @@
import Toggle from '../Toggle.svelte'
import { UserService, type NewToken } from '$lib/gen'
import TokenDisplay from './TokenDisplay.svelte'
import ScopeSelector from './ScopeSelector.svelte'
import McpScopeSelector from '../mcp/McpScopeSelector.svelte'
import ScopesPicker from './ScopesPicker.svelte'
import TextInput from '../text_input/TextInput.svelte'
import Select from '../select/Select.svelte'
@@ -43,12 +42,10 @@
let newTokenExpiration = $state<number | undefined>(undefined)
let newTokenWorkspace = $state<string | undefined>(untrack(() => defaultNewTokenWorkspace))
let mcpCreationMode = $state(false)
let mcpScope = $state('mcp:favorites')
let lastRequestedMcpMode = $state<boolean | undefined>(undefined)
let mcpLabelAutofilled = $state(false)
let customScopes = $state<string[]>([])
let showCustomScopes = $state(false)
let pickedScopes = $state<string[] | null>(null)
function ensureCurrentWorkspaceIncluded(
workspacesList: UserWorkspace[],
@@ -96,12 +93,7 @@
date = new Date(new Date().getTime() + newTokenExpiration * 1000)
}
let tokenScopes = scopes
if (mcpMode) {
tokenScopes = mcpScope.split(' ').filter((s) => s.length > 0)
} else if (showCustomScopes && customScopes.length > 0) {
tokenScopes = customScopes
}
const tokenScopes = scopes ?? pickedScopes ?? undefined
const createdToken = await UserService.createToken({
requestBody: {
@@ -195,77 +187,58 @@
</div>
{/if}
{#if !mcpCreationMode && (!scopes || scopes.length === 0)}
<div class="flex flex-col gap-2">
<Toggle
checked={showCustomScopes}
on:change={(e) => {
showCustomScopes = e.detail
}}
options={{
right: 'Limit token permissions',
rightTooltip:
'By default, tokens have full API access. Enable this to restrict the token to specific scopes.'
}}
size="xs"
/>
{#if showCustomScopes}
<ScopeSelector bind:selectedScopes={customScopes} />
{/if}
</div>
{#if !scopes || scopes.length === 0}
<ScopesPicker
mode={mcpCreationMode ? 'mcp' : 'standard'}
workspaceId={newTokenWorkspace || $workspaceStore || ''}
bind:value={pickedScopes}
/>
{/if}
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
{#if mcpCreationMode}
<div class="col-span-2">
<McpScopeSelector
workspaceId={newTokenWorkspace || $workspaceStore || ''}
bind:scope={mcpScope}
/>
</div>
{#if !lockWorkspace}
<div>
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
<Select
bind:value={newTokenWorkspace}
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
/>
</div>
{/if}
{/if}
{#if !mcpOnly}
{#if mcpCreationMode}
{#if !lockWorkspace}
<div>
<span class="block mb-1 text-emphasis text-xs font-semibold"
>Label <span class="text-xs text-primary">(optional)</span></span
>
<TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" />
</div>
{/if}
{#if !mcpCreationMode}
<div>
<span class="block mb-1 text-xs text-emphasis font-semibold"
>Expires In <span class="text-xs text-primary">(optional)</span></span
>
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
<Select
bind:value={newTokenExpiration}
placeholder="No expiration"
inputClass="w-full"
items={[
{ label: 'No expiration', value: undefined },
{ label: '15 minutes', value: 15 * 60 },
{ label: '30 minutes', value: 30 * 60 },
{ label: '1 hour', value: 1 * 60 * 60 },
{ label: '1 day', value: 1 * 24 * 60 * 60 },
{ label: '7 days', value: 7 * 24 * 60 * 60 },
{ label: '30 days', value: 30 * 24 * 60 * 60 },
{ label: '90 days', value: 90 * 24 * 60 * 60 }
]}
bind:value={newTokenWorkspace}
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
/>
</div>
{/if}
{/if}
{#if !mcpOnly}
<div>
<span class="block mb-1 text-emphasis text-xs font-semibold"
>Label <span class="text-xs text-primary">(optional)</span></span
>
<TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" />
</div>
{/if}
{#if !mcpCreationMode}
<div>
<span class="block mb-1 text-xs text-emphasis font-semibold"
>Expires In <span class="text-xs text-primary">(optional)</span></span
>
<Select
bind:value={newTokenExpiration}
placeholder="No expiration"
inputClass="w-full"
items={[
{ label: 'No expiration', value: undefined },
{ label: '15 minutes', value: 15 * 60 },
{ label: '30 minutes', value: 30 * 60 },
{ label: '1 hour', value: 1 * 60 * 60 },
{ label: '1 day', value: 1 * 24 * 60 * 60 },
{ label: '7 days', value: 7 * 24 * 60 * 60 },
{ label: '30 days', value: 30 * 24 * 60 * 60 },
{ label: '90 days', value: 90 * 24 * 60 * 60 }
]}
/>
</div>
{/if}
</div>
<div class="mt-4 flex justify-end gap-2 flex-row">
@@ -281,8 +254,7 @@
{/if}
<Button
on:click={() => createToken(mcpCreationMode)}
disabled={mcpCreationMode &&
(newTokenWorkspace == undefined || !mcpScope || mcpScope.trim().length === 0)}
disabled={mcpCreationMode && (newTokenWorkspace == undefined || !pickedScopes)}
variant="accent"
>
{mcpCreationMode ? 'Generate MCP URL' : 'New token'}
@@ -0,0 +1,76 @@
<script lang="ts">
import { UserService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
import Button from '../common/button/Button.svelte'
import Modal from '../common/modal/Modal.svelte'
import ScopesPicker from './ScopesPicker.svelte'
interface Props {
open: boolean
tokenPrefix?: string
initialScopes?: string[]
tokenWorkspaceId?: string
onSaved?: () => void
}
let {
open = $bindable(),
tokenPrefix,
initialScopes,
tokenWorkspaceId,
onSaved
}: Props = $props()
// Treat as MCP only when *all* existing scopes are mcp:* — mixed-scope or
// null-scope tokens fall back to the standard picker so non-MCP scopes are
// never silently dropped.
const isMcp = $derived(
(initialScopes ?? []).length > 0 && (initialScopes ?? []).every((s) => s.startsWith('mcp:'))
)
const mcpWorkspaceId = $derived(tokenWorkspaceId ?? $workspaceStore ?? '')
let pickedScopes = $state<string[] | null>(null)
let saving = $state(false)
async function save() {
if (!tokenPrefix) return
saving = true
try {
await UserService.updateTokenScopes({
tokenPrefix,
requestBody: { scopes: pickedScopes }
})
sendUserToast('Token scopes updated')
onSaved?.()
open = false
} catch (err) {
sendUserToast(`Failed to update scopes: ${err.body ?? err.message}`, true)
} finally {
saving = false
}
}
const saveDisabled = $derived(saving || !tokenPrefix || (isMcp && !pickedScopes))
</script>
<Modal bind:open title="Edit token scopes" class="!max-w-3xl">
<div class="flex flex-col gap-3">
<div class="text-xs text-secondary">
Token <span class="font-mono">{tokenPrefix}****</span>
</div>
{#key tokenPrefix}
<ScopesPicker
mode={isMcp ? 'mcp' : 'standard'}
workspaceId={mcpWorkspaceId}
{initialScopes}
bind:value={pickedScopes}
/>
{/key}
</div>
{#snippet actions()}
<Button size="sm" variant="accent" disabled={saveDisabled} on:click={save}>Save</Button>
{/snippet}
</Modal>
@@ -0,0 +1,59 @@
<script lang="ts">
import Toggle from '../Toggle.svelte'
import ScopeSelector from './ScopeSelector.svelte'
import McpScopeSelector from '../mcp/McpScopeSelector.svelte'
interface Props {
mode: 'standard' | 'mcp'
workspaceId?: string
/** Existing scopes (used for both initial standard selection and parsing MCP scope) */
initialScopes?: string[]
/** Final scope value: null = unrestricted/full access, array = explicit list */
value: string[] | null
}
let { mode, workspaceId = '', initialScopes, value = $bindable() }: Props = $props()
const initialMcpScope = $derived(
(initialScopes ?? []).length > 0 ? (initialScopes ?? []).join(' ') : undefined
)
// Standard-mode local state, seeded from initialScopes if any.
let limited = $state((initialScopes ?? []).length > 0)
let standardScopes = $state<string[]>([...(initialScopes ?? [])])
// MCP-mode local state. When no initial scope is provided we fall back
// to `mcp:favorites` (matches the create-token default).
let mcpScope = $state(initialMcpScope ?? 'mcp:favorites')
$effect(() => {
if (mode === 'mcp') {
const parts = mcpScope
.split(' ')
.map((s) => s.trim())
.filter((s) => s.length > 0)
value = parts.length > 0 ? parts : null
} else {
value = limited && standardScopes.length > 0 ? standardScopes : null
}
})
</script>
{#if mode === 'standard'}
<div class="flex flex-col gap-2">
<Toggle
bind:checked={limited}
options={{
right: 'Limit token permissions',
rightTooltip:
'When off, the token has full API access. Turn on to restrict it to specific scopes.'
}}
size="xs"
/>
{#if limited}
<ScopeSelector bind:selectedScopes={standardScopes} />
{/if}
</div>
{:else}
<McpScopeSelector {workspaceId} bind:scope={mcpScope} initialScope={initialMcpScope} />
{/if}
@@ -5,10 +5,11 @@
import { UserService, type TruncatedToken } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import CreateToken from './CreateToken.svelte'
import EditTokenScopesModal from './EditTokenScopesModal.svelte'
import Button from '../common/button/Button.svelte'
import Badge from '../common/badge/Badge.svelte'
import Alert from '../common/alert/Alert.svelte'
import { Trash } from 'lucide-svelte'
import { Pen, Trash } from 'lucide-svelte'
// --- Props ---
interface Props {
@@ -33,6 +34,10 @@
let tokens = $state<TruncatedToken[]>([])
let tokenPage = $state(1)
let newTokenLabel = $state<string | undefined>(untrack(() => defaultNewTokenLabel))
let editingToken = $state<
{ prefix: string; scopes: string[] | undefined; workspaceId: string | undefined } | undefined
>(undefined)
let editModalOpen = $state(false)
$effect(() => {
listTokens()
@@ -97,6 +102,19 @@
listTokens()
}
function handleEditClick(
tokenPrefix: string,
tokenScopes: string[] | undefined,
tokenWorkspaceId: string | undefined
) {
editingToken = {
prefix: tokenPrefix,
scopes: tokenScopes,
workspaceId: tokenWorkspaceId
}
editModalOpen = true
}
async function listTokens(): Promise<void> {
tokens = await UserService.listTokens({
excludeEphemeral: true,
@@ -123,7 +141,11 @@
</div>
{#if expiringSoonCount > 0}
<div class="mb-2">
<Alert type="warning" title="{expiringSoonCount} token{expiringSoonCount > 1 ? 's' : ''} expiring within 7 days" size="xs" />
<Alert
type="warning"
title="{expiringSoonCount} token{expiringSoonCount > 1 ? 's' : ''} expiring within 7 days"
size="xs"
/>
</div>
{/if}
<CreateToken
@@ -136,20 +158,19 @@
/>
<div class="overflow-auto grow min-h-64 max-h-2/3">
<TableCustom>
{#snippet headerRow()}
<tr >
<tr>
<th>Prefix</th>
<th>Label</th>
<th>Expiration</th>
<th>Scopes</th>
<th></th>
</tr>
{/snippet}
{/snippet}
{#snippet body()}
<tbody>
{#if tokens && tokens.length > 0}
{#each tokens as { token_prefix, expiration, label, scopes } (token_prefix)}
{#each tokens as { token_prefix, expiration, label, scopes, workspace_id } (token_prefix)}
{@const badge = expirationBadge(expiration, label)}
<tr>
<td class="w-32 text-xs text-primary">{token_prefix}****</td>
@@ -166,15 +187,29 @@
class="min-w-0 max-w-48 truncate text-xs text-secondary"
title={scopes?.join(', ') ?? ''}>{scopes?.join(', ') ?? ''}</td
>
<td class="w-16 text-center">
<Button
variant="subtle"
destructive
on:click={() => handleDeleteClick(token_prefix)}
size="xs"
startIcon={{ icon: Trash }}
iconOnly
/>
<td class="w-24 text-center">
<div class="flex items-center justify-center gap-1">
<Button
variant="subtle"
on:click={() =>
handleEditClick(
token_prefix,
scopes ?? undefined,
workspace_id ?? undefined
)}
size="xs"
startIcon={{ icon: Pen }}
iconOnly
/>
<Button
variant="subtle"
destructive
on:click={() => handleDeleteClick(token_prefix)}
size="xs"
startIcon={{ icon: Trash }}
iconOnly
/>
</div>
</td>
</tr>
{/each}
@@ -198,3 +233,11 @@
</div>
</div>
</div>
<EditTokenScopesModal
bind:open={editModalOpen}
tokenPrefix={editingToken?.prefix}
initialScopes={editingToken?.scopes}
tokenWorkspaceId={editingToken?.workspaceId}
onSaved={listTokens}
/>
@@ -94,7 +94,7 @@
async function loadForkedDatatables() {
if (!$workspaceStore) return
try {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore })
const settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore })
const datatables = settings.datatable?.datatables ?? {}
forkedDatatables = Object.entries(datatables)
.filter(([_, dt]) => dt.forked_from != null)
@@ -91,8 +91,8 @@ export function useNestedRestartState(opts: {
return
}
selectedJobStepIsTopLevel =
job.flow_status.modules.findIndex((m) => m.id === selectedJobStep) >= 0
const isTopLevel = job.flow_status.modules.findIndex((m) => m.id === selectedJobStep) >= 0
selectedJobStepIsTopLevel = isTopLevel
const moduleDefinition = job.raw_flow?.modules.find((m) => m.id === selectedJobStep)
if (moduleDefinition?.value.type === 'forloopflow') {
selectedJobStepType = 'forloop'
@@ -107,7 +107,10 @@ export function useNestedRestartState(opts: {
selectedJobStepType = 'single'
}
if (selectedJobStepIsTopLevel) return
// Read from the local — reading the `$state` we just wrote would register
// it as a dependency of this effect, causing `effect_update_depth_exceeded`
// because each write would reschedule the effect.
if (isTopLevel) return
// Inline-expanded subflow: id is `subflow:outerStep:[innerSubflow:...]<leaf>`.
// The graph adds the `subflow:` prefix only for subflow expansions, so each
+1 -4
View File
@@ -42,8 +42,5 @@
"discordReport": "hub/9085/discord",
"smtpReport": "hub/9086/smtp",
"appReport": "hub/28076/app-report",
"cloneRepoToS3forGitRepoViewer_0": "hub/19825/clone_repo_and_upload_to_instance_storage",
"cloneRepoToS3forGitRepoViewer_1": "hub/19827/clone_repo_and_upload_to_instance_storage",
"cloneRepoToS3forGitRepoViewer_2": "hub/28175/clone_repo_and_upload_to_instance_storage",
"cloneRepoToS3forGitRepoViewer": "hub/28182/clone_repo_and_upload_to_instance_storage"
"cloneRepoToS3forGitRepoViewer": "hub/28216/clone_repo_and_upload_to_instance_storage"
}
@@ -363,7 +363,7 @@
async function loadCriticalAlertsMuted() {
let g_muted = true
const ws_muted =
(await WorkspaceService.getSettings({ workspace: $workspaceStore! })).mute_critical_alerts ||
(await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })).mute_critical_alerts ||
false
if ($superadmin) {
@@ -71,7 +71,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -74,7 +74,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -359,7 +359,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -71,7 +71,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -63,7 +63,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -58,7 +58,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -63,7 +63,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -70,7 +70,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -435,7 +435,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -91,7 +91,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -61,7 +61,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -471,7 +471,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -57,7 +57,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -184,7 +184,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -57,7 +57,7 @@
deployUiSettings = ALL_DEPLOYABLE
return
}
let settings = await WorkspaceService.getSettings({ workspace: $workspaceStore! })
let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! })
deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE
}
getDeployUiSettings()
@@ -21,15 +21,13 @@
let state = page.url.searchParams.get('state') ?? undefined
onMount(async () => {
// const closeCookie = getAndDeleteCookie('close')
// console.log('closeCookie', closeCookie)
const rawRd = localStorage.getItem('rd')
if (rawRd) {
localStorage.removeItem('rd')
}
const rd = rawRd?.startsWith('http') && !isValidLogoutRedirect(rawRd) ? null : rawRd
const cookieCloseUponLogin = getCookie('close') == 'true'
const closeUponLogin = cookieCloseUponLogin ?? localStorage.getItem('closeUponLogin') == 'true'
const closeUponLogin =
getCookie('close') == 'true' || localStorage.getItem('closeUponLogin') == 'true'
if (error) {
sendUserToast(`Error trying to login with ${clientName} ${error}`, true)
if (closeUponLogin) {
+1 -2
View File
@@ -35,8 +35,7 @@ const config = {
'rubendev.wimill.xyz',
'windmill.xyz',
'app.windmill.xyz',
'public.windmill.xyz',
'hugo.ngrok.pro'
'public.windmill.xyz'
],
port: parseInt(process.env.FRONTEND_PORT) || 3000,
cors: { origin: '*' },
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.692.0"
wmill = ">=1.693.3"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.692.0
version: 1.693.3
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.692.0'
ModuleVersion = '1.693.3'
# Supported PSEditions
# CompatiblePSEditions = @()
+2 -1
View File
@@ -22,9 +22,10 @@ sed -z "
npx @redocly/openapi-cli@latest bundle openapi/openapi.yaml > openapi-bundled.yaml
sed -z 's/FlowModuleValue:/FlowModuleValue2:/' openapi-bundled.yaml > openapi-decycled.yaml
sed -z 's/FlowModuleValue:/FlowModuleValue2:/;s/RestartedFrom:/RestartedFrom2:/' openapi-bundled.yaml > openapi-decycled.yaml
echo " FlowModuleValue: {}" >> openapi-decycled.yaml
echo " RestartedFrom: {}" >> openapi-decycled.yaml
npx @redocly/openapi-cli@latest bundle openapi-decycled.yaml --ext json -d > openapi-deref.json
sed '$d' .gitignore > .gitignore2
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.692.0"
version = "1.693.3"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1
View File
@@ -2,3 +2,4 @@ export * from './prompts';
export declare function getScriptPrompt(language: string): string;
export declare function getFlowPrompt(): string;
export declare function getDatatableSdkReference(): string;
export declare function getWorkflowAsCodePrompt(): string;
+9
View File
@@ -45,3 +45,12 @@ export function getDatatableSdkReference(): string {
prompts.DATATABLE_SDK_PYTHON
].filter(Boolean).join('\n\n');
}
// Helper to combine prompts for Workflow-as-Code scripts
export function getWorkflowAsCodePrompt(): string {
return [
prompts.WORKFLOW_AS_CODE_BASE,
prompts.WAC_SDK_TYPESCRIPT,
prompts.WAC_SDK_PYTHON
].filter(Boolean).join('\n\n');
}
File diff suppressed because one or more lines are too long
+399
View File
@@ -324,6 +324,186 @@ Reference a specific resource using \`$res:\` prefix:
\`\`\`
`;
export const WORKFLOW_AS_CODE_BASE = `# Windmill Workflow-as-Code Writing Guide
## Scope
Use this guide when writing or modifying Windmill Workflow-as-Code (WAC) scripts.
WAC is authored as a Windmill script and deployed with the normal script workflow. It is not an OpenFlow YAML flow.
Supported WAC authoring targets:
- TypeScript scripts that import from \`windmill-client\`
- Python 3 scripts that import from \`wmill\`
## File Shape
TypeScript:
\`\`\`typescript
import {
task,
taskScript,
taskFlow,
step,
sleep,
waitForApproval,
getResumeUrls,
parallel,
workflow,
} from "windmill-client";
const process = task(async (x: string): Promise<string> => {
return \`processed: \${x}\`;
});
export const main = workflow(async (x: string) => {
const result = await process(x);
return { result };
});
\`\`\`
Python:
\`\`\`python
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow
@task()
async def process(x: str) -> str:
return f"processed: {x}"
@workflow
async def main(x: str):
result = await process(x)
return {"result": result}
\`\`\`
Rules:
- Do not call \`main\`.
- TypeScript should export the workflow entrypoint, preferably \`export const main = workflow(async (...) => { ... })\`.
- Python must use \`@workflow\` on an async top-level function, usually \`main\`.
- Define task functions and \`taskScript\`/\`task_script\` or \`taskFlow\`/\`task_flow\` assignments at module top level with stable names.
- Use the exact SDK names. Do not alias \`workflow\`, \`task\`, \`taskScript\`, \`taskFlow\`, \`step\`, \`sleep\`, \`waitForApproval\`, \`task_script\`, \`task_flow\`, or \`wait_for_approval\`; the WAC parser recognizes these names directly.
## Checkpoint And Replay Model
The parent workflow may rerun from the top after any suspension, retry, approval, or child task completion. Completed durable steps are replayed from the checkpoint.
Put every side effect or non-deterministic value behind a durable WAC boundary:
- Use \`task()\` / \`@task()\` for substantial work that should run as its own child job.
- Use \`taskScript()\` / \`task_script()\` for an existing script or a relative module file.
- Use \`taskFlow()\` / \`task_flow()\` for an existing Windmill flow.
- Use \`step(name, fn)\` for lightweight inline work whose result must be checkpointed.
- Use \`sleep(seconds)\` for server-side sleeps that do not hold a worker.
- Use \`waitForApproval()\` / \`wait_for_approval()\` for external approval suspension.
Never put API calls, database writes, notifications, random values, timestamps, or irreversible changes directly in the top-level workflow body. The workflow body can be rerun. Put those operations in a task or in \`step()\`.
Branching on task or step results is safe because those results are checkpointed. Branching on current time, random data, environment reads, or external state is unsafe unless the value is first captured with \`step()\`.
## Tasks
Use \`task()\` / \`@task()\` for inline functions that become workflow steps:
\`\`\`typescript
const enrich = task(async (customerId: string) => {
return await fetchCustomer(customerId);
});
\`\`\`
\`\`\`python
@task(timeout=600, tag="etl")
async def enrich(customer_id: str):
return await fetch_customer(customer_id)
\`\`\`
In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with \`@task()\` or \`@task\`.
For existing scripts:
\`\`\`typescript
const helper = taskScript("./helper.ts");
const existing = taskScript("f/data/extract", { timeout: 600 });
const value = await helper({ input: x });
\`\`\`
\`\`\`python
helper = task_script("./helper.py")
existing = task_script("f/data/extract", timeout=600)
value = await helper(input=x)
\`\`\`
For existing flows:
\`\`\`typescript
const pipeline = taskFlow("f/etl/pipeline");
const output = await pipeline({ input: data });
\`\`\`
\`\`\`python
pipeline = task_flow("f/etl/pipeline")
output = await pipeline(input=data)
\`\`\`
## Inline Steps
Use \`step()\` for lightweight inline values that must not change during replay:
\`\`\`typescript
const urls = await step("get_urls", () => getResumeUrls());
const startedAt = await step("started_at", () => new Date().toISOString());
\`\`\`
\`\`\`python
urls = await step("get_urls", lambda: get_resume_urls())
\`\`\`
Use stable, descriptive step names. Do not generate step names dynamically.
## Parallelism
To run independent work in parallel, start task promises/coroutines before awaiting them together:
\`\`\`typescript
const [a, b] = await Promise.all([process("a"), process("b")]);
const many = await parallel(items, process, { concurrency: 5 });
\`\`\`
\`\`\`python
import asyncio
a, b = await asyncio.gather(process("a"), process("b"))
many = await parallel(items, process, concurrency=5)
\`\`\`
Only parallelize independent steps. Do not read the result of a task before it is awaited.
## Approvals
Generate resume URLs inside \`step()\` before sending them:
\`\`\`typescript
const urls = await step("get_urls", () => getResumeUrls());
await step("notify", () => sendApprovalEmail(urls.approvalPage));
const approval = await waitForApproval({ timeout: 3600 });
\`\`\`
\`\`\`python
urls = await step("get_urls", lambda: get_resume_urls())
await step("notify", lambda: send_approval_email(urls["approvalPage"]))
approval = await wait_for_approval(timeout=3600)
\`\`\`
\`selfApproval: false\` and \`self_approval=False\` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.
## Error Handling
Let task errors fail the workflow unless the user asks for recovery logic.
Python: \`except Exception\` is safe around WAC calls because internal suspension inherits from \`BaseException\`. Avoid bare \`except:\` in workflow code. If the user asks for recovery logic around failed child work, catch \`TaskError\` from \`wmill\` for task failures.
TypeScript: avoid broad \`try/catch\` around WAC SDK calls. The SDK uses an internal suspension error during initial dispatch; catching it can break workflow suspension. If a broad catch is unavoidable, rethrow internal suspension errors before handling business errors.
`;
export const FLOW_CHAT_SPECIAL_MODULES = `## Special Modules
- Use \`set_preprocessor_module\` to add, replace, or remove the top-level \`value.preprocessor_module\`
@@ -1595,6 +1775,225 @@ def commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset:
`;
export const WAC_SDK_TYPESCRIPT = `## TypeScript Workflow-as-Code API (windmill-client)
Import: \`import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"\`
\`\`\`typescript
export interface TaskOptions {
timeout?: number;
tag?: string;
cache_ttl?: number;
priority?: number;
concurrency_limit?: number;
concurrency_key?: string;
concurrency_time_window_s?: number;
}
/**
* Get URLs needed for resuming a flow after this step
* @param approver approver name
* @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.
* This allows pre-approvals that can be consumed by any later suspend step in the same flow.
* @returns approval page UI URL, resume and cancel API URLs for resuming the flow
*/
export async function getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ approvalPage: string; resume: string; cancel: string; }>
/**
* Wrap an async function as a workflow task.
*
* @example
* const extract_data = task(async (url: string) => { ... });
* const run_external = task("f/external_script", async (x: number) => { ... });
*
* Inside a \`workflow()\`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly.
*/
export function task<T extends (...args: any[]) => Promise<any>>(fnOrPath: T | string, maybeFnOrOptions?: T | TaskOptions, maybeOptions?: TaskOptions,): T
/**
* Create a task that dispatches to a separate Windmill script.
*
* @example
* const extract = taskScript("f/data/extract");
* // inside workflow: await extract({ url: "https://..." })
*/
export function taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>
/**
* Create a task that dispatches to a separate Windmill flow.
*
* @example
* const pipeline = taskFlow("f/etl/pipeline");
* // inside workflow: await pipeline({ input: data })
*/
export function taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>
/**
* Mark an async function as a workflow-as-code entry point.
*
* The function must be **deterministic**: given the same inputs it must call
* tasks in the same order on every replay. Branching on task results is fine
* (results are replayed from checkpoint), but branching on external state
* (current time, random values, external API calls) must use \`step()\` to
* checkpoint the value so replays see the same result.
*/
export function workflow<T>(fn: (...args: any[]) => Promise<T>)
export async function step<T>(name: string, fn: () => T | Promise<T>): Promise<T>
export async function sleep(seconds: number): Promise<void>
/**
* Suspend the workflow and wait for an external approval.
*
* Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
*
* Each item is processed by calling \`fn(item)\`, which should be a task().
* Items are dispatched in batches of \`concurrency\` (default: all at once).
*
* @example
* const process = task(async (item: string) => { ... });
* const results = await parallel(items, process, { concurrency: 5 });
*/
export async function parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?: { concurrency?: number },): Promise<R[]>
\`\`\`
`;
export const WAC_SDK_PYTHON = `## Python Workflow-as-Code API (wmill)
Import: \`from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError\`
\`\`\`python
# Raised when a WAC task step failed.
#
# Attributes:
# step_key: The checkpoint key of the failed step.
# child_job_id: The UUID of the failed child job.
# result: The error result from the child job.
class TaskError(Exception):
def __init__(self, message: str, *, step_key: str = '', child_job_id: str = '', result = None)
# Get URLs needed for resuming a flow after suspension.
#
# Args:
# approver: Optional approver name
# flow_level: If True, generate resume URLs for the parent flow instead of the
# specific step. This allows pre-approvals that can be consumed by any later
# suspend step in the same flow.
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# Decorator that marks a function as a workflow task.
#
# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2
# (async, checkpoint/replay) modes:
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
#
# Usage::
#
# @task
# async def extract_data(url: str): ...
#
# @task(path="f/external_script", timeout=600, tag="gpu")
# async def run_external(x: int): ...
def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill script.
#
# Usage::
#
# extract = task_script("f/data/extract", timeout=600)
#
# @workflow
# async def main():
# data = await extract(url="https://...")
def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill flow.
#
# Usage::
#
# pipeline = task_flow("f/etl/pipeline", priority=10)
#
# @workflow
# async def main():
# result = await pipeline(input=data)
def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Decorator marking an async function as a workflow-as-code entry point.
#
# The function must be **deterministic**: given the same inputs it must call
# tasks in the same order on every replay. Branching on task results is fine
# (results are replayed from checkpoint), but branching on external state
# (current time, random values, external API calls) must use \`\`step()\`\` to
# checkpoint the value so replays see the same result.
def workflow(func)
# Execute \`\`fn\`\` inline and checkpoint the result.
#
# On replay the cached value is returned without re-executing \`\`fn\`\`.
# Use for lightweight deterministic operations (timestamps, random IDs,
# config reads) that should not incur the overhead of a child job.
async def step(name: str, fn)
# Server-side sleep suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after \`\`seconds\`\`.
# Outside a workflow, falls back to \`\`asyncio.sleep\`\`.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use \`\`get_resume_urls()\`\` (wrapped in \`\`step()\`\`) to obtain
# resume/cancel/approval URLs before calling this function.
#
# Returns a dict with \`\`value\`\` (form data), \`\`approver\`\`, and \`\`approved\`\`.
#
# Args:
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
# Each item is processed by calling \`\`fn(item)\`\`, which should be a @task.
# Items are dispatched in batches of \`\`concurrency\`\` (default: all at once).
#
# Example::
#
# @task
# async def process(item: str):
# ...
#
# results = await parallel(items, process, concurrency=5)
async def parallel(items, fn, *, concurrency: Optional[int] = None)
\`\`\`
`;
export const DATATABLE_SDK_TYPESCRIPT = `## TypeScript Datatable API (windmill-client)
Import: \`import * as wmill from 'windmill-client'\`
@@ -0,0 +1,121 @@
## Python Workflow-as-Code API (wmill)
Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError`
```python
# Raised when a WAC task step failed.
#
# Attributes:
# step_key: The checkpoint key of the failed step.
# child_job_id: The UUID of the failed child job.
# result: The error result from the child job.
class TaskError(Exception):
def __init__(self, message: str, *, step_key: str = '', child_job_id: str = '', result = None)
# Get URLs needed for resuming a flow after suspension.
#
# Args:
# approver: Optional approver name
# flow_level: If True, generate resume URLs for the parent flow instead of the
# specific step. This allows pre-approvals that can be consumed by any later
# suspend step in the same flow.
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# Decorator that marks a function as a workflow task.
#
# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2
# (async, checkpoint/replay) modes:
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
#
# Usage::
#
# @task
# async def extract_data(url: str): ...
#
# @task(path="f/external_script", timeout=600, tag="gpu")
# async def run_external(x: int): ...
def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill script.
#
# Usage::
#
# extract = task_script("f/data/extract", timeout=600)
#
# @workflow
# async def main():
# data = await extract(url="https://...")
def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill flow.
#
# Usage::
#
# pipeline = task_flow("f/etl/pipeline", priority=10)
#
# @workflow
# async def main():
# result = await pipeline(input=data)
def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Decorator marking an async function as a workflow-as-code entry point.
#
# The function must be **deterministic**: given the same inputs it must call
# tasks in the same order on every replay. Branching on task results is fine
# (results are replayed from checkpoint), but branching on external state
# (current time, random values, external API calls) must use ``step()`` to
# checkpoint the value so replays see the same result.
def workflow(func)
# Execute ``fn`` inline and checkpoint the result.
#
# On replay the cached value is returned without re-executing ``fn``.
# Use for lightweight deterministic operations (timestamps, random IDs,
# config reads) that should not incur the overhead of a child job.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.
# Outside a workflow, falls back to ``asyncio.sleep``.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain
# resume/cancel/approval URLs before calling this function.
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
# Args:
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
# Each item is processed by calling ``fn(item)``, which should be a @task.
# Items are dispatched in batches of ``concurrency`` (default: all at once).
#
# Example::
#
# @task
# async def process(item: str):
# ...
#
# results = await parallel(items, process, concurrency=5)
async def parallel(items, fn, *, concurrency: Optional[int] = None)
```
@@ -0,0 +1,94 @@
## TypeScript Workflow-as-Code API (windmill-client)
Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"`
```typescript
export interface TaskOptions {
timeout?: number;
tag?: string;
cache_ttl?: number;
priority?: number;
concurrency_limit?: number;
concurrency_key?: string;
concurrency_time_window_s?: number;
}
/**
* Get URLs needed for resuming a flow after this step
* @param approver approver name
* @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.
* This allows pre-approvals that can be consumed by any later suspend step in the same flow.
* @returns approval page UI URL, resume and cancel API URLs for resuming the flow
*/
export async function getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ approvalPage: string; resume: string; cancel: string; }>
/**
* Wrap an async function as a workflow task.
*
* @example
* const extract_data = task(async (url: string) => { ... });
* const run_external = task("f/external_script", async (x: number) => { ... });
*
* Inside a `workflow()`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly.
*/
export function task<T extends (...args: any[]) => Promise<any>>(fnOrPath: T | string, maybeFnOrOptions?: T | TaskOptions, maybeOptions?: TaskOptions,): T
/**
* Create a task that dispatches to a separate Windmill script.
*
* @example
* const extract = taskScript("f/data/extract");
* // inside workflow: await extract({ url: "https://..." })
*/
export function taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>
/**
* Create a task that dispatches to a separate Windmill flow.
*
* @example
* const pipeline = taskFlow("f/etl/pipeline");
* // inside workflow: await pipeline({ input: data })
*/
export function taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>
/**
* Mark an async function as a workflow-as-code entry point.
*
* The function must be **deterministic**: given the same inputs it must call
* tasks in the same order on every replay. Branching on task results is fine
* (results are replayed from checkpoint), but branching on external state
* (current time, random values, external API calls) must use `step()` to
* checkpoint the value so replays see the same result.
*/
export function workflow<T>(fn: (...args: any[]) => Promise<T>)
export async function step<T>(name: string, fn: () => T | Promise<T>): Promise<T>
export async function sleep(seconds: number): Promise<void>
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
*
* Each item is processed by calling `fn(item)`, which should be a task().
* Items are dispatched in batches of `concurrency` (default: all at once).
*
* @example
* const process = task(async (item: string) => { ... });
* const results = await parallel(items, process, { concurrency: 5 });
*/
export async function parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?: { concurrency?: number },): Promise<R[]>
```
@@ -0,0 +1,439 @@
---
name: write-workflow-as-code
description: MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow.
---
## CLI Commands
Place scripts in a folder.
After writing, tell the user which command fits what they want to do:
- `wmill script preview <script_path>`**default when iterating on a local script.** Runs the local file without deploying.
- `wmill script run <path>` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits.
- `wmill generate-metadata` — generate `.script.yaml` and `.lock` files for the script you modified.
- `wmill sync push` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test".
### Preview vs run — choose by intent, not habit
If the user says "run the script", "try it", "test it", "does it work" while there are **local edits to the script file**, use `script preview`. Do NOT push the script to then `script run` it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes.
Only use `script run` when:
- The user explicitly says "run the deployed version" / "run what's on the server".
- There is no local script being edited (you're just invoking an existing script).
Only use `sync push` when:
- The user explicitly asks to deploy, publish, push, or ship.
- The preview has already validated the change and the user wants it in the workspace.
### After writing — offer to test, don't wait passively
If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run `wmill script preview` with sample args?"). Do not present a multi-option menu.
If the user already asked to test/run/try the script in their original request, skip the offer and just execute `wmill script preview <path> -d '<args>'` directly — pick plausible args from the script's declared parameters. The shape varies by language: `main(...)` for code languages, the SQL dialect's own placeholder syntax (`$1` for PostgreSQL, `?` for MySQL/Snowflake, `@P1` for MSSQL, `@name` for BigQuery, etc.), positional `$1`, `$2`, … for Bash, `param(...)` for PowerShell.
`wmill script preview` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). `wmill sync push` and `wmill generate-metadata` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run.
For a **visual** open-the-script-in-the-dev-page preview (rather than `script preview`'s run-and-print-result), use the `preview` skill.
Use `wmill resource-type list --schema` to discover available resource types.
Workflow-as-Code files use the normal script CLI workflow. There are no separate WAC deploy commands.
# Windmill Workflow-as-Code Writing Guide
## Scope
Use this guide when writing or modifying Windmill Workflow-as-Code (WAC) scripts.
WAC is authored as a Windmill script and deployed with the normal script workflow. It is not an OpenFlow YAML flow.
Supported WAC authoring targets:
- TypeScript scripts that import from `windmill-client`
- Python 3 scripts that import from `wmill`
## File Shape
TypeScript:
```typescript
import {
task,
taskScript,
taskFlow,
step,
sleep,
waitForApproval,
getResumeUrls,
parallel,
workflow,
} from "windmill-client";
const process = task(async (x: string): Promise<string> => {
return `processed: ${x}`;
});
export const main = workflow(async (x: string) => {
const result = await process(x);
return { result };
});
```
Python:
```python
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow
@task()
async def process(x: str) -> str:
return f"processed: {x}"
@workflow
async def main(x: str):
result = await process(x)
return {"result": result}
```
Rules:
- Do not call `main`.
- TypeScript should export the workflow entrypoint, preferably `export const main = workflow(async (...) => { ... })`.
- Python must use `@workflow` on an async top-level function, usually `main`.
- Define task functions and `taskScript`/`task_script` or `taskFlow`/`task_flow` assignments at module top level with stable names.
- Use the exact SDK names. Do not alias `workflow`, `task`, `taskScript`, `taskFlow`, `step`, `sleep`, `waitForApproval`, `task_script`, `task_flow`, or `wait_for_approval`; the WAC parser recognizes these names directly.
## Checkpoint And Replay Model
The parent workflow may rerun from the top after any suspension, retry, approval, or child task completion. Completed durable steps are replayed from the checkpoint.
Put every side effect or non-deterministic value behind a durable WAC boundary:
- Use `task()` / `@task()` for substantial work that should run as its own child job.
- Use `taskScript()` / `task_script()` for an existing script or a relative module file.
- Use `taskFlow()` / `task_flow()` for an existing Windmill flow.
- Use `step(name, fn)` for lightweight inline work whose result must be checkpointed.
- Use `sleep(seconds)` for server-side sleeps that do not hold a worker.
- Use `waitForApproval()` / `wait_for_approval()` for external approval suspension.
Never put API calls, database writes, notifications, random values, timestamps, or irreversible changes directly in the top-level workflow body. The workflow body can be rerun. Put those operations in a task or in `step()`.
Branching on task or step results is safe because those results are checkpointed. Branching on current time, random data, environment reads, or external state is unsafe unless the value is first captured with `step()`.
## Tasks
Use `task()` / `@task()` for inline functions that become workflow steps:
```typescript
const enrich = task(async (customerId: string) => {
return await fetchCustomer(customerId);
});
```
```python
@task(timeout=600, tag="etl")
async def enrich(customer_id: str):
return await fetch_customer(customer_id)
```
In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`.
For existing scripts:
```typescript
const helper = taskScript("./helper.ts");
const existing = taskScript("f/data/extract", { timeout: 600 });
const value = await helper({ input: x });
```
```python
helper = task_script("./helper.py")
existing = task_script("f/data/extract", timeout=600)
value = await helper(input=x)
```
For existing flows:
```typescript
const pipeline = taskFlow("f/etl/pipeline");
const output = await pipeline({ input: data });
```
```python
pipeline = task_flow("f/etl/pipeline")
output = await pipeline(input=data)
```
## Inline Steps
Use `step()` for lightweight inline values that must not change during replay:
```typescript
const urls = await step("get_urls", () => getResumeUrls());
const startedAt = await step("started_at", () => new Date().toISOString());
```
```python
urls = await step("get_urls", lambda: get_resume_urls())
```
Use stable, descriptive step names. Do not generate step names dynamically.
## Parallelism
To run independent work in parallel, start task promises/coroutines before awaiting them together:
```typescript
const [a, b] = await Promise.all([process("a"), process("b")]);
const many = await parallel(items, process, { concurrency: 5 });
```
```python
import asyncio
a, b = await asyncio.gather(process("a"), process("b"))
many = await parallel(items, process, concurrency=5)
```
Only parallelize independent steps. Do not read the result of a task before it is awaited.
## Approvals
Generate resume URLs inside `step()` before sending them:
```typescript
const urls = await step("get_urls", () => getResumeUrls());
await step("notify", () => sendApprovalEmail(urls.approvalPage));
const approval = await waitForApproval({ timeout: 3600 });
```
```python
urls = await step("get_urls", lambda: get_resume_urls())
await step("notify", lambda: send_approval_email(urls["approvalPage"]))
approval = await wait_for_approval(timeout=3600)
```
`selfApproval: false` and `self_approval=False` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.
## Error Handling
Let task errors fail the workflow unless the user asks for recovery logic.
Python: `except Exception` is safe around WAC calls because internal suspension inherits from `BaseException`. Avoid bare `except:` in workflow code. If the user asks for recovery logic around failed child work, catch `TaskError` from `wmill` for task failures.
TypeScript: avoid broad `try/catch` around WAC SDK calls. The SDK uses an internal suspension error during initial dispatch; catching it can break workflow suspension. If a broad catch is unavoidable, rethrow internal suspension errors before handling business errors.
## TypeScript Workflow-as-Code API (windmill-client)
Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"`
```typescript
export interface TaskOptions {
timeout?: number;
tag?: string;
cache_ttl?: number;
priority?: number;
concurrency_limit?: number;
concurrency_key?: string;
concurrency_time_window_s?: number;
}
/**
* Get URLs needed for resuming a flow after this step
* @param approver approver name
* @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.
* This allows pre-approvals that can be consumed by any later suspend step in the same flow.
* @returns approval page UI URL, resume and cancel API URLs for resuming the flow
*/
export async function getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ approvalPage: string; resume: string; cancel: string; }>
/**
* Wrap an async function as a workflow task.
*
* @example
* const extract_data = task(async (url: string) => { ... });
* const run_external = task("f/external_script", async (x: number) => { ... });
*
* Inside a `workflow()`, calling a task dispatches it as a step.
* Outside a workflow, the function body executes directly.
*/
export function task<T extends (...args: any[]) => Promise<any>>(fnOrPath: T | string, maybeFnOrOptions?: T | TaskOptions, maybeOptions?: TaskOptions,): T
/**
* Create a task that dispatches to a separate Windmill script.
*
* @example
* const extract = taskScript("f/data/extract");
* // inside workflow: await extract({ url: "https://..." })
*/
export function taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>
/**
* Create a task that dispatches to a separate Windmill flow.
*
* @example
* const pipeline = taskFlow("f/etl/pipeline");
* // inside workflow: await pipeline({ input: data })
*/
export function taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>
/**
* Mark an async function as a workflow-as-code entry point.
*
* The function must be **deterministic**: given the same inputs it must call
* tasks in the same order on every replay. Branching on task results is fine
* (results are replayed from checkpoint), but branching on external state
* (current time, random values, external API calls) must use `step()` to
* checkpoint the value so replays see the same result.
*/
export function workflow<T>(fn: (...args: any[]) => Promise<T>)
export async function step<T>(name: string, fn: () => T | Promise<T>): Promise<T>
export async function sleep(seconds: number): Promise<void>
/**
* Suspend the workflow and wait for an external approval.
*
* Use `getResumeUrls()` (wrapped in `step()`) to obtain resume/cancel/approvalPage
* URLs before calling this function.
*
* @example
* const urls = await step("urls", () => getResumeUrls());
* await step("notify", () => sendEmail(urls.approvalPage));
* const { value, approver } = await waitForApproval({ timeout: 3600 });
*/
export function waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }>
/**
* Process items in parallel with optional concurrency control.
*
* Each item is processed by calling `fn(item)`, which should be a task().
* Items are dispatched in batches of `concurrency` (default: all at once).
*
* @example
* const process = task(async (item: string) => { ... });
* const results = await parallel(items, process, { concurrency: 5 });
*/
export async function parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?: { concurrency?: number },): Promise<R[]>
```
## Python Workflow-as-Code API (wmill)
Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError`
```python
# Raised when a WAC task step failed.
#
# Attributes:
# step_key: The checkpoint key of the failed step.
# child_job_id: The UUID of the failed child job.
# result: The error result from the child job.
class TaskError(Exception):
def __init__(self, message: str, *, step_key: str = '', child_job_id: str = '', result = None)
# Get URLs needed for resuming a flow after suspension.
#
# Args:
# approver: Optional approver name
# flow_level: If True, generate resume URLs for the parent flow instead of the
# specific step. This allows pre-approvals that can be consumed by any later
# suspend step in the same flow.
#
# Returns:
# Dictionary with approvalPage, resume, and cancel URLs
def get_resume_urls(approver: str = None, flow_level: bool = None) -> dict
# Decorator that marks a function as a workflow task.
#
# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2
# (async, checkpoint/replay) modes:
#
# - **v2 (inside @workflow)**: dispatches as a checkpoint step.
# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.
# - **Standalone**: executes the function body directly.
#
# Usage::
#
# @task
# async def extract_data(url: str): ...
#
# @task(path="f/external_script", timeout=600, tag="gpu")
# async def run_external(x: int): ...
def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill script.
#
# Usage::
#
# extract = task_script("f/data/extract", timeout=600)
#
# @workflow
# async def main():
# data = await extract(url="https://...")
def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Create a task that dispatches to a separate Windmill flow.
#
# Usage::
#
# pipeline = task_flow("f/etl/pipeline", priority=10)
#
# @workflow
# async def main():
# result = await pipeline(input=data)
def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None)
# Decorator marking an async function as a workflow-as-code entry point.
#
# The function must be **deterministic**: given the same inputs it must call
# tasks in the same order on every replay. Branching on task results is fine
# (results are replayed from checkpoint), but branching on external state
# (current time, random values, external API calls) must use ``step()`` to
# checkpoint the value so replays see the same result.
def workflow(func)
# Execute ``fn`` inline and checkpoint the result.
#
# On replay the cached value is returned without re-executing ``fn``.
# Use for lightweight deterministic operations (timestamps, random IDs,
# config reads) that should not incur the overhead of a child job.
async def step(name: str, fn)
# Server-side sleep — suspend the workflow for the given duration without holding a worker.
#
# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.
# Outside a workflow, falls back to ``asyncio.sleep``.
async def sleep(seconds: int)
# Suspend the workflow and wait for an external approval.
#
# Use ``get_resume_urls()`` (wrapped in ``step()``) to obtain
# resume/cancel/approval URLs before calling this function.
#
# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.
#
# Args:
# timeout: Approval timeout in seconds (default 1800).
# form: Optional form schema for the approval page.
# self_approval: Whether the user who triggered the flow can approve it (default True).
#
# Example::
#
# urls = await step("urls", lambda: get_resume_urls())
# await step("notify", lambda: send_email(urls["approvalPage"]))
# result = await wait_for_approval(timeout=3600)
async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True) -> dict
# Process items in parallel with optional concurrency control.
#
# Each item is processed by calling ``fn(item)``, which should be a @task.
# Items are dispatched in batches of ``concurrency`` (default: all at once).
#
# Example::
#
# @task
# async def process(item: str):
# ...
#
# results = await parallel(items, process, concurrency=5)
async def parallel(items, fn, *, concurrency: Optional[int] = None)
```
+178
View File
@@ -0,0 +1,178 @@
# Windmill Workflow-as-Code Writing Guide
## Scope
Use this guide when writing or modifying Windmill Workflow-as-Code (WAC) scripts.
WAC is authored as a Windmill script and deployed with the normal script workflow. It is not an OpenFlow YAML flow.
Supported WAC authoring targets:
- TypeScript scripts that import from `windmill-client`
- Python 3 scripts that import from `wmill`
## File Shape
TypeScript:
```typescript
import {
task,
taskScript,
taskFlow,
step,
sleep,
waitForApproval,
getResumeUrls,
parallel,
workflow,
} from "windmill-client";
const process = task(async (x: string): Promise<string> => {
return `processed: ${x}`;
});
export const main = workflow(async (x: string) => {
const result = await process(x);
return { result };
});
```
Python:
```python
from wmill import task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, workflow
@task()
async def process(x: str) -> str:
return f"processed: {x}"
@workflow
async def main(x: str):
result = await process(x)
return {"result": result}
```
Rules:
- Do not call `main`.
- TypeScript should export the workflow entrypoint, preferably `export const main = workflow(async (...) => { ... })`.
- Python must use `@workflow` on an async top-level function, usually `main`.
- Define task functions and `taskScript`/`task_script` or `taskFlow`/`task_flow` assignments at module top level with stable names.
- Use the exact SDK names. Do not alias `workflow`, `task`, `taskScript`, `taskFlow`, `step`, `sleep`, `waitForApproval`, `task_script`, `task_flow`, or `wait_for_approval`; the WAC parser recognizes these names directly.
## Checkpoint And Replay Model
The parent workflow may rerun from the top after any suspension, retry, approval, or child task completion. Completed durable steps are replayed from the checkpoint.
Put every side effect or non-deterministic value behind a durable WAC boundary:
- Use `task()` / `@task()` for substantial work that should run as its own child job.
- Use `taskScript()` / `task_script()` for an existing script or a relative module file.
- Use `taskFlow()` / `task_flow()` for an existing Windmill flow.
- Use `step(name, fn)` for lightweight inline work whose result must be checkpointed.
- Use `sleep(seconds)` for server-side sleeps that do not hold a worker.
- Use `waitForApproval()` / `wait_for_approval()` for external approval suspension.
Never put API calls, database writes, notifications, random values, timestamps, or irreversible changes directly in the top-level workflow body. The workflow body can be rerun. Put those operations in a task or in `step()`.
Branching on task or step results is safe because those results are checkpointed. Branching on current time, random data, environment reads, or external state is unsafe unless the value is first captured with `step()`.
## Tasks
Use `task()` / `@task()` for inline functions that become workflow steps:
```typescript
const enrich = task(async (customerId: string) => {
return await fetchCustomer(customerId);
});
```
```python
@task(timeout=600, tag="etl")
async def enrich(customer_id: str):
return await fetch_customer(customer_id)
```
In TypeScript, prefer assigning each task to a named top-level const. In Python, prefer top-level async functions decorated with `@task()` or `@task`.
For existing scripts:
```typescript
const helper = taskScript("./helper.ts");
const existing = taskScript("f/data/extract", { timeout: 600 });
const value = await helper({ input: x });
```
```python
helper = task_script("./helper.py")
existing = task_script("f/data/extract", timeout=600)
value = await helper(input=x)
```
For existing flows:
```typescript
const pipeline = taskFlow("f/etl/pipeline");
const output = await pipeline({ input: data });
```
```python
pipeline = task_flow("f/etl/pipeline")
output = await pipeline(input=data)
```
## Inline Steps
Use `step()` for lightweight inline values that must not change during replay:
```typescript
const urls = await step("get_urls", () => getResumeUrls());
const startedAt = await step("started_at", () => new Date().toISOString());
```
```python
urls = await step("get_urls", lambda: get_resume_urls())
```
Use stable, descriptive step names. Do not generate step names dynamically.
## Parallelism
To run independent work in parallel, start task promises/coroutines before awaiting them together:
```typescript
const [a, b] = await Promise.all([process("a"), process("b")]);
const many = await parallel(items, process, { concurrency: 5 });
```
```python
import asyncio
a, b = await asyncio.gather(process("a"), process("b"))
many = await parallel(items, process, concurrency=5)
```
Only parallelize independent steps. Do not read the result of a task before it is awaited.
## Approvals
Generate resume URLs inside `step()` before sending them:
```typescript
const urls = await step("get_urls", () => getResumeUrls());
await step("notify", () => sendApprovalEmail(urls.approvalPage));
const approval = await waitForApproval({ timeout: 3600 });
```
```python
urls = await step("get_urls", lambda: get_resume_urls())
await step("notify", lambda: send_approval_email(urls["approvalPage"]))
approval = await wait_for_approval(timeout=3600)
```
`selfApproval: false` and `self_approval=False` are Enterprise-only approval behavior. Do not use them unless the user asks for that behavior.
## Error Handling
Let task errors fail the workflow unless the user asks for recovery logic.
Python: `except Exception` is safe around WAC calls because internal suspension inherits from `BaseException`. Avoid bare `except:` in workflow code. If the user asks for recovery logic around failed child work, catch `TaskError` from `wmill` for task failures.
TypeScript: avoid broad `try/catch` around WAC SDK calls. The SDK uses an internal suspension error during initial dispatch; catching it can break workflow suspension. If a broad catch is unavoidable, rethrow internal suspension errors before handling business errors.
+353 -6
View File
@@ -1078,6 +1078,306 @@ def _indent_body(body: str) -> str:
return '\n'.join(result)
# =============================================================================
# Workflow-as-Code SDK Extraction
# =============================================================================
WAC_TS_FUNCTIONS = [
'getResumeUrls',
'task',
'taskScript',
'taskFlow',
'workflow',
'step',
'sleep',
'waitForApproval',
'parallel',
]
WAC_PY_FUNCTIONS = [
'get_resume_urls',
'task',
'task_script',
'task_flow',
'workflow',
'step',
'sleep',
'wait_for_approval',
'parallel',
]
def _extract_ts_angle_params(content: str, start_pos: int) -> tuple[str, int]:
"""Extract TypeScript generic parameters, ignoring arrow `=>` tokens."""
if start_pos >= len(content) or content[start_pos] != '<':
return '', start_pos
depth = 0
i = start_pos
quote: str | None = None
while i < len(content):
char = content[i]
prev = content[i - 1] if i > 0 else ''
if quote:
if char == '\\':
i += 2
continue
if char == quote:
quote = None
i += 1
continue
if char in ('"', "'", '`'):
quote = char
elif char == '<':
depth += 1
elif char == '>' and prev != '=':
depth -= 1
if depth == 0:
return content[start_pos:i + 1], i + 1
i += 1
return '', -1
def _render_ts_jsdoc(jsdoc_raw: str | None) -> str:
if not jsdoc_raw:
return ''
docstring = clean_jsdoc(jsdoc_raw)
if not docstring:
return ''
lines = ["/**"]
for line in docstring.split('\n'):
lines.append(f" * {line}" if line else " *")
lines.append(" */")
return '\n'.join(lines)
def _extract_ts_interface(content: str, name: str) -> str:
pattern = re.compile(
r'(?:(/\*\*(?:[^*]|\*(?!/))*\*/)\s*)?'
rf'export\s+interface\s+{re.escape(name)}\s*',
re.MULTILINE
)
match = pattern.search(content)
if not match:
return ''
try:
brace_start = content.index('{', match.end() - 1)
except ValueError:
return ''
body, end = extract_balanced(content, brace_start, '{', '}')
if end == -1:
return ''
parts = []
jsdoc = _render_ts_jsdoc(match.group(1))
if jsdoc:
parts.append(jsdoc)
parts.append(f"export interface {name} {{\n{_indent_body(body)}\n}}")
return '\n'.join(parts)
def _extract_ts_exported_function(content: str, name: str) -> str:
pattern = re.compile(
r'(?:(/\*\*(?:[^*]|\*(?!/))*\*/)\s*)?'
rf'export\s+(async\s+)?function\s+{re.escape(name)}\s*',
re.MULTILINE
)
match = pattern.search(content)
if not match:
return ''
jsdoc_raw, is_async = match.groups()
pos = match.end()
while pos < len(content) and content[pos] in ' \t\n':
pos += 1
generic = ''
if pos < len(content) and content[pos] == '<':
generic, pos = _extract_ts_angle_params(content, pos)
if pos == -1:
return ''
while pos < len(content) and content[pos] in ' \t\n':
pos += 1
if pos >= len(content) or content[pos] != '(':
return ''
params, paren_end = extract_balanced(content, pos, '(', ')')
if paren_end == -1:
return ''
return_type, _ = extract_return_type(content, paren_end + 1)
async_prefix = 'async ' if is_async else ''
signature = f"export {async_prefix}function {name}{generic}({clean_params(params)})"
if return_type:
signature += f": {clean_params(return_type)}"
parts = []
jsdoc = _render_ts_jsdoc(jsdoc_raw)
if jsdoc:
parts.append(jsdoc)
parts.append(signature)
return '\n'.join(parts)
def extract_wac_ts_sdk(ts_content: str) -> str:
"""Extract Workflow-as-Code API signatures from the TypeScript SDK."""
if not ts_content:
return ''
declarations = []
task_options = _extract_ts_interface(ts_content, 'TaskOptions')
if task_options:
declarations.append(task_options)
for function_name in WAC_TS_FUNCTIONS:
signature = _extract_ts_exported_function(ts_content, function_name)
if signature:
declarations.append(signature)
else:
print(f" Warning: TypeScript WAC function '{function_name}' not found")
if not declarations:
return ''
md = "## TypeScript Workflow-as-Code API (windmill-client)\n\n"
md += 'Import: `import { workflow, task, taskScript, taskFlow, step, sleep, waitForApproval, getResumeUrls, parallel } from "windmill-client"`\n\n'
md += "```typescript\n"
md += "\n\n".join(declarations)
md += "\n```\n"
return md
def _format_py_params_exact(node, skip_self: bool = False) -> str:
"""Format Python parameters from AST, preserving bare * for keyword-only args."""
params = []
args = node.args
positional = list(args.posonlyargs) + list(args.args)
num_defaults = len(args.defaults)
num_positional = len(positional)
for i, arg in enumerate(positional):
if skip_self and arg.arg == 'self':
continue
param_str = arg.arg
if arg.annotation:
param_str += f": {ast.unparse(arg.annotation)}"
default_idx = i - (num_positional - num_defaults)
if default_idx >= 0:
param_str += f" = {ast.unparse(args.defaults[default_idx])}"
params.append(param_str)
if args.vararg:
vararg_str = f"*{args.vararg.arg}"
if args.vararg.annotation:
vararg_str += f": {ast.unparse(args.vararg.annotation)}"
params.append(vararg_str)
elif args.kwonlyargs:
params.append('*')
for i, arg in enumerate(args.kwonlyargs):
param_str = arg.arg
if arg.annotation:
param_str += f": {ast.unparse(arg.annotation)}"
if args.kw_defaults[i] is not None:
param_str += f" = {ast.unparse(args.kw_defaults[i])}"
params.append(param_str)
if args.kwarg:
kwarg_str = f"**{args.kwarg.arg}"
if args.kwarg.annotation:
kwarg_str += f": {ast.unparse(args.kwarg.annotation)}"
params.append(kwarg_str)
return ', '.join(params)
def _render_py_docstring(docstring: str, indent: str = '') -> str:
if not docstring:
return ''
return '\n'.join(f"{indent}# {line}" if line else f"{indent}#" for line in docstring.split('\n'))
def _extract_py_function_signature(tree: ast.Module, name: str) -> str:
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name:
docstring = ast.get_docstring(node) or ''
params = _format_py_params_exact(node)
return_ann = f" -> {ast.unparse(node.returns)}" if node.returns else ''
async_prefix = 'async ' if isinstance(node, ast.AsyncFunctionDef) else ''
parts = []
rendered_docstring = _render_py_docstring(docstring)
if rendered_docstring:
parts.append(rendered_docstring)
parts.append(f"{async_prefix}def {node.name}({params}){return_ann}")
return '\n'.join(parts)
return ''
def _extract_py_class_signature(tree: ast.Module, name: str) -> str:
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name == name:
parts = []
docstring = _render_py_docstring(ast.get_docstring(node) or '')
if docstring:
parts.append(docstring)
bases = f"({', '.join(ast.unparse(base) for base in node.bases)})" if node.bases else ''
parts.append(f"class {node.name}{bases}:")
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name == '__init__':
init_docstring = _render_py_docstring(ast.get_docstring(item) or '', indent=' ')
if init_docstring:
parts.append(init_docstring)
params = _format_py_params_exact(item)
parts.append(f" def __init__({params})")
break
return '\n'.join(parts)
return ''
def extract_wac_py_sdk(py_content: str) -> str:
"""Extract Workflow-as-Code API signatures from the Python SDK."""
if not py_content:
return ''
try:
tree = ast.parse(py_content)
except SyntaxError as e:
print(f" Warning: Could not parse Python SDK for WAC extraction: {e}")
return ''
declarations = []
task_error = _extract_py_class_signature(tree, 'TaskError')
if task_error:
declarations.append(task_error)
for function_name in WAC_PY_FUNCTIONS:
signature = _extract_py_function_signature(tree, function_name)
if signature:
declarations.append(signature)
else:
print(f" Warning: Python WAC function '{function_name}' not found")
if not declarations:
return ''
md = "## Python Workflow-as-Code API (wmill)\n\n"
md += "Import: `from wmill import workflow, task, task_script, task_flow, step, sleep, wait_for_approval, get_resume_urls, parallel, TaskError`\n\n"
md += "```python\n"
md += "\n\n".join(declarations)
md += "\n```\n"
return md
# =============================================================================
# Skill Generation
# =============================================================================
@@ -1145,6 +1445,13 @@ SKILL_DEFINITIONS = [
'description': 'MUST use when managing resources.',
'content_key': 'resources',
},
{
'name': 'write-workflow-as-code',
'description': 'MUST use when writing or modifying Windmill Workflow-as-Code scripts using workflow, task, step, sleep, approvals, taskScript, taskFlow, task_script, or task_flow.',
'content_key': 'workflow_as_code',
'intro_key': 'wac_cli',
'sdk_content_key': 'wac',
},
{
'name': 'cli-commands',
'description': 'MUST use when using the CLI, including debugging job failures and inspecting run history via `wmill job`.',
@@ -1162,6 +1469,8 @@ def generate_skills(
languages: dict[str, str],
ts_sdk_md: str,
py_sdk_md: str,
wac_ts_md: str,
wac_py_md: str,
flow_cli: str,
flow_base: str,
openflow_content: str,
@@ -1184,6 +1493,7 @@ def generate_skills(
'triggers': read_markdown_file(base_dir / "triggers.md"),
'schedules': read_markdown_file(base_dir / "schedules.md"),
'resources': read_markdown_file(base_dir / "resources.md"),
'workflow_as_code': read_markdown_file(base_dir / "workflow-as-code.md"),
'cli_commands': cli_commands,
'preview': read_markdown_file(base_dir / "preview.md"),
}
@@ -1224,6 +1534,18 @@ For a **visual** open-the-script-in-the-dev-page preview (rather than `script pr
Use `wmill resource-type list --schema` to discover available resource types."""
wac_cli_intro = f"""{script_cli_intro}
Workflow-as-Code files use the normal script CLI workflow. There are no separate WAC deploy commands."""
intro_content = {
'wac_cli': wac_cli_intro,
}
extra_sdk_content = {
'wac': "\n\n".join(filter(None, [wac_ts_md, wac_py_md])),
}
skills_generated = []
# Generate script skills for each language
@@ -1238,18 +1560,18 @@ Use `wmill resource-type list --schema` to discover available resource types."""
skill_dir.mkdir(parents=True, exist_ok=True)
# Determine which SDK to include
sdk_content = ''
language_sdk_content = ''
if lang_key in TS_SDK_LANGUAGES:
sdk_content = ts_sdk_md
language_sdk_content = ts_sdk_md
elif lang_key in PY_SDK_LANGUAGES:
sdk_content = py_sdk_md
language_sdk_content = py_sdk_md
skill_content = generate_skill_content(
skill_name=skill_name,
description=metadata['description'],
intro=script_cli_intro,
content=lang_content,
sdk_content=sdk_content
sdk_content=language_sdk_content
)
(skill_dir / "SKILL.md").write_text(skill_content)
@@ -1273,8 +1595,9 @@ Use `wmill resource-type list --schema` to discover available resource types."""
skill_content = generate_skill_content(
skill_name=skill_name,
description=skill_def['description'],
intro="",
content=content
intro=intro_content.get(skill_def.get('intro_key', ''), ''),
content=content,
sdk_content=extra_sdk_content.get(skill_def.get('sdk_content_key', ''), '')
)
(skill_dir / "SKILL.md").write_text(skill_content)
@@ -1518,6 +1841,13 @@ def main():
(OUTPUT_SDKS_DIR / "datatable-typescript.md").write_text(datatable_ts_md)
(OUTPUT_SDKS_DIR / "datatable-python.md").write_text(datatable_py_md)
# Extract Workflow-as-Code SDK docs (for WAC skills and prompt helpers)
print("Extracting Workflow-as-Code SDK docs...")
wac_ts_md = extract_wac_ts_sdk(ts_content)
wac_py_md = extract_wac_py_sdk(py_content)
(OUTPUT_SDKS_DIR / "wac-typescript.md").write_text(wac_ts_md)
(OUTPUT_SDKS_DIR / "wac-python.md").write_text(wac_py_md)
# Read base prompts
print("Assembling complete prompts...")
base_dir = SCRIPT_DIR / "base"
@@ -1525,6 +1855,7 @@ def main():
script_base = read_markdown_file(base_dir / "script-base.md")
flow_base = read_markdown_file(base_dir / "flow-base.md")
workflow_as_code_base = read_markdown_file(base_dir / "workflow-as-code.md")
flow_cli = read_markdown_file(base_dir / "flow-cli.md")
flow_chat_special_modules = read_markdown_file(base_dir / "flow-chat-special-modules.md")
@@ -1580,11 +1911,14 @@ def main():
# Base prompts
'SCRIPT_BASE': script_base,
'FLOW_BASE': flow_base,
'WORKFLOW_AS_CODE_BASE': workflow_as_code_base,
'FLOW_CHAT_SPECIAL_MODULES': flow_chat_special_modules,
# SDKs
'SDK_TYPESCRIPT': ts_sdk_md,
'SDK_PYTHON': py_sdk_md,
'WAC_SDK_TYPESCRIPT': wac_ts_md,
'WAC_SDK_PYTHON': wac_py_md,
# Datatable-specific SDK docs (for app mode)
'DATATABLE_SDK_TYPESCRIPT': datatable_ts_md,
@@ -1666,6 +2000,15 @@ export function getDatatableSdkReference(): string {
prompts.DATATABLE_SDK_PYTHON
].filter(Boolean).join('\\n\\n');
}
// Helper to combine prompts for Workflow-as-Code scripts
export function getWorkflowAsCodePrompt(): string {
return [
prompts.WORKFLOW_AS_CODE_BASE,
prompts.WAC_SDK_TYPESCRIPT,
prompts.WAC_SDK_PYTHON
].filter(Boolean).join('\\n\\n');
}
"""
(OUTPUT_GENERATED_DIR / "index.ts").write_text(index_content)
@@ -1675,6 +2018,8 @@ export function getDatatableSdkReference(): string {
languages=languages,
ts_sdk_md=ts_sdk_md,
py_sdk_md=py_sdk_md,
wac_ts_md=wac_ts_md,
wac_py_md=wac_py_md,
flow_cli=flow_cli,
flow_base=flow_base,
cli_commands=cli_commands,
@@ -1703,6 +2048,8 @@ export function getDatatableSdkReference(): string {
print(f"\nGenerated files:")
print(f" - auto-generated/sdks/typescript.md")
print(f" - auto-generated/sdks/python.md")
print(f" - auto-generated/sdks/wac-typescript.md")
print(f" - auto-generated/sdks/wac-python.md")
print(f" - auto-generated/cli/cli-commands.md (auto-generated from CLI source)")
print(f" - auto-generated/prompts.ts")
print(f" - auto-generated/index.ts")
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.692.0",
"version": "1.693.3",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.692.0",
"version": "1.693.3",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"sideEffects": false,
+1 -1
View File
@@ -1 +1 @@
1.692.0
1.693.3