Merge remote-tracking branch 'origin/main' into gl/layout-ai

This commit is contained in:
Guilhem Lemouel
2026-05-25 08:52:36 +02:00
140 changed files with 5727 additions and 1001 deletions
@@ -1,126 +0,0 @@
name: Spawn Ephemeral Backend
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: "PR number"
required: true
type: number
jobs:
check-membership:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '/spawnbackend')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '/spawnbackend'))
uses: ./.github/workflows/check-org-membership.yml
secrets:
access_token: ${{ secrets.ORG_ACCESS_TOKEN }}
spawn-backend:
needs: check-membership
# Only run on PR comments that contain /spawn-backend, or manual dispatch
if: |
github.event_name == 'workflow_dispatch' ||
(github.event.issue.pull_request && needs.check-membership.outputs.is_member == 'true')
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Get PR details
id: pr-details
uses: actions/github-script@v7
with:
script: |
const prNumber = context.eventName === 'workflow_dispatch'
? context.payload.inputs.pr_number
: context.issue.number;
const pr = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
// Get branch name and format it for Cloudflare Pages
// Replace '/' with '-' for the URL
const branchName = pr.data.head.ref;
const formattedBranch = branchName.replace(/\//g, '-');
const cfFrontendUrl = `https://${formattedBranch}.windmill.pages.dev`;
core.setOutput('commit_hash', pr.data.head.sha);
core.setOutput('pr_number', prNumber);
core.setOutput('branch_name', branchName);
core.setOutput('cf_frontend_url', cfFrontendUrl);
- name: Check manager URL
id: check-manager-url
run: |
if [ -z "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}" ]; then
echo "manager_url_set=false" >> $GITHUB_OUTPUT
else
echo "manager_url_set=true" >> $GITHUB_OUTPUT
fi
- name: Post error comment if manager not running
if: steps.check-manager-url.outputs.manager_url_set == 'false'
uses: actions/github-script@v7
with:
script: |
const prNumber = context.eventName === 'workflow_dispatch'
? Number(context.payload.inputs.pr_number)
: context.issue.number;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `❌ Manager URL not set (did you start the ephemeral backend manager?)\n\nThe ephemeral backend manager needs to be running to spawn backends. Please start the manager first.`
});
- name: Fail if manager not running
if: steps.check-manager-url.outputs.manager_url_set == 'false'
run: |
echo "Error: EPHEMERAL_BACKEND_QUEUE_URL secret is not set"
exit 1
- name: Trigger Windmill flow
if: steps.check-manager-url.outputs.manager_url_set == 'true'
id: trigger-flow
run: |
JOB_UUID=$(curl -s -X POST "https://app.windmill.dev/api/w/windmill-labs/jobs/run/f/f/all/run_ephemeral_backend" \
-H "Authorization: Bearer ${{ secrets.WINDMILL_RUN_FLOW_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"manager_url": "${{ secrets.EPHEMERAL_BACKEND_QUEUE_URL }}",
"commit_hash": "${{ steps.pr-details.outputs.commit_hash }}",
"pr_number": ${{ steps.pr-details.outputs.pr_number }},
"cf_frontend_url": "${{ steps.pr-details.outputs.cf_frontend_url }}"
}' | tr -d '"')
echo "Job UUID: $JOB_UUID"
echo "job_uuid=$JOB_UUID" >> $GITHUB_OUTPUT
- name: Post comment with job link
if: steps.check-manager-url.outputs.manager_url_set == 'true'
uses: actions/github-script@v7
with:
script: |
const jobUuid = '${{ steps.trigger-flow.outputs.job_uuid }}';
const appUrl = `https://app.windmill.dev/public/windmill-labs/a106bad0256c1dfa7a4f9279c42b1a4b#${jobUuid}`;
const prNumber = context.eventName === 'workflow_dispatch'
? Number(context.payload.inputs.pr_number)
: context.issue.number;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
body: `🚀 Spawning new ephemeral backend!\n\n${appUrl}`
});
+54
View File
@@ -1,5 +1,59 @@
# Changelog
## [1.708.0](https://github.com/windmill-labs/windmill/compare/v1.707.0...v1.708.0) (2026-05-24)
### Features
* **queue:** per-workspace fairness cap on the shared cloud worker pool ([#9303](https://github.com/windmill-labs/windmill/issues/9303)) ([de2e243](https://github.com/windmill-labs/windmill/commit/de2e243313ee34348675dec600cb412b475d1b4b))
## [1.707.0](https://github.com/windmill-labs/windmill/compare/v1.706.1...v1.707.0) (2026-05-22)
### Features
* add wmill job rerun subcommand ([#9275](https://github.com/windmill-labs/windmill/issues/9275)) ([e0ffea2](https://github.com/windmill-labs/windmill/commit/e0ffea2deb5acf30815edd3669f4fc4c818b6e19))
* **github-app:** hide cloud-only UI on self-managed + admin assignment UI ([#9299](https://github.com/windmill-labs/windmill/issues/9299)) ([dcee8cc](https://github.com/windmill-labs/windmill/commit/dcee8cc0d3dd71c3a12f1720e3ce4eb86cdacf4f))
* **typescript-client:** add deleteS3File + optional workspace arg on S3 helpers ([#9300](https://github.com/windmill-labs/windmill/issues/9300)) ([daab561](https://github.com/windmill-labs/windmill/commit/daab561ec0763468d93e42e8f7f0796dc77be74d))
### Bug Fixes
* **auth:** tighten token-owner fallback for unscoped tokens (WIN-1978) ([#9293](https://github.com/windmill-labs/windmill/issues/9293)) ([7003998](https://github.com/windmill-labs/windmill/commit/7003998a575d76c272c6abd0789a1d1f7b722076))
* **cli:** wmill sync pull updates wmill-lock.yaml for raw apps ([#9289](https://github.com/windmill-labs/windmill/issues/9289)) ([486e5f9](https://github.com/windmill-labs/windmill/commit/486e5f947b1649c17d32e3b214c50d4be701a4e8))
* flow recording teardown crash + rename package to @windmill-labs/components ([#9288](https://github.com/windmill-labs/windmill/issues/9288)) ([13a2fae](https://github.com/windmill-labs/windmill/commit/13a2fae745ba4862006db5ee0811475c1d27fd1d))
* **flows:** restore Variables and Resources in flow editor prop picker ([#9290](https://github.com/windmill-labs/windmill/issues/9290)) ([5566c7b](https://github.com/windmill-labs/windmill/commit/5566c7b3ff2d5a6b15cb9187aa15ce1c7245b3fb))
* **ResourceEditor:** don't reset state when `selected` reverts to undefined ([#9295](https://github.com/windmill-labs/windmill/issues/9295)) ([1f2d2c1](https://github.com/windmill-labs/windmill/commit/1f2d2c11493db20b87615d41c21e5e1c35564739))
* **secret-backend:** pass DB to Vault migrations + show failure details ([#9292](https://github.com/windmill-labs/windmill/issues/9292)) ([ace2291](https://github.com/windmill-labs/windmill/commit/ace22910c40585a6a2c9abd0c46f7e5e0214e78e))
## [1.706.1](https://github.com/windmill-labs/windmill/compare/v1.706.0...v1.706.1) (2026-05-22)
### Bug Fixes
* fork compare visibility for non-admins and stale-token superadmins ([#9283](https://github.com/windmill-labs/windmill/issues/9283)) ([8272244](https://github.com/windmill-labs/windmill/commit/82722449e79da0b4b0ad4142aec7e7965e9ff236))
* **git-sync:** bump to hub/28234 with stateless gpg.program wrapper (WIN-1974) ([#9282](https://github.com/windmill-labs/windmill/issues/9282)) ([89a2f07](https://github.com/windmill-labs/windmill/commit/89a2f07218818b95238b4a4484deab3138099672))
* **nsjail:** gate unix-symlink test behind cfg(unix) for Windows build ([#9280](https://github.com/windmill-labs/windmill/issues/9280)) ([72e2c3a](https://github.com/windmill-labs/windmill/commit/72e2c3a6b3e0cb0f5bddf8291ae18bb8cf55ec28))
## [1.706.0](https://github.com/windmill-labs/windmill/compare/v1.705.0...v1.706.0) (2026-05-21)
### Features
* add userdraft listing primitives ([#9268](https://github.com/windmill-labs/windmill/issues/9268)) ([d0ee697](https://github.com/windmill-labs/windmill/commit/d0ee697e8b8de58085ea0b2ecde1af2b2441428d))
* add UV_PYTHON_INSTALL_MIRROR env and instance setting ([#9271](https://github.com/windmill-labs/windmill/issues/9271)) ([1169371](https://github.com/windmill-labs/windmill/commit/1169371d4885bdc18c76d03c6caae71f0e440235))
* add yolo mode for ai chat tools ([#9258](https://github.com/windmill-labs/windmill/issues/9258)) ([ac26aa4](https://github.com/windmill-labs/windmill/commit/ac26aa4e4c7cc2d493f136b59738c0708803cc6d))
* CLI datatable serve / psql ([#9267](https://github.com/windmill-labs/windmill/issues/9267)) ([28c8b5c](https://github.com/windmill-labs/windmill/commit/28c8b5c60fd46f961ae11b363b9be834fad6ee68))
* **cli:** add `wmill init prompts` and custom override slot ([#9266](https://github.com/windmill-labs/windmill/issues/9266)) ([1ba8ed8](https://github.com/windmill-labs/windmill/commit/1ba8ed8abd827313ce0f7728d9f84357417206ee))
* **nsjail:** optional disk-backed /tmp via instance setting ([#9272](https://github.com/windmill-labs/windmill/issues/9272)) ([b656dc6](https://github.com/windmill-labs/windmill/commit/b656dc6cdc8c50ef9740240447f119cceed18547))
### Bug Fixes
* **ai:** enforce RLS and scope check on user-supplied X-Resource-Path ([#9276](https://github.com/windmill-labs/windmill/issues/9276)) ([0692b97](https://github.com/windmill-labs/windmill/commit/0692b97c8a3818549d7050ea3e057e9cbf1ddb44))
* **debugger:** add non-root user support to Dockerfile ([#9277](https://github.com/windmill-labs/windmill/issues/9277)) ([0bdb6a9](https://github.com/windmill-labs/windmill/commit/0bdb6a9d5d5fb28a27af1b6eda9fde7172308faf))
* **indexer:** tell admins when ingress routes search to wrong pod ([#9274](https://github.com/windmill-labs/windmill/issues/9274)) ([d29a561](https://github.com/windmill-labs/windmill/commit/d29a5612fcd17eb4197468289e955a1209127cc1))
## [1.705.0](https://github.com/windmill-labs/windmill/compare/v1.704.1...v1.705.0) (2026-05-20)
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n (elem->>'installation_id')::bigint as installation_id,\n elem->>'account_id' as account_id,\n elem->>'github_base_url' as github_base_url,\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "installation_id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "account_id",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "github_base_url",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "provisioned_by_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null,
null,
null
]
},
"hash": "14bc9dd1d02a3d121297509beacc27f3c29d1b3877c1f2e7c206f0e36ef18701"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)\n VALUES ('wm-fork-stale-super', 'f/folder2/myscript', 333333, 'echo 1', '', '', 'bash', 'test-user-2', NOW(), false, false, false, false, $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "2e35598cb9695b726ee1d2cd5c8364503371a5272d88e970b95760555ab33ce2"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"is_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE workspace_id = $1\n AND (elem->>'installation_id')::bigint = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "2f166b5575a614b028c3130fc5089353bef40f1cccf31b7775d0e9a800425f4d"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)\n VALUES ('wm-fork-visibility-test', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "31445efb75a7b706f4404c411a4ef6a9ed6d29a02bb7fd08f2ceb0551ecac640"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)\n VALUES ('test-workspace', 'folder1', 'folder1', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "31e486e3377e79bfab4e391d6789d081edf45fef34f630372815ec323544acee"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM workspace_settings WHERE workspace_id = $1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "3c42a56d0ffe39ad217f2ee603431637bcb22c6e713a21bdb83204de9cf383d7"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) AS \"count!\" FROM workspace_diff\n WHERE source_workspace_id = 'test-workspace'\n AND fork_workspace_id = 'wm-fork-rename-test'\n AND kind = 'script'\n AND path = 'f/folder2/myscript'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "3fac8694f59803a42b635ce7dd1e60a7a4f53c3b7ed6592f70ef4fed97b2bc6d"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES\n ('wm-fork-visibility-test', 'test2@windmill.dev', 'test-user-2', false, 'User')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "652637b534f7d7b4c429a201247821e9f568b1976ea462a09e779e9a4c490197"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT username, is_admin, operator FROM usr\n WHERE workspace_id = $1 AND email = $2 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "is_admin",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "operator",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "8d64e61fad7bdf0cc4d4cad1a032e570bdfaf885b4f39ef3ab973256e0448ec7"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM skip_workspace_diff_tally",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "8eb5866b6279cb386bbeb7c387a7c731317199b28694a9c3e04028ef1f1d7500"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ('test-workspace', 'wm-fork-visibility-test', 'f/folder2/myscript', 'script', 1, 0, NULL)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "903c01dbda5996417a81f7fd76fb21a3566667ec1a9463c7464c5cd788d89270"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM skip_workspace_diff_tally WHERE workspace_id IN ('test-workspace', 'wm-fork-visibility-test')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "90c765e384170c2e9f9bc244c7578991315969faf7b845c93678c7ef63b8b8cb"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT last_locked_at FROM concurrency_locks WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_locked_at",
"type_info": "Timestamp"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "9b88e522ecbe9fa67ef83e79ec5eb5c9c87999a877fcb7f23be75d991bba6e49"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)\n VALUES ('wm-fork-stale-super', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "a420ea939b0bfe58b89f29c9eacf2d3fbbe0a140cddad3762e0b4147eb84c0b4"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr (workspace_id, email, username, is_admin, role)\n VALUES ('wm-fork-rename-test', 'test2@windmill.dev', 'test-user-2', false, 'User')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "b8cf0655ecb679c8437ea897a28ec86cd9f3b485a1bacccbd2e5ac5266ac6f01"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)\n VALUES ('wm-fork-visibility-test', 'f/folder2/myscript', 222222, 'def main():\n return 1', '', '', 'python3', 'test-user-2', NOW(), false, false, false, false, $1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb"
]
},
"nullable": []
},
"hash": "d94636ff736f9cfefb3c001acab6fb7fe3573e3aa71441d04b7cd7bba685b371"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET super_admin = true WHERE email = 'test2@windmill.dev'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "e48bf61e59268f95ec389ef30eac15271cbba1601c589a6e0f3399875438aae9"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n workspace_id,\n (elem->>'installation_id')::bigint as \"installation_id!\",\n COALESCE((elem->>'provisioned_by_admin')::bool, false) as \"provisioned_by_admin!\"\n FROM workspace_settings,\n LATERAL jsonb_array_elements(git_app_installations) AS elem\n WHERE (elem->>'installation_id')::bigint = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "installation_id!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "provisioned_by_admin!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
null,
null
]
},
"hash": "f5e98ff83301b89f33e4454ae944da1977030cf2db9dadd372188902bb23062f"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ('test-workspace', 'wm-fork-stale-super', 'f/folder2/myscript', 'script', 1, 0, NULL)",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "f83cf3c87a1e80d4a0a7f236c4fa472a4f9ae49e9c2e601c41cd7229ecde765a"
}
+86 -86
View File
@@ -727,9 +727,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-config"
@@ -1846,9 +1846,9 @@ dependencies = [
[[package]]
name = "bumpalo"
version = "3.20.2"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
dependencies = [
"allocator-api2",
]
@@ -4315,9 +4315,9 @@ dependencies = [
[[package]]
name = "either"
version = "1.15.0"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
dependencies = [
"serde",
]
@@ -10473,9 +10473,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.149"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"indexmap 2.14.0",
"itoa",
@@ -13788,7 +13788,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-nats",
@@ -13869,7 +13869,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"async-stream",
"async-trait",
@@ -13901,7 +13901,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -13914,7 +13914,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"argon2",
@@ -14057,7 +14057,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14080,7 +14080,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14093,7 +14093,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14119,7 +14119,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -14129,7 +14129,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14146,7 +14146,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"base64 0.22.1",
@@ -14168,7 +14168,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14191,7 +14191,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14207,7 +14207,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14228,7 +14228,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14249,7 +14249,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14263,7 +14263,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-nats",
@@ -14295,7 +14295,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14320,7 +14320,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"flate2",
@@ -14338,7 +14338,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14360,7 +14360,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14380,7 +14380,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14410,7 +14410,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14438,7 +14438,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"lazy_static",
"serde",
@@ -14450,7 +14450,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"argon2",
"axum 0.8.9",
@@ -14475,7 +14475,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14489,7 +14489,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"axum 0.8.9",
"chrono",
@@ -14522,7 +14522,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"chrono",
"lazy_static",
@@ -14536,7 +14536,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"axum 0.8.9",
@@ -14555,7 +14555,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -14656,7 +14656,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -14675,7 +14675,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"regex",
"serde",
@@ -14690,7 +14690,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -14714,7 +14714,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"futures",
@@ -14731,7 +14731,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -14747,7 +14747,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14768,7 +14768,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -14799,7 +14799,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -14824,7 +14824,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-stream",
@@ -14858,7 +14858,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"futures",
@@ -14876,7 +14876,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -14885,7 +14885,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14897,7 +14897,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14909,7 +14909,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"gosyn",
@@ -14921,7 +14921,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -14933,7 +14933,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde_json",
@@ -14945,7 +14945,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -14956,7 +14956,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14967,7 +14967,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -14979,7 +14979,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -14990,7 +14990,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15012,7 +15012,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde_json",
@@ -15024,7 +15024,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15038,7 +15038,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15055,7 +15055,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15068,7 +15068,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde",
@@ -15080,7 +15080,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -15098,7 +15098,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -15114,7 +15114,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -15130,7 +15130,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde",
@@ -15141,7 +15141,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15178,7 +15178,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"const_format",
@@ -15216,7 +15216,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -15227,7 +15227,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -15257,7 +15257,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15281,7 +15281,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15314,7 +15314,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15347,7 +15347,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15367,7 +15367,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15401,7 +15401,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15437,7 +15437,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15460,7 +15460,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15484,7 +15484,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15508,7 +15508,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15543,7 +15543,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15571,7 +15571,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-trait",
@@ -15594,7 +15594,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"bitflags 2.11.1",
@@ -15613,7 +15613,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -15723,7 +15723,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.705.0"
version = "1.708.0"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.705.0"
version = "1.708.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
daffe7bb81cfcaca666c61de1ee838a44d60ebc2
da5189cf69a453de3855057f41be0d84e5910707
+24 -24
View File
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.705.0"
version = "1.708.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.705.0"
version = "1.708.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+39 -13
View File
@@ -51,14 +51,17 @@ use windmill_common::{
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING,
OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING,
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
RESTART_COORDINATION_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING,
TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING,
UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_REGISTRIES_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING,
NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACING_PROXY_SETTING,
PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING,
PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING,
RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING,
SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING,
TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING,
UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING,
WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING,
WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_REGISTRIES_SETTING,
},
scripts::ScriptLang,
stats_oss::schedule_stats,
@@ -119,7 +122,9 @@ use crate::monitor::{
initial_load, load_disable_password_login, load_fork_workspace_tag_append_fork_suffix,
load_keep_job_dir, load_metrics_debug_enabled, load_preview_tags_override,
load_require_preexisting_user, load_tag_per_workspace_enabled,
load_tag_per_workspace_workspaces, monitor_db, reload_app_workspaced_route_setting,
load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs,
load_workspace_fairness_enabled, load_workspace_fairness_max_percent,
load_workspace_fairness_min_total, monitor_db, reload_app_workspaced_route_setting,
reload_audit_log_retention_days_setting, reload_base_url_setting,
reload_bun_install_min_release_age_setting, reload_bunfig_install_scopes_setting,
reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting,
@@ -127,10 +132,10 @@ use crate::monitor::{
reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting,
reload_hub_base_url_setting, reload_instance_events_webhook_setting,
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmpfs_size_setting,
reload_otel_tracing_proxy_setting, reload_pip_index_url_setting,
reload_retention_period_setting, reload_scim_token_setting, reload_smtp_config,
reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting,
reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting,
reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting,
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting,
reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting,
reload_worker_config, MonitorIteration,
};
@@ -1764,6 +1769,26 @@ async fn process_notify_event(
tracing::error!("Error loading preview tags override: {e:#}");
}
}
WORKSPACE_FAIRNESS_ENABLED_SETTING => {
if let Err(e) = load_workspace_fairness_enabled(db).await {
tracing::error!("Error loading workspace fairness enabled: {e:#}");
}
}
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING => {
if let Err(e) = load_workspace_fairness_max_percent(db).await {
tracing::error!("Error loading workspace fairness max percent: {e:#}");
}
}
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING => {
if let Err(e) = load_workspace_fairness_duration_secs(db).await {
tracing::error!("Error loading workspace fairness duration secs: {e:#}");
}
}
WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING => {
if let Err(e) = load_workspace_fairness_min_total(db).await {
tracing::error!("Error loading workspace fairness min total: {e:#}");
}
}
SMTP_SETTING => {
reload_smtp_config(db).await;
}
@@ -1785,6 +1810,7 @@ async fn process_notify_event(
JOB_DEFAULT_TIMEOUT_SECS_SETTING => reload_job_default_timeout_setting(conn).await,
JOB_ISOLATION_SETTING => reload_job_isolation_setting(conn).await,
NSJAIL_TMPFS_SIZE_MB_SETTING => reload_nsjail_tmpfs_size_setting(conn).await,
NSJAIL_TMP_BACKING_SETTING => reload_nsjail_tmp_backing_setting(conn).await,
#[cfg(feature = "parquet")]
OBJECT_STORE_CONFIG_SETTING => {
if !disable_s3_store {
+147 -11
View File
@@ -62,13 +62,15 @@ use windmill_common::{
HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING,
JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NSJAIL_TMPFS_SIZE_MB_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING,
TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING,
UV_PYTHON_INSTALL_MIRROR_SETTING,
NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING,
OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING,
POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING,
REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING,
RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING,
STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING,
UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING,
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
},
indexer::load_indexer_config,
jwt::JWT_SECRET,
@@ -84,7 +86,8 @@ use windmill_common::{
store_suspended_pull_query, Connection, WorkerConfig, DEFAULT_TAGS_PER_WORKSPACE,
DEFAULT_TAGS_WORKSPACES, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX, INDEXER_CONFIG,
PREVIEW_TAGS_OVERRIDE, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR, WORKER_CONFIG,
WORKER_GROUP,
WORKER_GROUP, WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED,
WORKSPACE_FAIRNESS_MAX_PERCENT, WORKSPACE_FAIRNESS_MIN_TOTAL,
},
KillpillSender, AUDIT_LOG_RETENTION_DAYS, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY, CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB,
@@ -108,9 +111,9 @@ use windmill_worker::{
BUN_INSTALL_MIN_RELEASE_AGE, CARGO_REGISTRIES, INSTANCE_PYTHON_VERSION, JAVA_HOME_DIR,
JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML,
NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB,
NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL,
POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY,
UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES,
NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL,
PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER,
UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES,
};
#[cfg(feature = "parquet")]
@@ -248,6 +251,22 @@ pub async fn initial_load(
if let Err(e) = load_preview_tags_override(db).await {
tracing::error!("Error loading preview tags override: {e:#}");
}
// Workspace fairness (cloud-only). Load the percentage/duration/min knobs
// *before* the enabled flag so that `load_workspace_fairness_enabled` reads
// current values when re-storing the pull queries.
if let Err(e) = load_workspace_fairness_max_percent(db).await {
tracing::error!("Error loading workspace fairness max percent: {e:#}");
}
if let Err(e) = load_workspace_fairness_duration_secs(db).await {
tracing::error!("Error loading workspace fairness duration secs: {e:#}");
}
if let Err(e) = load_workspace_fairness_min_total(db).await {
tracing::error!("Error loading workspace fairness min total: {e:#}");
}
if let Err(e) = load_workspace_fairness_enabled(db).await {
tracing::error!("Error loading workspace fairness enabled: {e:#}");
}
}
if server_mode {
@@ -387,6 +406,7 @@ pub async fn initial_load(
reload_job_default_timeout_setting(&conn).await;
reload_job_isolation_setting(&conn).await;
reload_nsjail_tmpfs_size_setting(&conn).await;
reload_nsjail_tmp_backing_setting(&conn).await;
reload_extra_pip_index_url_setting(&conn).await;
reload_pip_index_url_setting(&conn).await;
reload_uv_index_strategy_setting(&conn).await;
@@ -542,6 +562,112 @@ pub async fn load_preview_tags_override(db: &DB) -> error::Result<()> {
Ok(())
}
// Upper bound on the duration window. Postgres `make_interval(secs => $1::int4)` is the consumer
// downstream, so this stays comfortably below `i32::MAX` and the subsequent `u32 -> i32` cast in
// `workspace_fairness::refresh_overloaded` cannot wrap into a negative interval (which would
// silently turn `now() - interval` into a future timestamp and disable the completed-jobs half
// of the activity signal). A day is the practical ceiling for a "rolling window" knob.
const WORKSPACE_FAIRNESS_DURATION_SECS_MAX: u64 = 86_400;
/// Min-total floor is a counting threshold; cap at `u32::MAX` to make wraparound impossible
/// while still leaving more headroom than any realistic cluster will need.
const WORKSPACE_FAIRNESS_MIN_TOTAL_MAX: u64 = u32::MAX as u64;
// Defaults used when a fairness knob is unset (row missing or row deleted via NULL/empty value).
// Must stay in sync with the `AtomicU32::new(...)` initialisers in `windmill-common/src/worker.rs`
// so a process that has never seen the setting reads the same value as one that just saw it
// cleared.
const WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT: u32 = 50;
const WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT: u32 = 10;
const WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT: u32 = 4;
pub async fn load_workspace_fairness_enabled(db: &DB) -> error::Result<()> {
// Match the convention used by `load_preview_tags_override` /
// `load_fork_workspace_tag_append_fork_suffix`: on transient DB errors, leave the in-memory
// atomic untouched rather than silently toggling the feature off across the whole cluster
// (which would also trigger an unnecessary `store_pull_query` rebuild — exactly when DB load
// is probably highest).
let new_enabled =
match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_ENABLED_SETTING).await? {
Some(serde_json::Value::Bool(t)) => t,
// Setting unset / non-bool → explicit off.
_ => false,
};
let prev = WORKSPACE_FAIRNESS_ENABLED.swap(new_enabled, Ordering::Relaxed);
// Re-store the pull queries so the fairness variants appear/disappear in
// lockstep with the toggle.
if prev != new_enabled {
let wc = windmill_common::worker::WORKER_CONFIG.load_full();
store_pull_query(&wc).await;
}
Ok(())
}
pub async fn load_workspace_fairness_max_percent(db: &DB) -> error::Result<()> {
// Distinguish three outcomes:
// - `Err(_)`: transient DB issue. Leave the atomic alone (don't clobber a known-good value
// because of a network blip during a notify-event propagation).
// - `Ok(None)` or `Ok(Some(invalid))`: setting is unset / explicitly cleared / corrupt.
// Restore the default so a deletion via the admin UI actually takes effect at runtime
// instead of leaving the stale in-memory value pinned until restart.
// - `Ok(Some(valid))`: clamp and store.
match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING).await? {
Some(serde_json::Value::Number(n)) => {
let v = n
.as_u64()
.map(|u| u.clamp(1, 100) as u32)
.unwrap_or(WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT);
WORKSPACE_FAIRNESS_MAX_PERCENT.store(v, Ordering::Relaxed);
}
_ => {
WORKSPACE_FAIRNESS_MAX_PERCENT
.store(WORKSPACE_FAIRNESS_MAX_PERCENT_DEFAULT, Ordering::Relaxed);
}
}
Ok(())
}
pub async fn load_workspace_fairness_duration_secs(db: &DB) -> error::Result<()> {
// See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING).await? {
Some(serde_json::Value::Number(n)) => {
// Clamp to the safe range before narrowing. The downstream `u32 -> i32` cast in
// `workspace_fairness::refresh_overloaded` makes any value above `i32::MAX` toxic
// (sign flip → negative interval → silent disable of the completed-jobs scan).
let v = n
.as_u64()
.map(|u| u.clamp(1, WORKSPACE_FAIRNESS_DURATION_SECS_MAX) as u32)
.unwrap_or(WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT);
WORKSPACE_FAIRNESS_DURATION_SECS.store(v, Ordering::Relaxed);
}
_ => {
WORKSPACE_FAIRNESS_DURATION_SECS
.store(WORKSPACE_FAIRNESS_DURATION_SECS_DEFAULT, Ordering::Relaxed);
}
}
Ok(())
}
pub async fn load_workspace_fairness_min_total(db: &DB) -> error::Result<()> {
// See `load_workspace_fairness_max_percent` for the Err / None / invalid policy.
match load_value_from_global_settings(db, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING).await? {
Some(serde_json::Value::Number(n)) => {
// Clamp before narrowing — same reasoning as `_duration_secs`, just for the
// counting threshold rather than the interval.
let v = n
.as_u64()
.map(|u| u.min(WORKSPACE_FAIRNESS_MIN_TOTAL_MAX) as u32)
.unwrap_or(WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT);
WORKSPACE_FAIRNESS_MIN_TOTAL.store(v, Ordering::Relaxed);
}
_ => {
WORKSPACE_FAIRNESS_MIN_TOTAL
.store(WORKSPACE_FAIRNESS_MIN_TOTAL_DEFAULT, Ordering::Relaxed);
}
}
Ok(())
}
pub async fn load_fork_workspace_tag_append_fork_suffix(db: &DB) -> error::Result<()> {
let value =
load_value_from_global_settings(db, FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING).await;
@@ -1909,6 +2035,16 @@ pub async fn reload_nsjail_tmpfs_size_setting(conn: &Connection) {
.await;
}
pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) {
reload_option_setting_with_tracing(
conn,
NSJAIL_TMP_BACKING_SETTING,
"NSJAIL_TMP_BACKING",
NSJAIL_TMP_BACKING.clone(),
)
.await;
}
pub async fn reload_job_isolation_setting(conn: &Connection) {
let value =
match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await {
+101 -72
View File
@@ -250,92 +250,121 @@ impl AuthCache {
let username_override = username_override_from_label(label);
if let Some((prefix, name)) = owner.split_once('/') {
if prefix == "u" {
let (is_admin, is_operator) = if super_admin {
(true, false)
let lookup = if super_admin {
Some((true, false))
} else {
let r = sqlx::query!(
sqlx::query!(
"SELECT is_admin, operator FROM usr where username = $1 AND \
workspace_id = $2 AND disabled = false",
name,
&w_id.as_ref().unwrap()
)
.fetch_one(&self.db)
.fetch_optional(&self.db)
.await
.ok();
if let Some(r) = r {
(r.is_admin, r.operator)
} else {
(false, true)
}
.ok()
.flatten()
.map(|r| (r.is_admin, r.operator))
};
let w_id = &w_id.unwrap();
let groups =
get_groups_for_user(w_id, &name, &email, &self.db)
.await
.ok()
.unwrap_or_default();
if let Some((is_admin, is_operator)) = lookup {
let w_id = &w_id.unwrap();
let groups =
get_groups_for_user(w_id, &name, &email, &self.db)
.await
.ok()
.unwrap_or_default();
let folders =
get_folders_for_user(w_id, &name, &groups, &self.db)
.await
.ok()
.unwrap_or_default();
let folders = get_folders_for_user(
w_id, &name, &groups, &self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: name.to_string(),
is_admin,
is_operator,
groups,
folders,
scopes: None,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
Some(ApiAuthed {
email: email,
username: name.to_string(),
is_admin,
is_operator,
groups,
folders,
scopes: None,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
} else {
tracing::warn!(
"Token owner u/{} is not a member of workspace {}; rejecting auth",
name,
w_id.as_deref().unwrap_or("")
);
None
}
} else if prefix == "g" {
let group_exists = if super_admin {
true
} else {
sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM group_ WHERE workspace_id = $1 AND name = $2)",
&w_id.as_ref().unwrap(),
name,
)
.fetch_one(&self.db)
.await
.ok()
.flatten()
.unwrap_or(false)
};
if group_exists {
let groups = vec![name.to_string()];
let folders = get_folders_for_user(
&w_id.unwrap(),
"",
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: format!(
"{}{name}",
windmill_common::users::USERNAME_GROUP_PREFIX
),
is_admin: false,
groups,
is_operator: false,
folders,
scopes: None,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
} else {
tracing::warn!(
"Token owner g/{} is not a group in workspace {}; rejecting auth",
name,
w_id.as_deref().unwrap_or("")
);
None
}
} else {
let groups = vec![name.to_string()];
let folders = get_folders_for_user(
&w_id.unwrap(),
"",
&groups,
&self.db,
)
.await
.ok()
.unwrap_or_default();
Some(ApiAuthed {
email: email,
username: format!(
"{}{name}",
windmill_common::users::USERNAME_GROUP_PREFIX
),
is_admin: false,
groups,
is_operator: false,
folders,
scopes: None,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
tracing::warn!(
"Token owner '{}' has unrecognised prefix '{}'; rejecting auth",
owner,
prefix
);
None
}
} else {
let groups = vec![];
let folders = vec![];
Some(ApiAuthed {
email: email,
username: owner,
is_admin: super_admin,
is_operator: true,
groups,
folders,
scopes: None,
username_override,
token_prefix: Some(safe_token_prefix(token)),
read_only,
})
tracing::warn!(
"Token owner '{}' is missing a prefix (expected u/ or g/); rejecting auth",
owner
);
None
}
}
(_, Some(email), super_admin, scopes, label, read_only) => {
@@ -10,6 +10,10 @@ fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn authed_with(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {token}"))
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
@@ -106,3 +110,125 @@ async fn test_ai_proxy_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
Ok(())
}
/// Regression test for WIN-1971: the AI proxy's X-Resource-Path header must
/// honour resource RLS so that a low-privilege user cannot point the proxy
/// at a resource they are not allowed to read.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_ai_proxy_x_resource_path_enforces_rls(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let mock_port = start_mock_ai_api().await;
let mock_url = format!("http://127.0.0.1:{mock_port}/v1");
// Resource owned by test-user (admin). With default extra_perms {} the
// RLS `see_own` policy restricts SELECT to user `test-user`.
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \
VALUES ('test-workspace', 'u/test-user/restricted_openai', $1::jsonb, 'openai', '{}', 'test-user')",
)
.bind(json!({
"api_key": "sk-secret-restricted",
"base_url": mock_url,
}))
.execute(&db)
.await?;
// Sanity-check: normal resource API rejects test-user-3 (non-admin) for the restricted path.
let resp = authed_with(
client().get(format!(
"http://localhost:{port}/api/w/test-workspace/resources/get/u/test-user/restricted_openai"
)),
"SECRET_TOKEN_3",
)
.send()
.await?;
assert!(
resp.status().as_u16() >= 400,
"normal resource API should deny test-user-3 reading restricted resource, got {}",
resp.status()
);
// The vulnerability: as a non-admin user, point X-Resource-Path at the
// restricted resource. Must be rejected before the proxy fetches/uses it.
let resp = authed_with(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions"
))
.header("X-Provider", "openai")
.header("X-Resource-Path", "u/test-user/restricted_openai")
.json(&json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}]
})),
"SECRET_TOKEN_3",
)
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert!(
status >= 400,
"non-admin user should be rejected when X-Resource-Path points at a resource they cannot read, got {status}: {body}",
);
// A resource the non-admin owns must still work through X-Resource-Path.
sqlx::query(
"INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) \
VALUES ('test-workspace', 'u/test-user-3/own_openai', $1::jsonb, 'openai', '{}', 'test-user-3')",
)
.bind(json!({
"api_key": "sk-self",
"base_url": mock_url,
}))
.execute(&db)
.await?;
let resp = authed_with(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions"
))
.header("X-Provider", "openai")
.header("X-Resource-Path", "u/test-user-3/own_openai")
.json(&json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}]
})),
"SECRET_TOKEN_3",
)
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"non-admin with X-Resource-Path on owned resource",
);
// Admin must still be able to use X-Resource-Path on any resource.
let resp = authed(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions"
))
.header("X-Provider", "openai")
.header("X-Resource-Path", "u/test-user/restricted_openai")
.json(&json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}]
})),
)
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"admin with X-Resource-Path on restricted resource",
);
Ok(())
}
@@ -797,3 +797,479 @@ async fn test_compare_workspaces_trigger_and_schedule(db: Pool<Postgres>) -> any
Ok(())
}
/// Regression for the "superadmin-still-sees-the-warning" case in WIN-1975.
///
/// `compare_workspaces` historically trusted `authed.is_admin` for RLS — but
/// that flag is derived from the *token's* cached `super_admin` column at
/// auth time (windmill-api-auth/src/auth.rs), not from a live
/// `password.super_admin` read. A user who is *currently* an instance
/// superadmin can have a token from before the promotion (or via a session
/// refresh race) where `token.super_admin = false`. If they're also not a
/// workspace admin in the source workspace (only in the fork),
/// `authed.is_admin` lands as `false` and source-scoped RLS gets applied to
/// fork-side visibility queries — same bug as the regular non-admin case.
///
/// With the fix, `load_workspace_authed` re-checks `is_super_admin_email`
/// against `password.super_admin` at request time, so the fork-scoped authed
/// gets `is_admin = true` and RLS bypass kicks back in for the fork queries.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_compare_workspaces_stale_superadmin_token(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base_url = format!("http://localhost:{port}/api");
// Promote test-user-2 to instance superadmin AFTER their token was issued
// (base.sql inserts SECRET_TOKEN_2 with super_admin=false). The token row
// keeps super_admin=false; password.super_admin flips to true.
sqlx::query!("UPDATE password SET super_admin = true WHERE email = 'test2@windmill.dev'")
.execute(&db)
.await?;
let stale_super = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN_2".to_string(),
);
// Fork test-workspace.
let resp = stale_super
.client()
.post(&format!(
"{base_url}/w/test-workspace/workspaces/create_fork"
))
.json(&json!({
"id": "wm-fork-stale-super",
"name": "Stale Super Fork",
"color": "#0000ff"
}))
.send()
.await?;
assert!(
resp.status().is_success(),
"fork creation failed: {} — {}",
resp.status(),
resp.text().await?
);
// Fork-only folder + script, with empty extra_perms so the only way to
// see them is via fork's folder-based RLS or admin bypass.
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)
VALUES ('wm-fork-stale-super', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
json!({"u/test-user-2": true})
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)
VALUES ('wm-fork-stale-super', 'f/folder2/myscript', 333333, 'echo 1', '', '', 'bash', 'test-user-2', NOW(), false, false, false, false, $1)",
json!({})
)
.execute(&db)
.await?;
sqlx::query!(
"INSERT INTO workspace_diff
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
VALUES ('test-workspace', 'wm-fork-stale-super', 'f/folder2/myscript', 'script', 1, 0, NULL)"
)
.execute(&db)
.await?;
sqlx::query!("DELETE FROM skip_workspace_diff_tally")
.execute(&db)
.await?;
let comparison: serde_json::Value = stale_super
.client()
.get(&format!(
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-stale-super"
))
.send()
.await?
.json()
.await?;
assert_eq!(
comparison["all_ahead_items_visible"].as_bool(),
Some(true),
"current superadmin with stale token should still see ahead items: {comparison}"
);
let diffs = comparison["diffs"].as_array().unwrap();
assert!(
diffs
.iter()
.any(|d| d["path"] == "f/folder2/myscript" && d["kind"] == "script"),
"fork-only script should appear in diffs; got {diffs:?}"
);
Ok(())
}
/// End-to-end regression for WIN-1975 against the real EE tally path.
/// Reproduces the reporter's exact steps with the API: fork → create script
/// in folder1 → rename to folder2 → compare. Folder2 only exists in the
/// fork, so before the fix the source-scoped authed in `filter_visible_diffs`
/// hid the script and the response set `all_ahead_items_visible = false`.
///
/// Gated on `private` because the OSS build of `handle_deployment_metadata`
/// is a no-op (`windmill-git-sync/src/git_sync_oss.rs`) — without it the
/// `workspace_diff` rows never get written and the test would assert against
/// an empty diff set.
#[cfg(feature = "private")]
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_compare_workspaces_rename_visibility_ee_e2e(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base_url = format!("http://localhost:{port}/api");
let admin = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
let non_admin = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN_2".to_string(),
);
// The base fixture pre-populates `skip_workspace_diff_tally` for every
// workspace existing at migration time — that bypasses the diff
// accounting. Clear it so tally + compare run normally for this test.
sqlx::query!("DELETE FROM skip_workspace_diff_tally")
.execute(&db)
.await?;
// ------ Fork the existing test-workspace.
let resp = admin
.client()
.post(&format!(
"{base_url}/w/test-workspace/workspaces/create_fork"
))
.json(&json!({
"id": "wm-fork-rename-test",
"name": "Rename Fork",
"color": "#0000ff"
}))
.send()
.await?;
assert!(
resp.status().is_success(),
"fork creation failed: {}",
resp.status()
);
// Non-admin user must be a member of both workspaces. They already are in
// test-workspace (base fixture); add them to the fork. Same username as
// the source so RLS extra_perms keys still resolve.
sqlx::query!(
"INSERT INTO usr (workspace_id, email, username, is_admin, role)
VALUES ('wm-fork-rename-test', 'test2@windmill.dev', 'test-user-2', false, 'User')"
)
.execute(&db)
.await?;
// ------ Non-admin creates folder1 in the fork (owner = self).
let resp = non_admin
.client()
.post(&format!("{base_url}/w/wm-fork-rename-test/folders/create"))
.json(&json!({"name": "folder1", "owners": [], "summary": ""}))
.send()
.await?;
assert!(
resp.status().is_success(),
"folder1 create failed: {} — {}",
resp.status(),
resp.text().await?
);
// ------ Deploy a script in folder1 (initial deploy, no parent_hash).
let resp = non_admin
.client()
.post(&format!("{base_url}/w/wm-fork-rename-test/scripts/create"))
.json(&json!({
"path": "f/folder1/myscript",
"summary": "renamed test",
"description": "",
// Use bash so we don't trigger the dependency-job code path —
// create_script defers `handle_deployment_metadata` (and the
// tally) to the dep job for languages that need lock generation
// (Deno/Bun/Python/etc), which never runs in this test.
"content": "echo 1",
"language": "bash",
"schema": {"type": "object", "properties": {}, "required": []},
"deployment_message": "initial",
}))
.send()
.await?;
let status = resp.status();
let initial_hash = resp.text().await?;
assert!(
status.is_success(),
"initial script create failed: {} — {}",
status,
initial_hash
);
// ------ Create folder2 in fork.
let resp = non_admin
.client()
.post(&format!("{base_url}/w/wm-fork-rename-test/folders/create"))
.json(&json!({"name": "folder2", "owners": [], "summary": ""}))
.send()
.await?;
assert!(
resp.status().is_success(),
"folder2 create failed: {}",
resp.status()
);
// ------ Rename: re-deploy the same script at the new path with the old
// hash as parent_hash. This is exactly what the script editor sends when
// the user changes the path field and clicks Deploy. The EE tally upserts
// a workspace_diff row for both the new path AND the renamed_from path.
let resp = non_admin
.client()
.post(&format!("{base_url}/w/wm-fork-rename-test/scripts/create"))
.json(&json!({
"path": "f/folder2/myscript",
"summary": "renamed test",
"description": "",
// Use bash so we don't trigger the dependency-job code path —
// create_script defers `handle_deployment_metadata` (and the
// tally) to the dep job for languages that need lock generation
// (Deno/Bun/Python/etc), which never runs in this test.
"content": "echo 1",
"language": "bash",
"schema": {"type": "object", "properties": {}, "required": []},
// The API returns hash as hex (ScriptHash Serialize impl); pass it
// through verbatim — the backend deserializer parses hex back.
"parent_hash": initial_hash.trim().trim_matches('"'),
"deployment_message": "rename to folder2",
}))
.send()
.await?;
assert!(
resp.status().is_success(),
"rename failed: {} — {}",
resp.status(),
resp.text().await?
);
// The tally is fired via `tokio::spawn` in `handle_deployment_metadata`
// (windmill-git-sync/src/git_sync_ee.rs) — wait specifically for the
// renamed script row to appear so we don't race the actual case under
// test.
let mut script_diff_written = false;
for _ in 0..40 {
let row_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) AS \"count!\" FROM workspace_diff
WHERE source_workspace_id = 'test-workspace'
AND fork_workspace_id = 'wm-fork-rename-test'
AND kind = 'script'
AND path = 'f/folder2/myscript'"
)
.fetch_one(&db)
.await?;
if row_count >= 1 {
script_diff_written = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(
script_diff_written,
"tally never wrote the renamed-script row to workspace_diff"
);
// ------ Compare as the non-admin who owns folder2 in the fork. With the
// bug, the source-scoped authed has no folder2 entry → fork visibility
// query hides f/folder2/myscript → all_ahead_items_visible flips to
// false. With the fix, the fork-scoped authed sees folder2 and the
// visibility check passes.
let comparison: serde_json::Value = non_admin
.client()
.get(&format!(
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-rename-test"
))
.send()
.await?
.json()
.await?;
assert_eq!(
comparison["all_ahead_items_visible"].as_bool(),
Some(true),
"non-admin owner of fork-only folder should see ahead items as visible; got {comparison}"
);
let diffs = comparison["diffs"].as_array().unwrap();
assert!(
diffs
.iter()
.any(|d| d["path"] == "f/folder2/myscript" && d["kind"] == "script"),
"renamed script at f/folder2/myscript should appear in diffs; got {diffs:?}"
);
// The renamed_from row (f/folder1/myscript) must NOT appear: both sides'
// archived=false views show it missing, so compare_two_scripts returns
// has_changes=false and the row is deleted. Keep an explicit assertion
// so a future regression that leaks the old path is caught here.
assert!(
!diffs
.iter()
.any(|d| d["path"] == "f/folder1/myscript" && d["kind"] == "script"),
"renamed-from path f/folder1/myscript should be cleaned up; got {diffs:?}"
);
// ------ Also confirm the superadmin path still works (this used to be
// the only path that worked because RLS bypass masked the bug).
let comparison: serde_json::Value = admin
.client()
.get(&format!(
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-rename-test"
))
.send()
.await?
.json()
.await?;
assert_eq!(
comparison["all_ahead_items_visible"].as_bool(),
Some(true),
"superadmin must always see all ahead items: {comparison}"
);
Ok(())
}
/// Regression test for WIN-1975. A non-admin user creating a script in a fork-
/// only folder used to get the spurious
/// "this fork has changes not visible to your user" warning because
/// `filter_visible_diffs` ran every RLS query with the source-workspace
/// authed, so any item only reachable via fork-specific folders/groups was
/// hidden from the visibility check.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_compare_workspaces_fork_only_folder_visibility(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let client_user_2 = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN_2".to_string(),
);
let base_url = format!("http://localhost:{port}/api");
// ----- Set up parent workspace folder1 owned by test-user-2, then fork it.
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)
VALUES ('test-workspace', 'folder1', 'folder1', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
json!({"u/test-user-2": true})
)
.execute(&db)
.await?;
// Create fork via the API so cloning + workspace_settings.deploy_to wiring
// matches what production sees.
let client_admin = windmill_api_client::create_client(
&format!("http://localhost:{port}"),
"SECRET_TOKEN".to_string(),
);
let fork_response = client_admin
.client()
.post(&format!(
"{base_url}/w/test-workspace/workspaces/create_fork"
))
.json(&json!({
"id": "wm-fork-visibility-test",
"name": "Test Fork",
"color": "#0000ff"
}))
.send()
.await?;
assert!(
fork_response.status().is_success(),
"Fork creation failed: {}",
fork_response.status()
);
// test-user-2 must be a member of the fork. The fork's clone copies the
// creator's usr row only — add test-user-2 manually so they can hit the
// compare endpoint and own a fork-only folder.
sqlx::query!(
"INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES
('wm-fork-visibility-test', 'test2@windmill.dev', 'test-user-2', false, 'User')"
)
.execute(&db)
.await?;
// ----- Fork-only folder2 (does not exist in source) owned by test-user-2.
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, summary, created_by)
VALUES ('wm-fork-visibility-test', 'folder2', 'folder2', ARRAY['u/test-user-2']::varchar[], $1, '', 'test-user-2')",
json!({"u/test-user-2": true})
)
.execute(&db)
.await?;
// Script in the fork-only folder with empty extra_perms (typical: scripts
// inherit access through their containing folder, not direct perms).
sqlx::query!(
"INSERT INTO script (workspace_id, path, hash, content, summary, description, language, created_by, created_at, archived, schema_validation, ws_error_handler_muted, deleted, extra_perms)
VALUES ('wm-fork-visibility-test', 'f/folder2/myscript', 222222, 'def main():\n return 1', '', '', 'python3', 'test-user-2', NOW(), false, false, false, false, $1)",
json!({})
)
.execute(&db)
.await?;
// Seed workspace_diff to mirror what the tally would write.
sqlx::query!(
"INSERT INTO workspace_diff
(source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
VALUES ('test-workspace', 'wm-fork-visibility-test', 'f/folder2/myscript', 'script', 1, 0, NULL)"
)
.execute(&db)
.await?;
// Clear the skip flag added by the bootstrap migration so compare actually
// runs against this fork (it short-circuits otherwise).
sqlx::query!(
"DELETE FROM skip_workspace_diff_tally WHERE workspace_id IN ('test-workspace', 'wm-fork-visibility-test')"
)
.execute(&db)
.await?;
let comparison: serde_json::Value = client_user_2
.client()
.get(&format!(
"{base_url}/w/test-workspace/workspaces/compare/wm-fork-visibility-test"
))
.send()
.await?
.json()
.await?;
assert_eq!(
comparison["all_ahead_items_visible"].as_bool(),
Some(true),
"ahead items should be visible to the fork-only folder owner; full response: {comparison}"
);
assert_eq!(
comparison["all_behind_items_visible"].as_bool(),
Some(true),
"behind items should be visible (no behind items here)"
);
let diffs = comparison["diffs"].as_array().unwrap();
assert!(
diffs
.iter()
.any(|d| d["path"] == "f/folder2/myscript" && d["kind"] == "script"),
"fork-only script should appear in diffs; got {diffs:?}"
);
Ok(())
}
+63 -1
View File
@@ -54,10 +54,14 @@ use windmill_common::{
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, RUFF_CONFIG_SETTING, WS_BASE_URL_SETTING,
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, RUFF_CONFIG_SETTING,
WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING,
WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING,
WS_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
worker::is_cloud_production_host,
};
use windmill_common::{error::to_anyhow, PgDatabase};
@@ -446,6 +450,24 @@ pub async fn delete_global_setting(db: &DB, key: &str) -> error::Result<()> {
tracing::info!("Unset global setting {}", key);
Ok(())
}
/// Returns true when `key` is one of the workspace-fairness settings whose
/// writes must be gated to cloud only.
fn is_workspace_fairness_setting(key: &str) -> bool {
matches!(
key,
WORKSPACE_FAIRNESS_ENABLED_SETTING
| WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING
| WORKSPACE_FAIRNESS_DURATION_SECS_SETTING
| WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING
)
}
/// Cloud-and-app.windmill.dev gate for workspace fairness. Must hold to persist
/// the setting; the runtime path additionally verifies before applying the cap.
fn workspace_fairness_settings_allowed() -> bool {
is_cloud_production_host()
}
pub async fn set_global_setting(
Extension(db): Extension<DB>,
authed: ApiAuthed,
@@ -468,6 +490,27 @@ pub async fn set_global_setting_internal(
value
};
// Hard-gate the cloud-only workspace fairness settings: refuse to persist
// them on any instance that is not CLOUD_HOSTED + app.windmill.dev. This is
// belt-and-suspenders alongside the frontend `{#if isCloudHosted()}` wrap
// and the runtime check in `workspace_fairness::fairness_active`.
//
// Deletes (Null / empty-string) are *allowed* on non-cloud so admins can clear
// stale rows that ended up in `global_settings` via a cloned cloud DB. Without
// this exception a self-hosted instance would be stuck with cloud-only rows
// showing up in its instance-config YAML export.
let is_clearing_value = matches!(&value, serde_json::Value::Null)
|| matches!(&value, serde_json::Value::String(s) if s.trim().is_empty());
if is_workspace_fairness_setting(&key)
&& !is_clearing_value
&& !workspace_fairness_settings_allowed()
{
return Err(error::Error::BadRequest(format!(
"{} is only configurable on app.windmill.dev cloud (CLOUD_HOSTED + BASE_URL match required)",
key
)));
}
run_setting_pre_write_hook(db, &key, &value).await?;
match value {
@@ -726,6 +769,25 @@ async fn set_instance_config(
.iter()
.any(|(key, _)| key == AI_CONFIG_SETTING);
// Mirror the per-key cloud gate in `set_global_setting_internal`. Without this, the
// bulk endpoint would let a self-hosted superadmin persist `workspace_fairness_*` rows
// even though the per-key API rejects them. The runtime check in
// `workspace_fairness::fairness_active` still keeps the cap inert there, but persisting
// the rows would be a leak of cloud-only config into non-cloud DBs and would advertise
// the feature in the YAML export.
//
// Only block *upserts*; deletes are allowed everywhere so admins can clean up stale
// rows (e.g. from a cloned cloud DB) without flipping `CLOUD_HOSTED` on temporarily.
let upserts_touch_fairness = settings_diff
.upserts
.keys()
.any(|k| is_workspace_fairness_setting(k));
if upserts_touch_fairness && !workspace_fairness_settings_allowed() {
return Err(error::Error::BadRequest(
"Workspace fairness settings are only configurable on app.windmill.dev cloud (CLOUD_HOSTED + BASE_URL match required)".to_string(),
));
}
for (key, value) in &settings_diff.upserts {
run_setting_pre_write_hook(&db, key, value).await?;
}
@@ -6372,11 +6372,23 @@ async fn compare_workspaces(
}
}
// The authed in `authed` is loaded for the source workspace (the one in the
// URL path). Its `folders`/`groups`/`is_admin` reflect membership in the
// source workspace only. Using it as the RLS context when querying the
// fork's tables would hide items the user can only see via fork-specific
// permissions (e.g. a folder the user owns in the fork but that does not
// exist in the source), causing the spurious
// "this fork has changes not visible to your user" warning. Build a
// matching authed for the fork so each side's visibility check uses the
// right RLS context.
let fork_authed = load_workspace_authed(&db, &authed, &fork_workspace_id).await?;
let visible_diffs = filter_visible_diffs(
&confirmed_diffs,
&source_workspace_id,
&fork_workspace_id,
user_db.begin(&authed).await?,
&authed,
&fork_authed,
&user_db,
)
.await?;
@@ -6443,11 +6455,90 @@ async fn compare_workspaces(
}));
}
/// Build an `ApiAuthed` for the same user but scoped to a different workspace.
///
/// Reloads `is_admin`, `groups`, and `folders` from the target workspace's
/// `usr` / `group_` / `folder` tables (keyed by the caller's email) so the
/// returned authed can be used as the RLS context for queries against that
/// workspace. `is_admin` is OR'd with the user's superadmin status so cross-
/// workspace superadmins keep their RLS bypass.
///
/// If the user is not a member of `workspace_id`, returns an authed with no
/// folders/groups/operator/admin (except for superadmins, who stay admin) —
/// i.e. they will only see what RLS explicitly allows for unknown users.
async fn load_workspace_authed(
db: &DB,
base_authed: &ApiAuthed,
workspace_id: &str,
) -> Result<ApiAuthed> {
let mut conn = db
.acquire()
.await
.map_err(|e| Error::internal_err(e.to_string()))?;
let is_super_admin =
windmill_common::auth::is_super_admin_email(db, &base_authed.email).await?;
let user_row = sqlx::query!(
"SELECT username, is_admin, operator FROM usr
WHERE workspace_id = $1 AND email = $2 AND disabled = false",
workspace_id,
&base_authed.email
)
.fetch_optional(&mut *conn)
.await?;
let Some(user_row) = user_row else {
return Ok(ApiAuthed {
email: base_authed.email.clone(),
username: base_authed.username.clone(),
is_admin: is_super_admin,
is_operator: false,
groups: vec![],
folders: vec![],
scopes: base_authed.scopes.clone(),
username_override: base_authed.username_override.clone(),
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
});
};
let groups = windmill_common::auth::get_groups_for_user(
workspace_id,
&user_row.username,
&base_authed.email,
&mut *conn,
)
.await?;
let folders = windmill_common::auth::get_folders_for_user(
workspace_id,
&user_row.username,
&groups,
&mut *conn,
)
.await?;
Ok(ApiAuthed {
email: base_authed.email.clone(),
username: user_row.username,
is_admin: is_super_admin || user_row.is_admin,
is_operator: user_row.operator,
groups,
folders,
scopes: base_authed.scopes.clone(),
username_override: base_authed.username_override.clone(),
token_prefix: base_authed.token_prefix.clone(),
read_only: base_authed.read_only,
})
}
async fn filter_visible_diffs(
confirmed_diffs: &[WorkspaceDiffRow],
source_workspace_id: &str,
fork_workspace_id: &str,
mut tx: Transaction<'static, Postgres>,
source_authed: &ApiAuthed,
fork_authed: &ApiAuthed,
user_db: &UserDB,
) -> Result<Vec<WorkspaceDiffRow>> {
// Step 1: Group paths by (workspace, kind)
let mut source_items: HashMap<&str, Vec<&str>> = HashMap::new();
@@ -6462,9 +6553,23 @@ async fn filter_visible_diffs(
}
}
// Step 2: Batch query for each (workspace, kind) combination
let source_visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?;
let fork_visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?;
// Step 2: Batch query for each (workspace, kind) combination, each in its
// own transaction so RLS uses the right authed for each side. The fork's
// authed picks up fork-only folders/groups; without this split the fork
// queries would run with the source workspace's permissions and miss any
// item the user can only reach through fork-specific permissions.
let source_visible = {
let mut tx = user_db.clone().begin(source_authed).await?;
let visible = query_visible_items(&mut tx, source_workspace_id, &source_items).await?;
tx.commit().await?;
visible
};
let fork_visible = {
let mut tx = user_db.clone().begin(fork_authed).await?;
let visible = query_visible_items(&mut tx, fork_workspace_id, &fork_items).await?;
tx.commit().await?;
visible
};
// Step 3: Filter diffs based on visibility
let visible_diffs: Vec<WorkspaceDiffRow> = confirmed_diffs
+106 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.705.0
version: 1.708.0
title: Windmill API
contact:
@@ -2574,6 +2574,104 @@ paths:
- app_slug
- client_id
/github_app/ghes/discover:
get:
summary: Discover GHES App installations
description: |
Lists every installation the configured self-managed GitHub App can see,
annotated with the workspaces in this Windmill instance the
installation is currently assigned to. Super-admin only.
operationId: discoverGhesInstallations
tags:
- Git Sync
responses:
"200":
description: Discovered installations
content:
application/json:
schema:
type: array
items:
type: object
required:
- installation_id
- account_id
- assigned_workspaces
properties:
installation_id:
type: integer
format: int64
account_id:
type: string
description: GitHub login of the installation's account (org or user)
assigned_workspaces:
type: array
items:
type: object
required:
- workspace_id
- provisioned_by_admin
properties:
workspace_id:
type: string
provisioned_by_admin:
type: boolean
/github_app/ghes/assign:
post:
summary: Assign GHES installation to a workspace
description: |
Assigns a discovered GHES App installation to a workspace. The resulting
installation is marked as admin-provisioned, so workspace admins cannot
remove it. Super-admin only.
operationId: assignGhesInstallation
tags:
- Git Sync
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- workspace_id
- installation_id
properties:
workspace_id:
type: string
installation_id:
type: integer
format: int64
responses:
"200":
description: Installation assigned
/github_app/ghes/assign/{workspace_id}/{installation_id}:
delete:
summary: Unassign GHES installation from a workspace
description: |
Removes an installation (admin-provisioned or otherwise) from a
workspace. Super-admin only. Does not affect the installation on the
GitHub side.
operationId: unassignGhesInstallation
tags:
- Git Sync
parameters:
- name: workspace_id
in: path
required: true
schema:
type: string
- name: installation_id
in: path
required: true
schema:
type: integer
format: int64
responses:
"200":
description: Installation unassigned
/users/accept_invite:
post:
summary: accept invite to workspace
@@ -27823,6 +27921,13 @@ components:
error:
type: string
description: Error message if token retrieval failed
github_base_url:
type: string
nullable: true
description: Set for self-managed (GHES) installs. Cloud installs omit this field.
provisioned_by_admin:
type: boolean
description: True when the installation was assigned by the instance super-admin from instance settings. Workspace admins cannot remove these.
required:
- installation_id
- account_id
+38 -7
View File
@@ -1,6 +1,7 @@
#[cfg(feature = "bedrock")]
use crate::bedrock;
use crate::db::{ApiAuthed, DB};
use crate::utils::check_scopes;
#[cfg(feature = "bedrock")]
use axum::routing::get;
@@ -669,6 +670,7 @@ async fn global_proxy(
async fn proxy(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, mut ai_path)): Path<(String, String)>,
method: Method,
headers: HeaderMap,
@@ -689,6 +691,16 @@ async fn proxy(
.get("X-Resource-Path")
.map(|v| v.to_str().unwrap_or("").to_string());
let is_user_specified_resource = forced_resource_path.is_some();
// When the caller supplies X-Resource-Path, the resource is treated as if it
// were being read through the normal resource API: scope and RLS checks must
// apply so that a low-privilege user cannot point the proxy at a restricted
// AI resource (e.g. one in a folder they cannot read) to exfiltrate the
// resource's provider credentials or use them via the proxy.
if let Some(resource_path) = forced_resource_path.as_ref() {
check_scopes(&authed, || format!("resources:read:{}", resource_path))?;
}
let request_config = match workspace_cache {
Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => {
request_cache.config
@@ -759,13 +771,32 @@ async fn proxy(
)
};
let resource = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&db)
.await?
// For user-specified resources, fetch through an RLS-scoped
// connection so PostgreSQL row-level security enforces the same
// folder/group boundaries as the regular resource API. For the
// workspace/instance ai_config path, the resource_path was already
// validated by an admin/devops user when configuring the workspace,
// so the raw pool is used.
let resource = if is_user_specified_resource {
let mut tx = user_db.clone().begin(&authed).await?;
let res = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
res
} else {
sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&db)
.await?
}
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
.ok_or_else(|| Error::BadRequest(format!("Empty resource value for {}", resource_path)))?;
@@ -55,6 +55,9 @@ pub const KEEP_JOB_DIR_SETTING: &str = "keep_job_dir";
pub const REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING: &str = "require_preexisting_user_for_oauth";
pub const JOB_ISOLATION_SETTING: &str = "job_isolation";
pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb";
pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing";
pub const NSJAIL_TMP_BACKING_DISK: &str = "disk";
pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs";
pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config";
pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret";
@@ -83,6 +86,15 @@ pub const WORKSPACE_REGISTRIES_SETTING: &str = "workspace_registries";
pub const RESTART_COORDINATION_SETTING: &str = "_restart_coordination";
pub const ALERT_CONFIG_SETTING: &str = "alert_job_queue_waiting";
// Workspace fairness: cloud-only mechanism that caps any single workspace at
// `workspace_fairness_max_percent`% of the shared worker pool once it has been
// occupying it for more than `workspace_fairness_duration_secs` seconds. See
// `windmill-queue/src/workspace_fairness.rs`.
pub const WORKSPACE_FAIRNESS_ENABLED_SETTING: &str = "workspace_fairness_enabled";
pub const WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING: &str = "workspace_fairness_max_percent";
pub const WORKSPACE_FAIRNESS_DURATION_SECS_SETTING: &str = "workspace_fairness_duration_secs";
pub const WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING: &str = "workspace_fairness_min_total_jobs";
use std::sync::atomic::AtomicBool;
lazy_static::lazy_static! {
@@ -223,6 +223,8 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub nsjail_tmpfs_size_mb: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nsjail_tmp_backing: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bun_install_min_release_age: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uv_exclude_newer: Option<i64>,
+73 -3
View File
@@ -17,7 +17,7 @@ use std::{
panic::Location,
path::{Component, Path, PathBuf},
str::FromStr,
sync::atomic::AtomicBool,
sync::atomic::{AtomicBool, AtomicI64, AtomicU32},
time::Duration,
};
#[cfg(windows)]
@@ -237,14 +237,30 @@ lazy_static::lazy_static! {
});
pub static ref WORKER_PULL_QUERIES: arc_swap::ArcSwap<Vec<String>> = arc_swap::ArcSwap::from_pointee(vec![]);
pub static ref WORKER_PULL_QUERIES_FAIRNESS: arc_swap::ArcSwap<Vec<String>> = arc_swap::ArcSwap::from_pointee(vec![]);
pub static ref WORKER_SUSPENDED_PULL_QUERY: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
// Workspace fairness (cloud-only). When enabled, a workspace whose footprint over the rolling
// `WORKSPACE_FAIRNESS_DURATION_SECS` window represents >= `WORKSPACE_FAIRNESS_MAX_PERCENT`% of
// all worker activity gets excluded from the pull query, freeing slots for other workspaces.
// The list of overloaded workspaces is computed cluster-wide via a single coordinated UPDATE
// on `background_task_state` so only one process per refresh interval runs the aggregation.
pub static ref WORKSPACE_FAIRNESS_ENABLED: AtomicBool = AtomicBool::new(false);
pub static ref WORKSPACE_FAIRNESS_MAX_PERCENT: AtomicU32 = AtomicU32::new(50);
pub static ref WORKSPACE_FAIRNESS_DURATION_SECS: AtomicU32 = AtomicU32::new(10);
pub static ref WORKSPACE_FAIRNESS_MIN_TOTAL: AtomicU32 = AtomicU32::new(4);
pub static ref WORKSPACE_FAIRNESS_OVERLOADED: arc_swap::ArcSwap<Vec<String>> = arc_swap::ArcSwap::from_pointee(vec![]);
pub static ref WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS: AtomicI64 = AtomicI64::new(0);
pub static ref SMTP_CONFIG: arc_swap::ArcSwap<Option<Smtp>> = arc_swap::ArcSwap::from_pointee(None);
pub static ref INDEXER_CONFIG: arc_swap::ArcSwap<TantivyIndexerSettings> = arc_swap::ArcSwap::from_pointee(TantivyIndexerSettings::default());
pub static ref CLOUD_HOSTED: bool = std::env::var("CLOUD_HOSTED").is_ok();
/// Host used to gate cloud-only features that must only ever run on the
/// production `app.windmill.dev` cluster, not on staging or self-hosted.
pub static ref CLOUD_PRODUCTION_HOST: &'static str = "app.windmill.dev";
pub static ref CUSTOM_TAGS: Vec<String> = std::env::var("CUSTOM_TAGS")
.ok()
@@ -289,6 +305,34 @@ pub fn is_native_mode_from_env() -> bool {
*NATIVE_MODE || *WORKER_GROUP == "native"
}
/// True iff this process is configured to act as the production cloud cluster:
/// `CLOUD_HOSTED=true` AND `BASE_URL`'s host matches `CLOUD_PRODUCTION_HOST`.
/// Centralized so the API setter, the runtime pull path, and any future cloud-
/// only feature share one canonical check (rather than re-implementing the
/// scheme/host parser at each call site).
pub fn is_cloud_production_host() -> bool {
if !*CLOUD_HOSTED {
return false;
}
let base = crate::BASE_URL.load();
let s = base.as_str();
if s.is_empty() {
return false;
}
let after_scheme = s
.strip_prefix("https://")
.or_else(|| s.strip_prefix("http://"))
.unwrap_or(s);
let host = after_scheme
.split('/')
.next()
.unwrap_or("")
.split(':')
.next()
.unwrap_or("");
host == *CLOUD_PRODUCTION_HOST
}
/// Cached resolved native mode flag, updated when worker config is reloaded.
/// Use this for hot-path checks (e.g. per-job dispatch) to avoid read-locking WORKER_CONFIG.
pub static NATIVE_MODE_RESOLVED: AtomicBool = AtomicBool::new(false);
@@ -520,17 +564,43 @@ pub fn make_pull_query(tags: &[String]) -> String {
query
}
// Variant of `make_pull_query` that additionally excludes jobs whose workspace_id is in the
// overloaded-list bind parameter ($2::text[]). Built as a separate string (rather than reusing
// `make_pull_query` with an always-bound array) so the planner can keep using the same indexes
// when fairness is off — the default `make_pull_query` text stays bit-identical to today's.
//
// `pub(crate)` because only `store_pull_query` consumes it; the resulting query string is what
// crosses crate boundaries via `WORKER_PULL_QUERIES_FAIRNESS`.
pub(crate) fn make_pull_query_fairness(tags: &[String]) -> String {
let query = format_pull_query(format!(
"SELECT id
FROM v2_job_queue
WHERE running = false AND tag IN ({}) AND scheduled_for <= now()
AND workspace_id <> ALL($2::text[])
ORDER BY priority DESC NULLS LAST, scheduled_for
FOR UPDATE SKIP LOCKED
LIMIT 1",
tags.iter().map(|x| format!("'{x}'")).join(", ")
));
query
}
pub async fn store_pull_query(wc: &WorkerConfig) {
let mut queries = vec![];
let mut fairness_queries = vec![];
let fairness_enabled = WORKSPACE_FAIRNESS_ENABLED.load(std::sync::atomic::Ordering::Relaxed);
for tags in wc.priority_tags_sorted.iter() {
if tags.tags.len() == 0 {
tracing::error!("Empty tags in priority tags, skipping");
continue;
}
let query = make_pull_query(&tags.tags);
queries.push(query);
queries.push(make_pull_query(&tags.tags));
if fairness_enabled {
fairness_queries.push(make_pull_query_fairness(&tags.tags));
}
}
WORKER_PULL_QUERIES.store(std::sync::Arc::new(queries));
WORKER_PULL_QUERIES_FAIRNESS.store(std::sync::Arc::new(fairness_queries));
}
lazy_static::lazy_static! {
+1 -1
View File
@@ -157,7 +157,7 @@ pub enum ObjectType {
WorkspaceDependencies,
}
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28231/sync-script-to-git-repo-windmill";
pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28236/sync-script-to-git-repo-windmill";
/// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a
/// fork of another workspace.
+58 -18
View File
@@ -78,7 +78,8 @@ use windmill_common::{
utils::{not_found_if_none, report_critical_error, StripPath, WarnAfterExt},
worker::{
to_raw_value, CLOUD_HOSTED, DISABLE_FLOW_SCRIPT, NO_LOGS, PREVIEW_TAGS_OVERRIDE,
WORKER_PULL_QUERIES, WORKER_SUSPENDED_PULL_QUERY,
WORKER_PULL_QUERIES, WORKER_PULL_QUERIES_FAIRNESS, WORKER_SUSPENDED_PULL_QUERY,
WORKSPACE_FAIRNESS_OVERLOADED,
},
DB, METRICS_ENABLED,
};
@@ -3632,28 +3633,67 @@ async fn pull_single_job_and_mark_as_running_no_concurrency_limit<'c>(
return Ok((None, false));
}
for query in queries.iter() {
// tracing::info!("Pulling job with query: {}", query);
// let instant = std::time::Instant::now();
// Workspace fairness (cloud-only): if the fairness refresh has flagged any
// overloaded workspaces, try the fairness-aware pull queries first (which
// exclude those workspace_ids). When fairness is off or no workspace is
// currently capped, this branch is skipped and the hot path is identical
// to today's. Lazy refresh is fired from the same place; it runs at most
// once per process per refresh interval and never blocks this pull.
crate::workspace_fairness::maybe_refresh_overloaded(db);
let overloaded = WORKSPACE_FAIRNESS_OVERLOADED.load_full();
let fairness_active = !overloaded.is_empty();
#[cfg(feature = "benchmark")]
add_time!(bench, "pre pull");
if fairness_active {
let fairness_queries = WORKER_PULL_QUERIES_FAIRNESS.load();
let overloaded_slice: &[String] = overloaded.as_slice();
for query in fairness_queries.iter() {
#[cfg(feature = "benchmark")]
add_time!(bench, "pre pull (fairness)");
let r = sqlx::query_as::<_, PulledJob>(query)
.bind(worker_name)
.fetch_optional(db)
.await?;
let r = sqlx::query_as::<_, PulledJob>(query)
.bind(worker_name)
.bind(overloaded_slice)
.fetch_optional(db)
.await?;
#[cfg(feature = "benchmark")]
add_time!(bench, "post pull");
#[cfg(feature = "benchmark")]
add_time!(bench, "post pull (fairness)");
if let Some(pulled_job) = r {
// tracing::info!("pulled job: {:?}", instant.elapsed().as_micros());
highest_priority_job = Some(pulled_job);
break;
if let Some(pulled_job) = r {
highest_priority_job = Some(pulled_job);
break;
}
}
}
if highest_priority_job.is_none() {
// Standard pull path. Also acts as the fallback when fairness filtered
// out every candidate: prefer running a capped workspace's job over
// leaving a worker idle. The cap re-engages on the next refresh as
// soon as the workspace's footprint exceeds the threshold again.
for query in queries.iter() {
// tracing::info!("Pulling job with query: {}", query);
// let instant = std::time::Instant::now();
#[cfg(feature = "benchmark")]
add_time!(bench, "pre pull");
let r = sqlx::query_as::<_, PulledJob>(query)
.bind(worker_name)
.fetch_optional(db)
.await?;
#[cfg(feature = "benchmark")]
add_time!(bench, "post pull");
if let Some(pulled_job) = r {
// tracing::info!("pulled job: {:?}", instant.elapsed().as_micros());
highest_priority_job = Some(pulled_job);
break;
}
// else continue pulling for lower priority tags
}
// else continue pulling for lower priority tags
}
// #[cfg(feature = "benchmark")]
+1
View File
@@ -14,6 +14,7 @@ pub mod schedule;
pub use jobs::*;
pub mod flow_status;
pub mod tags;
pub mod workspace_fairness;
#[cfg(feature = "cloud")]
pub mod cloud_usage;
@@ -0,0 +1,262 @@
//! Per-workspace fairness for the shared worker pool (cloud-only).
//!
//! On `app.windmill.dev` the cluster runs a single default worker group, so a
//! single workspace flooding the queue with jobs can degrade quality of service
//! for everyone else. This module computes the set of "overloaded" workspaces
//! that should be temporarily excluded from the pull query.
//!
//! ## Detection signal
//!
//! A workspace is overloaded when, over the last `WORKSPACE_FAIRNESS_DURATION_SECS`
//! seconds, it has accounted for at least `WORKSPACE_FAIRNESS_MAX_PERCENT`% of
//! cluster activity. "Cluster activity" counts both currently-running jobs and
//! jobs completed within the window — this captures workspaces hogging slots
//! with long-running jobs **and** workspaces spamming many small short-lived
//! jobs (where no individual job's `started_at` is old, but the aggregate
//! throughput share dominates).
//!
//! ## Coordinated refresh
//!
//! The aggregation runs **at most once every `refresh_interval` seconds
//! cluster-wide**, regardless of fleet size. A single `UPDATE` statement on
//! `background_task_state` does double duty:
//! 1. The `WHERE updated_at < now() - $interval` predicate, combined with
//! row-level locking, ensures only the first process to commit per cycle
//! actually recomputes the value. Other processes that race in see the
//! `WHERE` re-evaluated against the now-fresh row and update zero rows.
//! 2. The same round trip falls through to a plain `SELECT` (via
//! `UNION ALL ... LIMIT 1`) so every caller reads the current value.
//!
//! Each process mirrors the result into [`WORKSPACE_FAIRNESS_OVERLOADED`]
//! which the pull path reads at near-zero cost.
//!
//! ## Cloud gating
//!
//! The feature is hard-gated to `CLOUD_HOSTED=true` **and** `BASE_URL` matching
//! `app.windmill.dev` (belt-and-suspenders against an on-prem instance importing
//! cloud's `global_settings` row). When either check fails, [`maybe_refresh_overloaded`]
//! and the pull-side dispatch both treat the feature as disabled.
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::time::Duration;
use sqlx::{Pool, Postgres};
use windmill_common::error::Result;
use windmill_common::worker::{
is_cloud_production_host, WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED,
WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS, WORKSPACE_FAIRNESS_MAX_PERCENT,
WORKSPACE_FAIRNESS_MIN_TOTAL, WORKSPACE_FAIRNESS_OVERLOADED,
};
pub const TASK_STATE_NAME: &str = "workspace_fairness";
/// Refresh interval when no workspace is currently capped. Slower cadence to
/// keep DB load minimal during normal operation.
const IDLE_REFRESH_SECS: u32 = 5;
/// Refresh interval when at least one workspace is capped. Faster cadence so
/// the cap lifts promptly once load drops below threshold.
const ACTIVE_REFRESH_SECS: u32 = 2;
/// Hard cap on the size of the overloaded list bound into the pull query.
const MAX_OVERLOADED_RETURNED: i64 = 64;
/// Whether the feature can be active in this process. Combined gate:
/// - `WORKSPACE_FAIRNESS_ENABLED` setting toggled on, AND
/// - `CLOUD_HOSTED=true`, AND
/// - `BASE_URL` host is the production cloud host.
fn fairness_active() -> bool {
WORKSPACE_FAIRNESS_ENABLED.load(Ordering::Relaxed) && is_cloud_production_host()
}
#[derive(serde::Deserialize)]
struct FairnessState {
#[serde(default)]
overloaded: Vec<String>,
}
/// Lazy, non-blocking refresh entry point called from the pull path.
///
/// Cost on the hot path: one atomic load, optionally one compare-exchange. If
/// this process wins the per-interval CAS, the actual refresh is spawned as a
/// `tokio` task — the caller does not wait on it.
pub fn maybe_refresh_overloaded(db: &Pool<Postgres>) {
if !fairness_active() {
// Drain the cached list so the dispatch in jobs.rs falls back to the
// unmodified pull queries within at most one pull cycle.
if !WORKSPACE_FAIRNESS_OVERLOADED.load().is_empty() {
WORKSPACE_FAIRNESS_OVERLOADED.store(Arc::new(vec![]));
}
return;
}
let interval_us = current_refresh_interval_micros();
let now_us = chrono::Utc::now().timestamp_micros();
let last = WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS.load(Ordering::Relaxed);
if now_us.saturating_sub(last) < interval_us {
return;
}
// Single in-flight refresh per process per cycle. If someone beat us, give up.
if WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS
.compare_exchange(last, now_us, Ordering::Relaxed, Ordering::Relaxed)
.is_err()
{
return;
}
let db = db.clone();
tokio::spawn(async move {
match tokio::time::timeout(Duration::from_secs(5), refresh_overloaded(&db)).await {
Ok(Ok(())) => {}
// On failure, leave `LAST_REFRESH_MICROS` set to `now_us` (already done by the CAS
// above). The next attempt therefore has to wait a full `current_refresh_interval`
// — exactly the same cooldown as a successful refresh. Previously we wrote `0`
// here, which removed the rate limit entirely and let every subsequent pull spawn
// a fresh refresh task while the DB was under pressure (precisely the moment we
// most need to back off).
Ok(Err(e)) => {
tracing::warn!("workspace fairness refresh failed: {e:#}");
}
Err(_) => {
tracing::warn!("workspace fairness refresh timed out after 5s");
}
}
});
}
fn current_refresh_interval_micros() -> i64 {
let secs = if WORKSPACE_FAIRNESS_OVERLOADED.load().is_empty() {
IDLE_REFRESH_SECS
} else {
ACTIVE_REFRESH_SECS
};
(secs as i64) * 1_000_000
}
/// Run the coordinated refresh.
///
/// The previous implementation used a single `INSERT ... ON CONFLICT DO UPDATE
/// WHERE updated_at < ...` statement, which had a fatal flaw: Postgres evaluates
/// the `VALUES` clause (including the expensive `v2_job_queue v2_job_completed`
/// aggregation inlined there) **for every contender** to build the proposed row,
/// before the conflict-row check decides whether to actually apply the update.
/// So every worker process re-ran the heavy aggregation each cycle, and the
/// claimed "one heavy aggregation per cycle cluster-wide" property did not hold.
///
/// This version splits the refresh into three small statements:
/// 1. Claim: a cheap upsert with only constant `VALUES`. Returns `Some(...)`
/// iff this process won the right to refresh (row was either missing or
/// had a stale `updated_at`).
/// 2. Winner-only: an `UPDATE ... SET value = ...` whose `SET` expression
/// contains the heavy aggregation. Postgres evaluates `SET` per row
/// matching `WHERE`; we only issue it when `won`, so the aggregation runs
/// exactly once per refresh cycle cluster-wide.
/// 3. Read: every caller reads the current value (winner sees its own fresh
/// write; losers see whatever the winner-from-this-or-the-prior-cycle
/// wrote).
async fn refresh_overloaded(db: &Pool<Postgres>) -> Result<()> {
let duration_secs = WORKSPACE_FAIRNESS_DURATION_SECS
.load(Ordering::Relaxed)
.clamp(1, i32::MAX as u32) as i32;
let max_percent = WORKSPACE_FAIRNESS_MAX_PERCENT
.load(Ordering::Relaxed)
.clamp(1, 100) as i64;
let min_total = WORKSPACE_FAIRNESS_MIN_TOTAL.load(Ordering::Relaxed) as i64;
// Use the tighter of the two intervals as the cluster-wide guard. The
// slower idle cadence is enforced by the per-process CAS gate in
// `maybe_refresh_overloaded`; the DB-side guard only needs to prevent
// two processes from racing into a refresh at the same time.
let refresh_secs = ACTIVE_REFRESH_SECS as i32;
// Step 1: claim. The VALUES clause is all constants — Postgres has no
// expensive work to do for either the insert-side or the conflict-side.
// Returns Some(true) for the unique winner per cycle, None for losers.
let won = sqlx::query_scalar::<_, bool>(
r#"
INSERT INTO background_task_state (name, value, running, owner, updated_at)
VALUES ($1, '{"overloaded":[]}'::jsonb, false, NULL, NOW())
ON CONFLICT (name) DO UPDATE
SET updated_at = NOW()
WHERE background_task_state.updated_at
< NOW() - make_interval(secs => $2::int)
RETURNING true
"#,
)
.bind(TASK_STATE_NAME)
.bind(refresh_secs)
.fetch_optional(db)
.await?
.is_some();
// Step 2: winner-only aggregation + value write. `SET` is evaluated per
// updated row, so issuing this statement only when `won` guarantees the
// expensive aggregation never runs for a loser.
if won {
sqlx::query(
r#"
UPDATE background_task_state
SET value = jsonb_build_object('overloaded', (
WITH active AS (
SELECT workspace_id FROM v2_job_queue WHERE running = true
UNION ALL
SELECT workspace_id FROM v2_job_completed
WHERE completed_at > NOW() - make_interval(secs => $2::int)
),
per_ws AS (
SELECT workspace_id, COUNT(*)::int8 AS c FROM active GROUP BY 1
),
total AS (SELECT SUM(c)::int8 AS t FROM per_ws)
SELECT COALESCE(jsonb_agg(workspace_id ORDER BY c DESC), '[]'::jsonb)
FROM (
SELECT workspace_id, c FROM per_ws, total
WHERE total.t >= $3
AND per_ws.c * 100 >= $4 * total.t
ORDER BY c DESC
LIMIT $5
) capped
))
WHERE name = $1
"#,
)
.bind(TASK_STATE_NAME)
.bind(duration_secs)
.bind(min_total)
.bind(max_percent)
.bind(MAX_OVERLOADED_RETURNED)
.execute(db)
.await?;
}
// Step 3: read current state (winner reads its own fresh write).
let row: Option<serde_json::Value> =
sqlx::query_scalar("SELECT value FROM background_task_state WHERE name = $1")
.bind(TASK_STATE_NAME)
.fetch_optional(db)
.await?;
let new_list: Vec<String> = match row {
Some(value) => match serde_json::from_value::<FairnessState>(value) {
Ok(s) => s.overloaded,
Err(e) => {
tracing::warn!("workspace fairness state parse error: {e:#}");
vec![]
}
},
None => vec![],
};
let prev = WORKSPACE_FAIRNESS_OVERLOADED.load();
if **prev != new_list {
tracing::info!(
"workspace fairness overloaded set changed: {} -> {} ({:?})",
prev.len(),
new_list.len(),
&new_list,
);
WORKSPACE_FAIRNESS_OVERLOADED.store(Arc::new(new_list));
}
Ok(())
}
@@ -86,12 +86,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -86,12 +86,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{TARGET}"
@@ -49,12 +49,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "/etc"
@@ -66,12 +66,7 @@ mount {
is_bind: false
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/main.yml"
@@ -68,12 +68,7 @@ mount {
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/main.sh"
@@ -60,12 +60,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/package.json"
@@ -57,12 +57,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -50,12 +50,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{NU_PATH}"
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/main.php"
@@ -64,12 +64,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/main.ps1"
@@ -54,12 +54,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
src: "{JOB_DIR}/{MAIN}.py"
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -51,12 +51,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -50,12 +50,7 @@ mount {
rw: true
}
mount {
dst: "/tmp"
fstype: "tmpfs"
rw: true
options: "size={NSJAIL_TMPFS_SIZE}"
}
{TMP_MOUNT_BLOCK}
mount {
@@ -30,7 +30,7 @@ use crate::{
bash_executor::BIN_BASH,
common::{
build_command_with_isolation, check_executor_binary_exists, get_reserved_variables,
read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes,
read_and_check_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
start_child_process, transform_json, OccupancyMetrics,
},
handle_child::handle_child,
@@ -1457,8 +1457,8 @@ mount {{
additional_python_paths_folders.as_str(),
)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -41,7 +41,7 @@ use crate::handle_child::run_future_with_polling_update_job_poller;
use crate::{
common::{
build_args_map, build_command_with_isolation, get_reserved_variables, read_file,
read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -216,8 +216,8 @@ exit $exit_status
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -16,7 +16,7 @@ use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
parse_npm_config, read_file, read_file_content, read_result, resolve_nsjail_timeout,
resolve_nsjail_tmpfs_size_bytes, start_child_process, write_file_binary, MaybeLock,
resolve_nsjail_tmp_mount_block, start_child_process, write_file_binary, MaybeLock,
OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -2186,8 +2186,8 @@ try {{
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+276 -1
View File
@@ -15,6 +15,7 @@ use tokio::process::Command;
use tokio::{fs::File, io::AsyncReadExt};
use windmill_common::flows::Step;
use windmill_common::global_settings::NSJAIL_TMP_BACKING_DISK;
use windmill_common::variables::{build_crypt_with_key_suffix, decrypt};
use windmill_common::worker::{
to_raw_value, update_ping_for_failed_init_script_query, write_file, Connection, Ping, PingType,
@@ -48,7 +49,8 @@ use tokio::{io::AsyncWriteExt, time::Instant};
use crate::agent_workers::UPDATE_PING_URL;
use crate::{
JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB, PATH_ENV,
JOB_DEFAULT_TIMEOUT, MAX_RESULT_SIZE, MAX_TIMEOUT_DURATION, NSJAIL_TMPFS_SIZE_MB,
NSJAIL_TMP_BACKING, PATH_ENV,
};
use windmill_common::client::AuthedClient;
@@ -1023,6 +1025,279 @@ pub async fn resolve_nsjail_tmpfs_size_bytes() -> String {
}
}
/// Sub-directory inside each job dir used as the disk-backed `/tmp` when
/// `nsjail_tmp_disk_backed` is enabled. Kept under `{JOB_DIR}` so existing
/// job-dir cleanup removes it for free.
const NSJAIL_TMP_BIND_SUBDIR: &str = "jail_tmp";
fn tmpfs_mount_block(size_bytes: &str) -> String {
format!(
"mount {{\n dst: \"/tmp\"\n fstype: \"tmpfs\"\n rw: true\n options: \"size={size_bytes}\"\n}}"
)
}
fn bind_mount_block(jail_tmp: &str) -> String {
format!(
"mount {{\n src: \"{jail_tmp}\"\n dst: \"/tmp\"\n is_bind: true\n rw: true\n}}"
)
}
/// Build the nsjail `mount { ... }` block that backs `/tmp` inside the
/// sandbox.
///
/// **Caller contract**: `job_dir` must be a trusted, worker-allocated job
/// directory (typically `{worker_dir}/{job_id}`). In disk-backed mode this
/// function creates `{job_dir}/jail_tmp` and bind-mounts it as `/tmp` with
/// `rw: true`. Callers must not pass user-controlled paths.
///
/// Some executors (e.g. the bun codebase path) extract user-supplied archives
/// into `job_dir` before this resolver runs, so the resolver actively refuses
/// any pre-existing entry at `{job_dir}/jail_tmp` (including symlinks) to
/// avoid bind-mounting an attacker-controlled host directory as `/tmp`.
///
/// When the `nsjail_tmp_backing` instance setting is `"disk"`, returns a
/// disk-backed bind mount of `{job_dir}/jail_tmp` after creating the
/// directory. If creation or the pre-existence check fails, logs an error and
/// falls back to the historical tmpfs block so the job can still start. For
/// any other value (including unset, `"tmpfs"`, or unrecognized), returns the
/// historical RAM-backed tmpfs mount sized via `nsjail_tmpfs_size_mb`.
pub(crate) async fn resolve_nsjail_tmp_mount_block(job_dir: &str) -> String {
let disk_backed = NSJAIL_TMP_BACKING
.read()
.await
.as_deref()
.map(|v| v.eq_ignore_ascii_case(NSJAIL_TMP_BACKING_DISK))
.unwrap_or(false);
let size_bytes = resolve_nsjail_tmpfs_size_bytes().await;
if !disk_backed {
return tmpfs_mount_block(&size_bytes);
}
let jail_tmp = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
// SECURITY: never bind-mount a symlinked (or otherwise non-directory)
// entry at jail_tmp. `symlink_metadata` returns the link's own metadata
// without following it, so `is_dir()` is true only for a real directory.
// User-controlled archives extracted into job_dir could otherwise plant
// `jail_tmp` as a symlink to an arbitrary host directory, which nsjail
// would then expose as a writable /tmp.
//
// A pre-existing real directory at this path is legitimate: several
// executors (python_executor, ruby_executor, rust_executor) invoke nsjail
// more than once per job_dir (e.g. dep install, then run), and the first
// invocation will have created it via the `create_dir` below.
match tokio::fs::symlink_metadata(&jail_tmp).await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Err(e) = tokio::fs::create_dir(&jail_tmp).await {
tracing::error!(
"Failed to create nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \
falling back to tmpfs for this job."
);
return tmpfs_mount_block(&size_bytes);
}
}
Ok(meta) if meta.is_dir() => {
// Real directory left over from an earlier nsjail invocation in
// this same job_dir — safe to reuse.
}
Ok(_) => {
tracing::error!(
"Refusing to bind-mount nsjail disk-backed /tmp: {jail_tmp} \
exists but is not a regular directory (possibly a symlink \
planted by a user-controlled archive). Falling back to \
RAM-backed tmpfs for this job."
);
return tmpfs_mount_block(&size_bytes);
}
Err(e) => {
tracing::error!(
"Failed to stat nsjail disk-backed /tmp at {jail_tmp}: {e:?}; \
falling back to tmpfs for this job."
);
return tmpfs_mount_block(&size_bytes);
}
}
bind_mount_block(&jail_tmp)
}
#[cfg(test)]
mod nsjail_tmp_mount_tests {
use super::*;
#[test]
fn tmpfs_block_renders_size() {
let block = tmpfs_mount_block("800000000");
assert!(block.contains("dst: \"/tmp\""));
assert!(block.contains("fstype: \"tmpfs\""));
assert!(block.contains("options: \"size=800000000\""));
assert!(!block.contains("is_bind"));
}
#[test]
fn bind_block_renders_source_path() {
let block = bind_mount_block("/var/lib/windmill/jobs/abc/jail_tmp");
assert!(block.contains("src: \"/var/lib/windmill/jobs/abc/jail_tmp\""));
assert!(block.contains("dst: \"/tmp\""));
assert!(block.contains("is_bind: true"));
assert!(block.contains("rw: true"));
assert!(!block.contains("fstype"));
}
/// Serializes tests that mutate the process-global `NSJAIL_TMP_BACKING`
/// so they don't race when cargo runs them in parallel.
static SETTING_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
async fn with_tmp_backing<F, Fut, T>(value: Option<String>, f: F) -> T
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = T>,
{
let _serial = SETTING_GUARD.lock().await;
let prev = NSJAIL_TMP_BACKING.read().await.clone();
*NSJAIL_TMP_BACKING.write().await = value;
let res = f().await;
*NSJAIL_TMP_BACKING.write().await = prev;
res
}
#[tokio::test]
async fn tmpfs_mode_returns_tmpfs_block_for_any_job_dir() {
let block = with_tmp_backing(Some("tmpfs".to_string()), || async {
resolve_nsjail_tmp_mount_block("/anything").await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(block.contains("options: \"size="));
assert!(!block.contains("is_bind"));
}
#[tokio::test]
async fn unset_defaults_to_tmpfs() {
let block = with_tmp_backing(None, || async {
resolve_nsjail_tmp_mount_block("/anything").await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(!block.contains("is_bind"));
}
/// Disk-backed branch: the resolver must create `{job_dir}/jail_tmp` and
/// emit a bind block pointing at it.
#[tokio::test]
async fn disk_backed_creates_jail_tmp_and_returns_bind_block() {
let tmp = tempfile::tempdir().expect("tempdir");
let job_dir = tmp.path().to_str().expect("utf8 path").to_string();
let block = with_tmp_backing(Some("disk".to_string()), || async {
resolve_nsjail_tmp_mount_block(&job_dir).await
})
.await;
let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
assert!(
std::path::Path::new(&expected_dir).is_dir(),
"jail_tmp dir should have been created at {expected_dir}"
);
assert!(block.contains("is_bind: true"));
assert!(block.contains(&format!("src: \"{expected_dir}\"")));
}
/// Disk-backed branch fallback: if `create_dir_all` fails, we must emit
/// the tmpfs block instead of returning an invalid bind config.
#[tokio::test]
async fn disk_backed_falls_back_to_tmpfs_on_mkdir_error() {
// /proc is a kernel filesystem that disallows directory creation,
// so create_dir_all on a subpath returns EPERM/EACCES.
let job_dir = "/proc/win1967_should_not_exist";
let block = with_tmp_backing(Some("disk".to_string()), || async {
resolve_nsjail_tmp_mount_block(job_dir).await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(!block.contains("is_bind"));
}
/// Unknown values fall through to the tmpfs branch instead of crashing.
#[tokio::test]
async fn unknown_value_defaults_to_tmpfs() {
let block = with_tmp_backing(Some("bogus".to_string()), || async {
resolve_nsjail_tmp_mount_block("/anything").await
})
.await;
assert!(block.contains("fstype: \"tmpfs\""));
assert!(!block.contains("is_bind"));
}
/// Security regression: if a pre-existing symlink sits at the jail_tmp
/// path (e.g. planted by a user-controlled tarball extracted into
/// `job_dir` before the resolver runs), the resolver must refuse the
/// bind mount and fall back to tmpfs — never bind-mount the symlink
/// target into the sandbox as /tmp.
#[cfg(unix)]
#[tokio::test]
async fn disk_backed_refuses_preexisting_symlink_at_jail_tmp() {
let tmp = tempfile::tempdir().expect("tempdir");
let job_dir = tmp.path().to_str().expect("utf8 path").to_string();
// Plant a symlink at {job_dir}/jail_tmp pointing at an arbitrary host
// path. Target doesn't have to exist — what matters is that the
// resolver doesn't follow it.
let jail_tmp_path = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
std::os::unix::fs::symlink("/etc", &jail_tmp_path).expect("plant symlink");
assert!(std::path::Path::new(&jail_tmp_path).is_symlink());
let block = with_tmp_backing(Some("disk".to_string()), || async {
resolve_nsjail_tmp_mount_block(&job_dir).await
})
.await;
// Fell back to tmpfs — no bind-mount of the attacker-controlled path.
assert!(
block.contains("fstype: \"tmpfs\""),
"expected tmpfs fallback, got: {block}"
);
assert!(
!block.contains("is_bind"),
"must not emit bind block, got: {block}"
);
assert!(
!block.contains("/etc"),
"must not leak the symlink target into the proto, got: {block}"
);
}
/// Sequential resolver calls in the same `job_dir` (e.g. Python uv install
/// → Python run, Ruby install → run, Rust build → run) must keep using
/// the bind mount instead of silently falling back to tmpfs on the
/// second call. The first call creates `jail_tmp`; subsequent calls see
/// it as a pre-existing real directory and must accept it.
#[tokio::test]
async fn disk_backed_reuses_jail_tmp_across_sequential_calls() {
let tmp = tempfile::tempdir().expect("tempdir");
let job_dir = tmp.path().to_str().expect("utf8 path").to_string();
let expected_dir = format!("{job_dir}/{NSJAIL_TMP_BIND_SUBDIR}");
let (first, second) = with_tmp_backing(Some("disk".to_string()), || async {
let first = resolve_nsjail_tmp_mount_block(&job_dir).await;
// Simulate an executor that completes its first nsjail invocation
// (e.g. uv install) leaving jail_tmp on disk, then invokes nsjail
// again for the main run.
assert!(std::path::Path::new(&expected_dir).is_dir());
let second = resolve_nsjail_tmp_mount_block(&job_dir).await;
(first, second)
})
.await;
assert!(first.contains("is_bind: true"), "first call: {first}");
assert!(
second.contains("is_bind: true"),
"second call regressed to tmpfs: {second}"
);
assert!(second.contains(&format!("src: \"{expected_dir}\"")));
}
}
async fn hash_args(
#[allow(unused)] db: &DB,
#[allow(unused)] client: &AuthedClient,
@@ -27,7 +27,7 @@ use windmill_queue::CanceledBy;
use crate::{
common::{
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
start_child_process, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -604,8 +604,8 @@ pub async fn handle_csharp_job(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -22,7 +22,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, capitalize, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, DEV_CONF_NSJAIL,
},
handle_child::handle_child,
@@ -352,8 +352,8 @@ func Run(req Req) (interface{{}}, error){{
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics,
},
handle_child, is_sandboxing_enabled, read_ee_registry_bool_with_workspace_override,
@@ -671,8 +671,8 @@ async fn run<'a>(
// .replace("{CACHED_TARGET}", &shared_mount)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -14,7 +14,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang, handle_child, is_sandboxing_enabled, DISABLE_NUSER, NSJAIL_PATH,
@@ -259,8 +259,8 @@ async fn run<'a>(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy};
use crate::{
common::{
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
start_child_process, MaybeLock, OccupancyMetrics,
},
handle_child::handle_child,
@@ -426,8 +426,8 @@ try {{
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{SHARED_MOUNT}", shared_mount)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+3 -3
View File
@@ -26,7 +26,7 @@ lazy_static::lazy_static! {
use crate::{
common::{
build_args_map, build_command_with_isolation, get_reserved_variables, read_file,
read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
MaybeLock, OccupancyMetrics,
},
handle_child::handle_child,
@@ -683,8 +683,8 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"",
.replace("{SHARED_MOUNT}", shared_mount)
.replace("{CACHE_DIR}", &*POWERSHELL_CACHE_DIR)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
@@ -146,7 +146,7 @@ use windmill_object_store::OBJECT_STORE_SETTINGS;
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables, read_file,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, StreamNotifier, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -1028,8 +1028,8 @@ mount {{
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
@@ -2056,8 +2056,8 @@ async fn spawn_uv_install(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.as_str(),
)?;
+3 -3
View File
@@ -20,7 +20,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_tmpfs_size_bytes, start_child_process, OccupancyMetrics,
read_result, resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics,
DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -582,8 +582,8 @@ async fn run<'a>(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()),
)?;
+7 -4
View File
@@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, get_reserved_variables,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes, start_child_process,
read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process,
OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -619,7 +619,7 @@ async fn install<'a>(
envs.clone(),
get_reserved_variables(job, &client.token, conn, parent_runnable_path.clone()).await?,
);
let nsjail_tmpfs_size = resolve_nsjail_tmpfs_size_bytes().await;
let nsjail_tmp_mount_block = resolve_nsjail_tmp_mount_block(&job_dir).await;
par_install_language_dependencies_seq(
InstallDeps::Flat(deps.clone()),
"ruby",
@@ -639,7 +639,7 @@ async fn install<'a>(
.replace("{TARGET}", &dependency.path)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("{NSJAIL_TMPFS_SIZE}", &nsjail_tmpfs_size)
.replace("{TMP_MOUNT_BLOCK}", &nsjail_tmp_mount_block)
.replace("#{DEV}", DEV_CONF_NSJAIL), // .replace("{BUILD}", &build_dir),
)?;
let mut cmd = Command::new(NSJAIL_PATH.as_str());
@@ -812,7 +812,10 @@ mount {{
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string())
.replace("{NSJAIL_TMPFS_SIZE}", &resolve_nsjail_tmpfs_size_bytes().await)
.replace(
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
let mut cmd = Command::new(NSJAIL_PATH.as_str());
+5 -5
View File
@@ -23,7 +23,7 @@ use windmill_queue::{append_logs, CanceledBy};
use crate::{
common::{
build_command_with_isolation, check_executor_binary_exists, create_args_and_out_file,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmpfs_size_bytes,
get_reserved_variables, read_result, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block,
start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL,
},
get_proxy_envs_for_lang,
@@ -481,8 +481,8 @@ pub async fn build_rust_crate(
.replace("{TRACING_PROXY_CA_CERT_PATH}", &*TRACING_PROXY_CA_CERT_PATH)
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{BUILD}", &build_dir),
)?;
@@ -706,8 +706,8 @@ pub async fn handle_rust_job(
.replace("#{DEV}", DEV_CONF_NSJAIL)
.replace("{SHARED_MOUNT}", shared_mount)
.replace(
"{NSJAIL_TMPFS_SIZE}",
&resolve_nsjail_tmpfs_size_bytes().await,
"{TMP_MOUNT_BLOCK}",
&resolve_nsjail_tmp_mount_block(job_dir).await,
)
.replace("{TIMEOUT}", &nsjail_timeout),
)?;
+6
View File
@@ -687,6 +687,12 @@ lazy_static::lazy_static! {
/// `DEFAULT_NSJAIL_TMPFS_SIZE_BYTES` (800MB).
pub static ref NSJAIL_TMPFS_SIZE_MB: Arc<RwLock<Option<i64>>> = Arc::new(RwLock::new(None));
/// Selects how `/tmp` is backed inside nsjail sandboxes. `Some("disk")`
/// switches to a bind mount on `{JOB_DIR}/jail_tmp` (disk-backed); any
/// other value (including `None` or `Some("tmpfs")`) keeps the historical
/// RAM-backed tmpfs sized by `nsjail_tmpfs_size_mb`.
pub static ref NSJAIL_TMP_BACKING: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
/// Optional mirror URL for `uv python install`. Wires to the `UV_PYTHON_INSTALL_MIRROR`
/// env var when forwarded to uv. Can be set via the `UV_PYTHON_INSTALL_MIRROR` env var
/// or the `uv_python_install_mirror` instance setting.
+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.705.0";
export const VERSION = "v1.708.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+2 -45
View File
@@ -16,13 +16,8 @@ import {
type Workspace,
} from "../workspace/workspace.ts";
import { generateRTNamespace } from "../resource-type/resource-type.ts";
import {
WMILL_INIT_AI_AGENTS_SOURCE_ENV,
WMILL_INIT_AI_CLAUDE_SOURCE_ENV,
WMILL_INIT_AI_SKILLS_SOURCE_ENV,
writeAiGuidanceFiles,
} from "../../guidance/writer.ts";
import { generateCommentedTemplate } from "./template.ts";
import { refreshPrompts } from "../refresh/prompts.ts";
export interface InitOptions {
useDefault?: boolean;
@@ -241,45 +236,7 @@ async function initAction(opts: InitOptions) {
}
}
// Read nonDottedPaths from config
let nonDottedPaths = true; // default for new inits
try {
const { readConfigFile } = await import("../../core/conf.ts");
const config = await readConfigFile();
nonDottedPaths = config.nonDottedPaths ?? true;
} catch {
// If config can't be read, use defaults
}
// Create guidance files (AGENTS.md, CLAUDE.md, and agent skills)
try {
const guidanceResult = await writeAiGuidanceFiles({
targetDir: ".",
nonDottedPaths,
overwriteProjectGuidance: false,
skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV],
agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV],
claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV],
});
if (guidanceResult.agentsWritten) {
log.info(colors.green("Created AGENTS.md"));
}
if (guidanceResult.claudeWritten) {
log.info(colors.green("Created CLAUDE.md"));
}
log.info(
colors.green(
`Created .claude/skills/ and .agents/skills/ with ${guidanceResult.skillCount} skills`
)
);
} catch (error) {
if (error instanceof Error) {
log.warn(`Could not create guidance files: ${error.message}`);
} else {
log.warn(`Could not create guidance files: ${error}`);
}
}
await refreshPrompts({ yes: opts.useDefault === true });
// Generate resource type namespace (only if a workspace was bound)
if (didBindWorkspace && boundProfile) {
+72 -1
View File
@@ -376,6 +376,63 @@ async function cancel(
log.info(colors.green(`Job ${id} canceled.`));
}
async function rerun(
opts: GlobalOptions,
id: string
) {
log.setSilent(true);
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const response = await wmill.batchReRunJobs({
workspace: workspace.workspaceId,
requestBody: {
job_ids: [id],
script_options_by_path: {},
flow_options_by_path: {},
},
});
const newIds: string[] = [];
const errorLines: string[] = [];
for (const line of String(response).split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith("Error:")) errorLines.push(trimmed);
else newIds.push(trimmed);
}
for (const err of errorLines) log.error(err);
if (newIds.length === 0) {
throw new Error(`Failed to re-run job ${id}.`);
}
console.log(newIds[0]);
}
async function restart(
opts: GlobalOptions & { step: string; iteration?: number },
id: string
) {
log.setSilent(true);
opts = await mergeConfigWithConfigFile(opts);
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
const newId = await wmill.restartFlowAtStep({
workspace: workspace.workspaceId,
id,
requestBody: {
step_id: opts.step,
branch_or_iteration_n: opts.iteration,
},
});
console.log(newId);
}
// Shared list options to avoid repetition between default action and list subcommand
const listOptions = (cmd: Command) =>
cmd
@@ -410,6 +467,20 @@ const command = listOptions(new Command()
.command("cancel", "Cancel a running or queued job")
.arguments("<id:string>")
.option("--reason <reason:string>", "Reason for cancellation")
.action(cancel as any);
.action(cancel as any)
.command(
"rerun",
"Re-run a completed job with the same args. Prints the new job UUID on stdout."
)
.arguments("<id:string>")
.action(rerun as any)
.command(
"restart",
"Restart a completed flow at a given top-level step. Prints the new flow job UUID on stdout."
)
.arguments("<id:string>")
.option("--step <stepId:string>", "Top-level step id to restart the flow from", { required: true })
.option("--iteration <n:number>", "For a top-level branchall or for-loop step, the iteration to restart at")
.action(restart as any);
export default command;
+182
View File
@@ -0,0 +1,182 @@
import { colors } from "@cliffy/ansi/colors";
import { Command } from "@cliffy/command";
import { Select } from "@cliffy/prompt/select";
import * as log from "../../core/log.ts";
import {
type AgentsMdMigration,
type ReconcileOutcome,
WMILL_INIT_AI_AGENTS_SOURCE_ENV,
WMILL_INIT_AI_CLAUDE_SOURCE_ENV,
WMILL_INIT_AI_SKILLS_SOURCE_ENV,
writeAiGuidanceFiles,
} from "../../guidance/writer.ts";
/**
* Programmatic entry point reused by `wmill init`. The init flow doesn't
* register the cliffy command itself it imports and calls this directly so
* that prompt regeneration is part of every init.
*/
export async function refreshPrompts(opts: {
yes?: boolean;
}): Promise<void> {
// Match `core/conf.ts`'s missing-key default (`?? false`) so legacy
// wmill.yaml files without the key don't drift from how sync renders
// paths. New projects get `true` via the wmill.yaml template, not via
// this fallback.
let nonDottedPaths = false;
try {
const { readConfigFile } = await import("../../core/conf.ts");
const config = await readConfigFile();
nonDottedPaths = config.nonDottedPaths ?? false;
} catch {
// If config can't be read, use the conservative default above.
}
const interactive = process.stdin.isTTY && !opts.yes;
try {
const result = await writeAiGuidanceFiles({
targetDir: ".",
nonDottedPaths,
skillsSourcePath: process.env[WMILL_INIT_AI_SKILLS_SOURCE_ENV],
agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV],
claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV],
resolveAgentsMdMigration: async () => {
if (!interactive) return "append";
return await promptMigration();
},
});
log.info(colors.green("Refreshed AGENTS.cli.md"));
reportReconciliation({
file: "AGENTS.md",
includeLine: "@AGENTS.cli.md",
created: result.agentsCreated,
migration: result.agentsMigration,
});
reportReconciliation({
file: "CLAUDE.md",
includeLine: "@AGENTS.md",
created: result.claudeCreated,
migration: result.claudeMigration,
});
log.info(
colors.green(
`Refreshed .claude/skills/ and .agents/skills/ with ${result.skillCount} skills`
)
);
log.info(
colors.gray(
"Project-specific instructions live in AGENTS.md (never overwritten unless you opt in)."
)
);
} catch (error) {
// Log first so the user sees what happened, then rethrow so `wmill
// refresh prompts` (and `wmill init`, which delegates here) exits
// non-zero. Silent swallowing would hide a broken refresh from CI.
if (error instanceof Error) {
log.error(`Could not refresh guidance files: ${error.message}`);
} else {
log.error(`Could not refresh guidance files: ${error}`);
}
throw error;
}
}
function reportReconciliation(opts: {
file: string;
includeLine: string;
created: boolean;
migration: ReconcileOutcome;
}): void {
if (opts.created) {
log.info(colors.green(`Created ${opts.file} (user-owned)`));
return;
}
switch (opts.migration) {
case "already-linked":
log.info(
colors.gray(
`${opts.file} already references ${opts.includeLine} — left as-is`
)
);
break;
case "append":
log.info(
colors.green(`Appended ${opts.includeLine} include to existing ${opts.file}`)
);
break;
case "overwrite":
log.info(colors.yellow(`Overwrote ${opts.file} with managed skeleton`));
break;
case "skip":
log.info(
colors.gray(
`${opts.file} left unchanged — wire \`${opts.includeLine}\` in manually when ready`
)
);
break;
case "not-applicable":
// unreachable when created is false, but keep exhaustive
break;
}
}
async function promptMigration(): Promise<AgentsMdMigration> {
log.info("");
log.info(
colors.yellow(
"An existing AGENTS.md or CLAUDE.md was found that does not reference Windmill's managed guidance."
)
);
log.info(
colors.gray(
"Choose how to link the managed files in (we'll apply the same choice to AGENTS.md and CLAUDE.md):"
)
);
const choice = await Select.prompt({
message: "How should we handle the existing file(s)?",
options: [
{
name:
"Append the include line " +
"(preserves your content — recommended if you have custom instructions)",
value: "append",
},
{
name:
"Overwrite with the managed skeleton " +
"(replaces your content — pick if the file only had the default template)",
value: "overwrite",
},
{
name: "Skip — leave the file alone; I'll wire it up manually",
value: "skip",
},
],
});
return choice as AgentsMdMigration;
}
interface CommandOptions {
yes?: boolean;
}
async function promptsAction(opts: CommandOptions): Promise<void> {
await refreshPrompts({ yes: opts.yes === true });
}
const command = new Command()
.description("Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.")
.option(
"--yes",
"Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include."
)
.action(promptsAction as any);
export default command;
+8
View File
@@ -0,0 +1,8 @@
import { Command } from "@cliffy/command";
import promptsCommand from "./prompts.ts";
const command = new Command()
.description("Refresh wmill-managed project files (AGENTS.cli.md and skills)")
.command("prompts", promptsCommand);
export default command;
+8 -22
View File
@@ -2503,14 +2503,8 @@ export async function pull(
}
if (opts.onlyCreateBranch) {
gitSyncDeployPush({
items: deployItems,
authorName: process.env["WM_USERNAME"] || "windmill",
authorEmail: process.env["WM_EMAIL"] || "windmill@windmill.dev",
committerName: opts.gitCommitterName,
committerEmail: opts.gitCommitterEmail,
onlyCreateBranch: true,
});
// Branch is checked out locally; the caller pushes it. Symmetric with
// the non-onlyCreateBranch path: CLI does branch + pull, never push.
return;
}
}
@@ -2864,7 +2858,7 @@ export async function pull(
await generateAppLocksInternal(
change,
true,
true,
false,
workspace,
opts,
true,
@@ -2982,19 +2976,11 @@ export async function pull(
log.warn(`Failed to pull shared UI folder: ${e}`);
}
// Git-sync deployment-callback mode: commit the pulled files and push the
// current branch (the wm_deploy/fork branch checked out above, or the base
// branch in workspace-wide mode).
if (opts.gitDeployItems !== undefined && !opts.onlyCreateBranch) {
const deployItems: GitSyncDeployItem[] = JSON.parse(opts.gitDeployItems);
gitSyncDeployPush({
items: deployItems,
authorName: process.env["WM_USERNAME"] || "windmill",
authorEmail: process.env["WM_EMAIL"] || "windmill@windmill.dev",
committerName: opts.gitCommitterName,
committerEmail: opts.gitCommitterEmail,
});
}
// Git-sync deployment-callback mode stops here: branch checkout + pull have
// happened, but commit + push are the caller's job. The hub script does
// them in-process with `set_gpg_signing_secret` so the agent's pre-warmed
// passphrase cache is still warm at sign time (WIN-1974). `gitSyncDeployPush`
// stays exported for callers that want the same commit/push behavior.
}
// Internal git-sync deployment-callback entrypoint. Invoked only by the
+93 -9
View File
@@ -1,17 +1,55 @@
/**
* Core guidance content for AGENTS.md
* Core guidance content for the AGENTS files Windmill writes during init.
*
* This module exports the template for the AGENTS.md file that provides
* AI agent instructions for working with Windmill projects.
* `wmill` writes two files:
*
* - `AGENTS.cli.md` managed CLI / workspace guidance, refreshed by
* `wmill refresh prompts` (and the implicit refresh inside `wmill init`).
* - `AGENTS.md` user-owned project entry point. The default skeleton
* references `AGENTS.cli.md` via an `@`-include so the managed content is
* pulled in automatically.
*/
export const AGENTS_CLI_INCLUDE_LINE = "@AGENTS.cli.md";
/**
* Generate the AGENTS.md content with the given skills reference.
* @param skillsReference - A formatted list of skills to include in the document
* @returns The complete AGENTS.md content
* Lightweight, user-owned AGENTS.md skeleton. Written only when no AGENTS.md
* exists in the project. Everything below the `@AGENTS.cli.md` include is for
* the user to edit; nothing in this file is refreshed by `wmill`.
*/
export function generateAgentsMdContent(skillsReference: string): string {
return `# Windmill AI Agent Instructions
export function generateAgentsMdSkeleton(): string {
return `# Project AI Agent Instructions
This file is the entry point for AI agents working in this repository. It is
**user-owned** \`wmill\` never overwrites it. Add your project-specific
guidance below the include line.
The line below pulls in Windmill's managed CLI guidance (skills, deploy flow,
debugging jobs, etc.). Refresh it with \`wmill refresh prompts\`. Remove the
include line if you don't want the managed guidance in this project.
${AGENTS_CLI_INCLUDE_LINE}
## Project-specific instructions
<!-- Add anything specific to this repo here. Examples:
- Deploy commands or environments unique to this project.
- Domain glossary, naming conventions, or "ask before X" rules.
- Overrides for the managed guidance above (be explicit that they
supersede the managed rule). -->
`;
}
/**
* Managed AGENTS.cli.md content. Rewritten by `wmill init` and
* `wmill refresh prompts` every time.
*/
export function generateAgentsCliMdContent(skillsReference: string): string {
return `# Windmill CLI Agent Instructions
> Managed by \`wmill\`. This file is regenerated on \`wmill init\` and
> \`wmill refresh prompts\` — edit AGENTS.md (user-owned) for project-specific
> instructions instead.
You are a helpful assistant that can help with Windmill scripts, flows, apps, and resources management.
@@ -55,6 +93,50 @@ You MUST use the \`preview\` skill any time the user wants to see/open/visualize
You MUST use the \`cli-commands\` skill to use the CLI.
## Running and previewing local changes
Local previews exist for every entity type and don't deploy:
- \`wmill script preview <path> -d '<args>'\` — run a local script.
- \`wmill flow preview <flow_path> -d '<args>'\` — run a local flow.yaml.
- \`wmill app dev\` — live-reload dev server for raw apps.
Argument shapes and per-language details live in the \`write-script-<lang>\`, \`write-flow\`, and \`raw-app\` skills.
## Deploying
There are two ways local changes reach the workspace. Pick based on how the repo is wired, not habit.
### Detecting the setup
Before deploying, check whether this repo has a **GitHub Actions (or other CI) workflow that runs \`wmill sync push\` on push**. That workflow is the signal that pushing a branch will deploy:
- Look for \`.github/workflows/*.yml\` (or other CI configs) that invoke \`wmill sync push\`, \`wmill\` deployment commands, or similar.
- Cache the result for the rest of the session don't re-scan on every deploy.
If such a workflow exists **use \`git push\`** (Option A). Otherwise → **use \`wmill sync push\`** directly (Option B).
### Option A \`git push\` (CI is wired to sync)
The CI workflow will pick up the commit and run \`wmill sync push\` on the backend, which is how deployments are intended to happen in this repo. Don't bypass it.
1. \`git add\` + \`git commit\` the local changes.
2. \`git push\` to the branch the CI runs on.
3. The workflow deploys to the workspace.
Only fall back to Option B if the user explicitly asks to bypass CI for this change (e.g. CI is broken, urgent hotfix), or if the workflow doesn't cover the current branch.
### Option B \`wmill sync push\` (no CI wiring)
No CI workflow runs \`wmill sync push\` automatically, so deploy directly from the CLI:
- \`wmill sync push --dry-run\` to preview.
- \`wmill sync push\` to apply.
### In both cases
Only deploy when the user explicitly asks to deploy, publish, push, or ship not when they say "run", "try", or "test". For testing local edits use the per-entity \`preview\` commands (\`wmill script preview\`, \`wmill flow preview\`) — they don't deploy.
## Debugging Jobs
When the user reports a script or flow failure, is investigating unexpected output, or asks why something ran the way it did, use the CLI to fetch job details before speculating. See the \`cli-commands\` skill for all flags.
@@ -65,12 +147,14 @@ When the user reports a script or flow failure, is investigating unexpected outp
- \`wmill job logs <id>\` — stdout/stderr; for flows, aggregates every step's logs
- \`wmill job result <id>\` — JSON result of a completed job
- \`wmill job cancel <id>\` — stop a running or queued job
- \`wmill job rerun <id>\` — re-run a completed job with the same args (single-job equivalent of the frontend "rerun" button)
- \`wmill job restart <id> --step <step-id> [--iteration <n>]\` — restart a completed flow at a top-level step (for nested-container restart, use the UI)
For flow failures, start with \`wmill job get <id>\` to identify the failing step and its sub-job ID, then \`wmill job logs <sub-job-id>\` to drill in.
## Skills
For specific guidance, ALWAYS use the skills listed below.
For specific guidance, ALWAYS use the skills listed below. Paths point at \`.agents/skills/\` — Claude Code reads identical copies under \`.claude/skills/\`.
${skillsReference}
`;
+169
View File
@@ -0,0 +1,169 @@
/**
* Versioning + freshness check for the managed AGENTS.cli.md bundle.
*
* We embed a short hash of "what this CLI would write" into AGENTS.cli.md as
* an HTML comment. On every `wmill` command (with a few exceptions), we read
* the stored hash and compare against the current CLI's hash. Mismatch =>
* one-line warning telling the user to `wmill refresh prompts`.
*
* The hash covers all inputs that affect the rendered bundle: the
* AGENTS.cli.md template, every skill body, schemas and schema mappings, and
* the nonDottedPaths setting. It is *not* tied to the CLI's package version,
* so non-prompt CLI releases don't produce false positives.
*/
import { createHash } from "node:crypto";
import { stat } from "node:fs/promises";
import { colors } from "@cliffy/ansi/colors";
import { readTextFile } from "../utils/utils.ts";
import { generateAgentsCliMdContent } from "./core.ts";
import {
SCHEMAS,
SCHEMA_MAPPINGS,
SKILLS,
SKILL_CONTENT,
} from "./skills.gen.ts";
// Re-export from the gate module so existing callers (and tests) keep working.
// `shouldRunFreshnessCheck` lives there to avoid pulling skills.gen.ts (~360 KB)
// into main.ts's static import graph; main.ts now imports the gate directly
// and only `await import`s this file lazily.
import { shouldRunFreshnessCheck } from "./freshness_gate.ts";
export { shouldRunFreshnessCheck };
export const PROMPTS_HASH_MARKER_PREFIX = "<!-- wmill-prompts-hash: ";
const PROMPTS_HASH_REGEX = /<!-- wmill-prompts-hash: ([0-9a-f]{12}) -->/;
export function buildPromptsHashMarker(hash: string): string {
return `${PROMPTS_HASH_MARKER_PREFIX}${hash} -->`;
}
export function extractPromptsHash(content: string): string | null {
const match = content.match(PROMPTS_HASH_REGEX);
return match ? match[1] : null;
}
/**
* Insert the hash marker into rendered AGENTS.cli.md content. The marker
* goes on the line right after the title so it's easy to find and doesn't
* break the rendered Markdown structure.
*/
export function injectPromptsHashMarker(
content: string,
hash: string
): string {
const lines = content.split("\n");
const marker = buildPromptsHashMarker(hash);
// Insert right after the first line if it's an H1 title; otherwise
// prepend so the marker is always near the top.
const insertAt = lines[0].startsWith("# ") ? 1 : 0;
lines.splice(insertAt, 0, marker);
return lines.join("\n");
}
/**
* Compute the hash for the rendered bundle. The hash is deterministic for a
* given (CLI bundle, nonDottedPaths) pair.
*/
export function currentPromptsHash(nonDottedPaths: boolean): string {
const hasher = createHash("sha256");
// Template structure (without the skills reference — that's hashed
// separately from the SKILLS metadata).
hasher.update("template:");
hasher.update(generateAgentsCliMdContent("__PLACEHOLDER__"));
// Skill metadata (names + descriptions) — fed into the skills reference
// line in AGENTS.cli.md and the wrapper frontmatter.
hasher.update("\nskills:");
hasher.update(JSON.stringify(SKILLS));
// Skill bodies — what actually lands in .agents/skills/<name>/SKILL.md.
// Sort entries for stable ordering.
hasher.update("\nbodies:");
for (const [name, content] of Object.entries(SKILL_CONTENT).sort()) {
hasher.update("\n");
hasher.update(name);
hasher.update("\n");
hasher.update(content);
}
// Schemas + their mappings — embedded inside specific skills.
hasher.update("\nschemas:");
hasher.update(JSON.stringify(SCHEMAS));
hasher.update("\nmappings:");
hasher.update(JSON.stringify(SCHEMA_MAPPINGS));
// Path-style setting — controls __flow vs .flow rendering in skill bodies.
hasher.update("\nnonDotted:");
hasher.update(String(nonDottedPaths));
return hasher.digest("hex").slice(0, 12);
}
/**
* Read AGENTS.cli.md in the current working directory, compare its embedded
* hash to the current CLI's hash, and print a one-line warning if they
* differ. Silent on every other code path (no AGENTS.cli.md, no marker,
* matching hash, IO error, ) so it never gets in the user's way.
*/
export async function warnIfPromptsStale(opts?: {
cwd?: string;
nonDottedPaths?: boolean;
argv?: readonly string[];
}): Promise<void> {
if (opts?.argv && !shouldRunFreshnessCheck(opts.argv)) return;
const cwd = opts?.cwd ?? process.cwd();
const path = `${cwd}/AGENTS.cli.md`;
if (!(await stat(path).catch(() => null))) return;
let content: string;
try {
content = await readTextFile(path);
} catch {
return;
}
const stored = extractPromptsHash(content);
if (!stored) {
// Older AGENTS.cli.md without a marker. Warn so the user re-runs
// refresh and picks up the new format.
emitWarning(
"Your AGENTS.cli.md predates prompt versioning. Run `wmill refresh prompts` to refresh and add a version marker."
);
return;
}
let nonDottedPaths = opts?.nonDottedPaths;
if (nonDottedPaths === undefined) {
try {
const { readConfigFile } = await import("../core/conf.ts");
const config = await readConfigFile();
// Match `core/conf.ts`'s missing-key default (`?? false`); otherwise
// legacy wmill.yaml files without the key trip a permanent freshness
// warning even though the prompts are objectively up to date.
nonDottedPaths = config.nonDottedPaths ?? false;
} catch {
nonDottedPaths = false;
}
}
const current = currentPromptsHash(nonDottedPaths);
if (stored !== current) {
emitWarning(
"Your AGENTS.cli.md is out of date. Run `wmill refresh prompts` to refresh."
);
}
}
/**
* Send the freshness warning to **stderr** so it never contaminates a
* downstream pipe (e.g. `wmill job result <id> | jq`). The rest of the CLI
* uses `log.warn` which writes to stdout that's wrong for an always-on
* notification like this one, but we don't want to fix `log.warn` globally
* in this PR.
*/
function emitWarning(message: string): void {
process.stderr.write(`${colors.yellow(message)}\n`);
}
+67
View File
@@ -0,0 +1,67 @@
/**
* Argv-only gate for the prompts freshness check. Kept in its own module so
* `main.ts` can import it without pulling in the heavy `skills.gen.ts`
* bundle (~360 KB) on every `wmill` invocation. The full check (which does
* touch the bundle) lives in `./freshness.ts` and is loaded lazily after
* this gate returns `true`.
*/
/**
* Subcommands where a freshness warning is noise (the user is either fixing
* it, asking for help, or doing something orthogonal).
*/
const SKIP_FRESHNESS_FOR_SUBCOMMANDS = new Set([
"init",
"refresh",
"completions",
"upgrade",
]);
/**
* Cliffy global options that consume the *next* argv element as their value.
* Must be kept in sync with the option declarations on the top-level
* `command` in `cli/src/main.ts`.
*/
const VALUE_GLOBAL_OPTS = new Set([
"--workspace",
"--token",
"--base-url",
"--config-dir",
]);
/**
* Returns `true` if the freshness check should run for this invocation.
*
* Bypasses:
* - bare `wmill` (no subcommand shows help)
* - `--help`, `-h`, `--version`, `-V` anywhere in the args
* - subcommands in {init, refresh, completions, upgrade}
*
* Handles cliffy global options that take a value (`--workspace foo`,
* `--token tok`, `--base-url https://…`, `--config-dir /etc/wmill`) by
* skipping their value when scanning for the first positional argument.
* Without that, `wmill --workspace prod refresh prompts` would misread
* `"prod"` as the subcommand and fire the warning during the very command
* meant to fix it.
*/
export function shouldRunFreshnessCheck(argv: readonly string[]): boolean {
const args = argv.slice(2); // strip node + script
if (args.length === 0) return false;
if (args.includes("--help") || args.includes("-h")) return false;
if (args.includes("--version") || args.includes("-V")) return false;
let i = 0;
while (i < args.length) {
const arg = args[i];
if (VALUE_GLOBAL_OPTS.has(arg)) {
i += 2; // skip flag + its value
continue;
}
if (arg.startsWith("-")) {
i += 1; // flag with no value
continue;
}
return !SKIP_FRESHNESS_FOR_SUBCOMMANDS.has(arg);
}
return false;
}
+105 -16
View File
@@ -605,9 +605,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise<an
/**
* Get S3 client settings from a resource or workspace default
* @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
* @returns S3 client configuration settings
*/
async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise<DenoS3LightClientSettings>
async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise<DenoS3LightClientSettings>
/**
* Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -618,8 +619,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise<D
* const text = new TextDecoder().decode(fileContentStream)
* console.log(text);
* \`\`\`
*
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
*/
async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise<Uint8Array | undefined>
async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Uint8Array | undefined>
/**
* Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -629,8 +632,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi
* // if the content is plain text, the blob can be read directly:
* console.log(await fileContentBlob.text());
* \`\`\`
*
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
*/
async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise<Blob | undefined>
async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Blob | undefined>
/**
* Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -640,8 +645,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined =
* const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')
* console.log(fileContentAsUtf8Str)
* \`\`\`
*
* @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var)
*/
async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise<S3Object>
async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise<S3Object>
/**
* Permanently delete a file from S3 by key.
*
* \`\`\`typescript
* await wmill.deleteS3File({ s3: "path/to/file.txt" })
* \`\`\`
*
* @param s3object - S3 object identifying the file to delete (must have \`s3\` set)
* @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var)
*/
async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise<void>
/**
* Sign S3 objects to be used by anonymous users in public apps
@@ -1296,9 +1315,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise<an
/**
* Get S3 client settings from a resource or workspace default
* @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
* @returns S3 client configuration settings
*/
async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise<DenoS3LightClientSettings>
async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise<DenoS3LightClientSettings>
/**
* Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -1309,8 +1329,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise<D
* const text = new TextDecoder().decode(fileContentStream)
* console.log(text);
* \`\`\`
*
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
*/
async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise<Uint8Array | undefined>
async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Uint8Array | undefined>
/**
* Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -1320,8 +1342,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi
* // if the content is plain text, the blob can be read directly:
* console.log(await fileContentBlob.text());
* \`\`\`
*
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
*/
async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise<Blob | undefined>
async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Blob | undefined>
/**
* Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -1331,8 +1355,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined =
* const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')
* console.log(fileContentAsUtf8Str)
* \`\`\`
*
* @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var)
*/
async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise<S3Object>
async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise<S3Object>
/**
* Permanently delete a file from S3 by key.
*
* \`\`\`typescript
* await wmill.deleteS3File({ s3: "path/to/file.txt" })
* \`\`\`
*
* @param s3object - S3 object identifying the file to delete (must have \`s3\` set)
* @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var)
*/
async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise<void>
/**
* Sign S3 objects to be used by anonymous users in public apps
@@ -2075,9 +2113,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise<an
/**
* Get S3 client settings from a resource or workspace default
* @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
* @returns S3 client configuration settings
*/
async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise<DenoS3LightClientSettings>
async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise<DenoS3LightClientSettings>
/**
* Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -2088,8 +2127,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise<D
* const text = new TextDecoder().decode(fileContentStream)
* console.log(text);
* \`\`\`
*
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
*/
async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise<Uint8Array | undefined>
async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Uint8Array | undefined>
/**
* Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -2099,8 +2140,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi
* // if the content is plain text, the blob can be read directly:
* console.log(await fileContentBlob.text());
* \`\`\`
*
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
*/
async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise<Blob | undefined>
async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Blob | undefined>
/**
* Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -2110,8 +2153,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined =
* const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')
* console.log(fileContentAsUtf8Str)
* \`\`\`
*
* @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var)
*/
async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise<S3Object>
async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise<S3Object>
/**
* Permanently delete a file from S3 by key.
*
* \`\`\`typescript
* await wmill.deleteS3File({ s3: "path/to/file.txt" })
* \`\`\`
*
* @param s3object - S3 object identifying the file to delete (must have \`s3\` set)
* @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var)
*/
async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise<void>
/**
* Sign S3 objects to be used by anonymous users in public apps
@@ -3277,9 +3334,10 @@ async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise<an
/**
* Get S3 client settings from a resource or workspace default
* @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
* @returns S3 client configuration settings
*/
async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise<DenoS3LightClientSettings>
async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise<DenoS3LightClientSettings>
/**
* Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -3290,8 +3348,10 @@ async denoS3LightClientSettings(s3_resource_path: string | undefined): Promise<D
* const text = new TextDecoder().decode(fileContentStream)
* console.log(text);
* \`\`\`
*
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
*/
async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise<Uint8Array | undefined>
async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Uint8Array | undefined>
/**
* Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -3301,8 +3361,10 @@ async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefi
* // if the content is plain text, the blob can be read directly:
* console.log(await fileContentBlob.text());
* \`\`\`
*
* @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var)
*/
async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined): Promise<Blob | undefined>
async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Blob | undefined>
/**
* Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.
@@ -3312,8 +3374,22 @@ async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined =
* const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')
* console.log(fileContentAsUtf8Str)
* \`\`\`
*
* @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var)
*/
async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined): Promise<S3Object>
async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise<S3Object>
/**
* Permanently delete a file from S3 by key.
*
* \`\`\`typescript
* await wmill.deleteS3File({ s3: "path/to/file.txt" })
* \`\`\`
*
* @param s3object - S3 object identifying the file to delete (must have \`s3\` set)
* @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var)
*/
async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise<void>
/**
* Sign S3 objects to be used by anonymous users in public apps
@@ -6948,6 +7024,10 @@ Manage jobs (list, inspect, cancel)
- \`job logs <id:string>\` - Get job logs. For flows: aggregates all step logs
- \`job cancel <id:string>\` - Cancel a running or queued job
- \`--reason <reason:string>\` - Reason for cancellation
- \`job rerun <id:string>\` - Re-run a completed job with the same args. Prints the new job UUID on stdout.
- \`job restart <id:string>\` - Restart a completed flow at a given top-level step. Prints the new flow job UUID on stdout.
- \`--step <stepId:string>\` - Top-level step id to restart the flow from
- \`--iteration <n:number>\` - For a top-level branchall or for-loop step, the iteration to restart at
### jobs
@@ -7001,6 +7081,15 @@ List all queues with their metrics
- \`--instance [instance]\` - Name of the instance to push to, override the active instance
- \`--base-url [baseUrl]\` - If used with --token, will be used as the base url for the instance
### refresh
Refresh wmill-managed project files (AGENTS.cli.md and skills)
**Subcommands:**
- \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.
- \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include.
### resource
resource related commands
+197 -43
View File
@@ -1,7 +1,15 @@
import { cp, mkdir, readdir, stat, writeFile } from "node:fs/promises";
import { readTextFile } from "../utils/utils.ts";
import { join } from "node:path";
import { generateAgentsMdContent } from "./core.ts";
import {
AGENTS_CLI_INCLUDE_LINE,
generateAgentsCliMdContent,
generateAgentsMdSkeleton,
} from "./core.ts";
import {
currentPromptsHash,
injectPromptsHashMarker,
} from "./freshness.ts";
import {
SCHEMAS,
SCHEMA_MAPPINGS,
@@ -14,18 +22,47 @@ type ResolvedSkillMetadata = SkillMetadata & {
directoryName: string;
};
/**
* How to reconcile an existing user-owned guidance file (AGENTS.md or
* CLAUDE.md) that doesn't reference the managed file below it
* (`@AGENTS.cli.md` for AGENTS.md, `@AGENTS.md` for CLAUDE.md).
*
* - `append`: leave the file as-is and append the include line.
* - `overwrite`: replace the file with the managed skeleton.
* - `skip`: leave the file alone. The managed downstream file is still
* written/refreshed, but no link to it the user is expected to wire it
* manually later.
*/
export type AgentsMdMigration = "append" | "overwrite" | "skip";
export type ReconcileOutcome =
| AgentsMdMigration
| "already-linked"
| "not-applicable";
export interface WriteAiGuidanceOptions {
targetDir: string;
nonDottedPaths?: boolean;
overwriteProjectGuidance?: boolean;
/** Skill source override (testing / source-of-truth bundling). */
skillsSourcePath?: string;
/** AGENTS.cli.md source override (testing). */
agentsSourcePath?: string;
/** CLAUDE.md source override (testing). */
claudeSourcePath?: string;
/**
* Optional resolver invoked when an existing AGENTS.md lacks an
* `@AGENTS.cli.md` reference. Callers are expected to prompt the user; if
* omitted, the writer defaults to `append` (non-destructive).
*/
resolveAgentsMdMigration?: () => Promise<AgentsMdMigration>;
}
export interface WriteAiGuidanceResult {
agentsWritten: boolean;
claudeWritten: boolean;
agentsCliWritten: boolean;
agentsCreated: boolean;
agentsMigration: ReconcileOutcome;
claudeCreated: boolean;
claudeMigration: ReconcileOutcome;
skillCount: number;
}
@@ -34,32 +71,73 @@ export const WMILL_INIT_AI_AGENTS_SOURCE_ENV = "WMILL_INIT_AI_AGENTS_SOURCE";
export const WMILL_INIT_AI_CLAUDE_SOURCE_ENV = "WMILL_INIT_AI_CLAUDE_SOURCE";
const CLAUDE_MD_DEFAULT = "Instructions are in @AGENTS.md\n";
const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const;
const CLAUDE_MD_INCLUDE_LINE = "@AGENTS.md";
/**
* Both `.agents/skills/` (read by Codex, Pi) and `.claude/skills/` (read by
* Claude Code) receive the full skill content. We can't use `@<path>` to
* deduplicate because Claude's skill loader reads SKILL.md as-is it does
* not expand `@` references inside skill bodies (those work only in
* AGENTS.md / CLAUDE.md).
*/
const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const;
export async function writeAiGuidanceFiles(
options: WriteAiGuidanceOptions
): Promise<WriteAiGuidanceResult> {
const nonDottedPaths = options.nonDottedPaths ?? true;
// Match `core/conf.ts`'s missing-key default — if a legacy wmill.yaml
// omits `nonDottedPaths`, sync treats it as `false`, so we must too or
// the freshness hash will be permanently out of sync with the rest of
// the CLI's view of the project.
const nonDottedPaths = options.nonDottedPaths ?? false;
const skillMetadata = options.skillsSourcePath
? await readSkillMetadataFromDirectory(options.skillsSourcePath)
: getGeneratedSkillMetadata();
const agentsWritten = await writeProjectGuidanceFile({
targetPath: join(options.targetDir, "AGENTS.md"),
overwrite: options.overwriteProjectGuidance ?? false,
content:
options.agentsSourcePath != null
? await readTextFile(options.agentsSourcePath)
: generateAgentsMdContent(buildSkillsReference(skillMetadata)),
// AGENTS.cli.md — always (re)written, this is the managed file.
// We embed a content-hash marker so other `wmill` commands can detect a
// stale bundle and prompt the user to `wmill refresh prompts`.
const rawAgentsCliContent =
options.agentsSourcePath != null
? await readTextFile(options.agentsSourcePath)
: generateAgentsCliMdContent(buildSkillsReference(skillMetadata));
const agentsCliContent = injectPromptsHashMarker(
rawAgentsCliContent,
currentPromptsHash(nonDottedPaths)
);
const agentsCliPath = join(options.targetDir, "AGENTS.cli.md");
await writeFile(agentsCliPath, agentsCliContent, "utf8");
const agentsCliWritten = true;
// Cache the user's first migration answer and reuse it for every file
// that needs reconciling in this run — there's never a good reason to ask
// the same question twice in a row.
const resolveMigration = cacheOnce(options.resolveAgentsMdMigration);
// AGENTS.md — user-owned. Three paths:
// 1. doesn't exist → create skeleton (which already includes @AGENTS.cli.md).
// 2. exists and already references @AGENTS.cli.md → leave alone.
// 3. exists but doesn't reference @AGENTS.cli.md → ask caller via
// resolveMigration (defaults to append).
const agentsMdResult = await reconcileIncludingFile({
path: join(options.targetDir, "AGENTS.md"),
includeLine: AGENTS_CLI_INCLUDE_LINE,
skeleton: generateAgentsMdSkeleton(),
resolveMigration,
});
const claudeWritten = await writeProjectGuidanceFile({
targetPath: join(options.targetDir, "CLAUDE.md"),
overwrite: options.overwriteProjectGuidance ?? false,
content:
options.claudeSourcePath != null
? await readTextFile(options.claudeSourcePath)
: CLAUDE_MD_DEFAULT,
// CLAUDE.md — user-owned wrapper that points at @AGENTS.md. Same three-way
// reconciliation: create if missing, leave alone if it already references
// AGENTS.md, otherwise ask via resolveMigration.
const claudeSkeleton =
options.claudeSourcePath != null
? await readTextFile(options.claudeSourcePath)
: CLAUDE_MD_DEFAULT;
const claudeMdResult = await reconcileIncludingFile({
path: join(options.targetDir, "CLAUDE.md"),
includeLine: CLAUDE_MD_INCLUDE_LINE,
skeleton: claudeSkeleton,
resolveMigration,
});
if (options.skillsSourcePath) {
@@ -69,17 +147,87 @@ export async function writeAiGuidanceFiles(
}
return {
agentsWritten,
claudeWritten,
agentsCliWritten,
agentsCreated: agentsMdResult.created,
agentsMigration: agentsMdResult.migration,
claudeCreated: claudeMdResult.created,
claudeMigration: claudeMdResult.migration,
skillCount: skillMetadata.length,
};
}
function cacheOnce(
resolver: (() => Promise<AgentsMdMigration>) | undefined
): (() => Promise<AgentsMdMigration>) | undefined {
if (!resolver) return undefined;
let cached: AgentsMdMigration | null = null;
return async () => {
if (cached !== null) return cached;
cached = await resolver();
return cached;
};
}
async function reconcileIncludingFile(options: {
path: string;
includeLine: string;
skeleton: string;
resolveMigration?: () => Promise<AgentsMdMigration>;
}): Promise<{ created: boolean; migration: ReconcileOutcome }> {
const exists = (await stat(options.path).catch(() => null)) != null;
if (!exists) {
await writeFile(options.path, options.skeleton, "utf8");
return { created: true, migration: "not-applicable" };
}
const existing = await readTextFile(options.path);
if (referencesIncludeLine(existing, options.includeLine)) {
return { created: false, migration: "already-linked" };
}
const choice = options.resolveMigration
? await options.resolveMigration()
: "append";
if (choice === "skip") {
return { created: false, migration: "skip" };
}
if (choice === "overwrite") {
await writeFile(options.path, options.skeleton, "utf8");
return { created: false, migration: "overwrite" };
}
// append — add the include at the end, leaving existing content untouched.
const appended = existing.endsWith("\n")
? `${existing}\n${options.includeLine}\n`
: `${existing}\n\n${options.includeLine}\n`;
await writeFile(options.path, appended, "utf8");
return { created: false, migration: "append" };
}
function referencesIncludeLine(content: string, includeLine: string): boolean {
// Match only when the include sits on a line by itself (allowing leading
// and trailing whitespace). Earlier we split on `\s+`, but that
// false-positives on commented-out includes like `<!-- @AGENTS.cli.md -->`
// where the middle token equals the include. CRLF is handled by the
// `\r?\n` split.
for (const line of content.split(/\r?\n/)) {
if (line.trim() === includeLine) {
return true;
}
}
return false;
}
function buildSkillsReference(
skills: Pick<ResolvedSkillMetadata, "directoryName" | "description">[]
): string {
return skills
.map((skill) => `- \`.claude/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}`)
.map(
(skill) =>
`- \`.agents/skills/${skill.directoryName}/SKILL.md\` - ${skill.description}`
)
.join("\n");
}
@@ -89,7 +237,9 @@ async function copySkillsFromSource(
): Promise<ResolvedSkillMetadata[]> {
const skillsDirs = await ensureSkillsDirectories(targetDir);
await Promise.all(
skillsDirs.map((skillsDir) => copyDirectoryContents(skillsSourcePath, skillsDir))
skillsDirs.map((skillsDir) =>
copyDirectoryContents(skillsSourcePath, skillsDir)
)
);
return await readSkillMetadataFromDirectory(skillsDirs[0]);
}
@@ -137,7 +287,10 @@ async function ensureSkillsDirectories(targetDir: string): Promise<string[]> {
return skillsDirs;
}
async function copyDirectoryContents(sourceDir: string, targetDir: string): Promise<void> {
async function copyDirectoryContents(
sourceDir: string,
targetDir: string
): Promise<void> {
const entries = await readdir(sourceDir, { withFileTypes: true });
await Promise.all(
@@ -150,7 +303,10 @@ async function copyDirectoryContents(sourceDir: string, targetDir: string): Prom
);
}
function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean): string {
function renderGeneratedSkillContent(
skillName: string,
nonDottedPaths: boolean
): string {
let skillContent = SKILL_CONTENT[skillName];
if (!skillContent) {
throw new Error(`Missing generated skill content for ${skillName}`);
@@ -187,7 +343,11 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean)
if (!schemaYaml) {
return null;
}
return formatSchemaForMarkdown(schemaYaml, mapping.name, mapping.filePattern);
return formatSchemaForMarkdown(
schemaYaml,
mapping.name,
mapping.filePattern
);
})
.filter((entry): entry is string => entry !== null);
@@ -198,11 +358,15 @@ function renderGeneratedSkillContent(skillName: string, nonDottedPaths: boolean)
return `${skillContent}\n\n${schemaDocs.join("\n\n")}`;
}
async function readSkillMetadataFromDirectory(skillsDir: string): Promise<ResolvedSkillMetadata[]> {
async function readSkillMetadataFromDirectory(
skillsDir: string
): Promise<ResolvedSkillMetadata[]> {
const entries = await readdir(skillsDir, { withFileTypes: true });
const skills: ResolvedSkillMetadata[] = [];
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
for (const entry of entries.sort((left, right) =>
left.name.localeCompare(right.name)
)) {
if (!entry.isDirectory()) {
continue;
}
@@ -219,7 +383,10 @@ async function readSkillMetadataFromDirectory(skillsDir: string): Promise<Resolv
return skills;
}
function parseSkillMetadata(content: string, fallbackName: string): ResolvedSkillMetadata {
function parseSkillMetadata(
content: string,
fallbackName: string
): ResolvedSkillMetadata {
const frontMatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!frontMatterMatch) {
return {
@@ -251,19 +418,6 @@ function parseSkillMetadata(content: string, fallbackName: string): ResolvedSkil
return { name, description, directoryName: fallbackName };
}
async function writeProjectGuidanceFile(options: {
targetPath: string;
content: string;
overwrite: boolean;
}): Promise<boolean> {
if (!options.overwrite && (await stat(options.targetPath).catch(() => null))) {
return false;
}
await writeFile(options.targetPath, options.content, "utf8");
return true;
}
function formatSchemaForMarkdown(
schemaYaml: string,
schemaName: string,
+13 -1
View File
@@ -40,6 +40,8 @@ import workers from "./commands/workers/workers.ts";
import queues from "./commands/queues/queues.ts";
import dependencies from "./commands/dependencies/dependencies.ts";
import init from "./commands/init/init.ts";
import refresh from "./commands/refresh/refresh.ts";
import { shouldRunFreshnessCheck } from "./guidance/freshness_gate.ts";
import jobs from "./commands/jobs/jobs.ts";
import job from "./commands/job/job.ts";
import group from "./commands/group/group.ts";
@@ -85,7 +87,7 @@ export {
token,
};
export const VERSION = "1.705.0";
export const VERSION = "1.708.0";
// Re-exported from constants.ts to maintain backwards compatibility
export { WM_FORK_PREFIX } from "./core/constants.ts";
@@ -175,6 +177,7 @@ const command = new Command()
},
})
.command("init", init)
.command("refresh", refresh)
.command("app", app)
.command("flow", flow)
.command("script", script)
@@ -291,6 +294,15 @@ async function main() {
await detectAuthGatewayChallenge(response);
return response;
});
// Warn (one line) if AGENTS.cli.md predates this CLI's prompts bundle.
// The check is gated on argv parsing (cheap) so the ~360 KB skills.gen.ts
// bundle stays out of the import graph for help/version/init/refresh/etc.
if (shouldRunFreshnessCheck(process.argv)) {
const { warnIfPromptsStale } = await import("./guidance/freshness.ts");
await warnIfPromptsStale({ argv: process.argv }).catch(() => {});
}
await command.parse(args);
} catch (e) {
if (e && typeof e === "object" && "name" in e && e.name === "ApiError") {
+25 -4
View File
@@ -6,10 +6,11 @@
* `use_individual_branch` is set NOT straight to the cloned base branch
* (e.g. a protected `main`, which fails with GH006).
*
* The CLI's `wmill sync pull --git-deploy-items ...` now owns that branch
* checkout + commit + push (previously hub-script-only, hence untestable).
* This drives it against a real local bare repo so the regression is caught
* deterministically, with no network and no GitHub.
* Contract: `wmill sync git-deploy` does branch checkout + pull only. Commit
* + push are the caller's job the hub script does them in the same process
* as `set_gpg_signing_secret` so the GPG agent's passphrase cache is still
* warm at sign time (WIN-1974). This test replicates the caller half (git
* add + commit + push) inline so the full promotion regression stays caught.
*/
import { expect, test } from "bun:test";
@@ -121,6 +122,23 @@ test.skipIf(shouldSkipOnCI())(
{ path_type: "script", path: "f/promo/foo", commit_msg: "deploy foo" },
]);
// Caller-half: stage anything the CLI's pull dropped, commit on the
// current branch (which the CLI just checked out), and push. Mirrors
// what the hub script does in production after `wmill sync git-deploy`.
const commitAndPush = (work: string) => {
git(work, "config", "user.email", "test@windmill.dev");
git(work, "config", "user.name", "test");
git(work, "add", "-A");
try {
git(work, "diff", "--cached", "--quiet");
// Exit 0 = nothing staged; nothing to commit. Still push the
// (possibly new) branch ref so the assertions see it.
} catch {
git(work, "commit", "-m", "deploy foo");
}
git(work, "push", "--porcelain", "-u", "origin", "HEAD");
};
// --- Case A: use_individual_branch=true -> wm_deploy branch, main untouched ---
const workA = await mkdtemp(join(tmpdir(), "wmill_promo_a_"));
git(workA, "clone", `file://${bareDir}`, ".");
@@ -141,6 +159,7 @@ test.skipIf(shouldSkipOnCI())(
workA,
);
expect(resA.code).toBe(0);
commitAndPush(workA);
const branchesA = remoteBranches(bareDir);
const expectedBranch = `refs/heads/wm_deploy/${ws}/script/f__promo__foo`;
@@ -167,6 +186,8 @@ test.skipIf(shouldSkipOnCI())(
workB,
);
expect(resB.code).toBe(0);
commitAndPush(workB);
expect(remoteHead(bareDir, "main")).not.toBe(seedMain);
expect(
remoteBranches(bareDir).filter((b) => b.includes("wm_deploy")).length,
+403 -27
View File
@@ -3,8 +3,15 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { writeAiGuidanceFiles } from "../src/guidance/writer.ts";
import {
currentPromptsHash,
extractPromptsHash,
injectPromptsHashMarker,
shouldRunFreshnessCheck,
warnIfPromptsStale,
} from "../src/guidance/freshness.ts";
const SKILL_TARGET_ROOTS = [".claude", ".agents"] as const;
const SKILL_TARGET_ROOTS = [".agents", ".claude"] as const;
async function withTempDir(fn: (tempDir: string) => Promise<void>): Promise<void> {
const tempDir = await mkdtemp(join(tmpdir(), "wmill_guidance_writer_"));
@@ -26,7 +33,7 @@ async function writeSkill(
return skillPath;
}
describe("writeAiGuidanceFiles", () => {
describe("writeAiGuidanceFiles — skills", () => {
test("preserves custom skills when refreshing generated guidance", async () => {
await withTempDir(async (tempDir) => {
const skillsDirs = SKILL_TARGET_ROOTS.map((root) =>
@@ -52,19 +59,21 @@ Preserve me.
)
);
await writeAiGuidanceFiles({
targetDir: tempDir,
overwriteProjectGuidance: false,
});
await writeAiGuidanceFiles({ targetDir: tempDir });
// Custom skills survive on every side, untouched.
for (const customSkillPath of customSkillPaths) {
expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent);
}
// Both `.agents/skills/` and `.claude/skills/` hold the same full
// canonical content. (Claude's skill loader doesn't expand `@`
// references inside SKILL.md, so we can't dedupe via `@`-include.)
for (const generatedSkillPath of generatedSkillPaths) {
const generatedSkillContent = await readFile(generatedSkillPath, "utf8");
expect(generatedSkillContent).not.toBe(staleGeneratedContent);
expect(generatedSkillContent).toContain("name: write-flow");
expect(generatedSkillContent).not.toContain("@../../../");
}
});
});
@@ -101,7 +110,7 @@ Copied from source bundle.
writeSkill(skillsDir, "custom-skill", customSkillContent)
)
);
const existingGeneratedSkillPaths = await Promise.all(
await Promise.all(
skillsDirs.map((skillsDir) =>
writeSkill(skillsDir, "write-flow", "old content")
)
@@ -113,25 +122,28 @@ Copied from source bundle.
await writeAiGuidanceFiles({
targetDir: tempDir,
overwriteProjectGuidance: false,
skillsSourcePath: sourceSkillsDir,
});
// Custom skills survive untouched on every side.
for (const customSkillPath of customSkillPaths) {
expect(await readFile(customSkillPath, "utf8")).toBe(customSkillContent);
}
for (const existingGeneratedSkillPath of existingGeneratedSkillPaths) {
expect(await readFile(existingGeneratedSkillPath, "utf8")).toBe(sourceSkillContent);
}
for (const skillsDir of skillsDirs) {
expect(await readFile(join(skillsDir, "bundle-only", "SKILL.md"), "utf8")).toBe(
bundleOnlySkillContent
);
// Source bundle is copied verbatim into both `.agents/skills/` and
// `.claude/skills/`. No `@`-include wrapping.
for (const root of SKILL_TARGET_ROOTS) {
expect(
await readFile(join(tempDir, root, "skills/write-flow/SKILL.md"), "utf8")
).toBe(sourceSkillContent);
expect(
await readFile(join(tempDir, root, "skills/bundle-only/SKILL.md"), "utf8")
).toBe(bundleOnlySkillContent);
}
});
});
test("builds AGENTS skill references from copied directory names", async () => {
test("AGENTS.cli.md gets the skills reference from copied directory names", async () => {
await withTempDir(async (tempDir) => {
const sourceSkillsDir = join(tempDir, "source-skills");
await writeSkill(
@@ -148,29 +160,393 @@ Copied from source bundle.
await writeAiGuidanceFiles({
targetDir: tempDir,
overwriteProjectGuidance: false,
skillsSourcePath: sourceSkillsDir,
});
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(agentsMd).toContain(".claude/skills/custom-folder/SKILL.md");
expect(agentsMd).not.toContain(".claude/skills/write-flow/SKILL.md");
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
expect(agentsCli).toContain(".agents/skills/custom-folder/SKILL.md");
expect(agentsCli).not.toContain(".agents/skills/write-flow/SKILL.md");
// The skill reference points at the .agents/ tree — not .claude/ —
// so the path is meaningful to Codex/Pi as well as Claude.
expect(agentsCli).not.toContain(".claude/skills/custom-folder/SKILL.md");
});
});
test("writes AGENTS.md and CLAUDE.md even if skills creation fails", async () => {
test("AGENTS.cli.md and CLAUDE.md are written even if skills creation fails", async () => {
await withTempDir(async (tempDir) => {
// Create a file at .claude so mkdir of .claude/skills throws.
await writeFile(join(tempDir, ".claude"), "not a directory\n", "utf8");
await expect(
writeAiGuidanceFiles({
targetDir: tempDir,
overwriteProjectGuidance: false,
})
writeAiGuidanceFiles({ targetDir: tempDir })
).rejects.toThrow();
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(".claude/skills/");
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain("@AGENTS.md");
expect(await readFile(join(tempDir, "AGENTS.cli.md"), "utf8")).toContain(
".agents/skills/"
);
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toContain(
"@AGENTS.cli.md"
);
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain(
"@AGENTS.md"
);
});
});
});
describe("writeAiGuidanceFiles — AGENTS.md reconciliation", () => {
test("creates a skeleton AGENTS.md (with @AGENTS.cli.md include) when none exists", async () => {
await withTempDir(async (tempDir) => {
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsCreated).toBe(true);
expect(result.agentsMigration).toBe("not-applicable");
const agentsMd = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(agentsMd).toContain("@AGENTS.cli.md");
});
});
test("leaves an existing AGENTS.md alone when it already references @AGENTS.cli.md", async () => {
await withTempDir(async (tempDir) => {
const original = "# My AGENTS.md\n\nlocal stuff\n\n@AGENTS.cli.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("already-linked");
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original);
});
});
test("appends @AGENTS.cli.md when the resolver returns 'append'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Existing custom AGENTS.md\n\nproject rules here.\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "append",
});
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("append");
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.cli.md");
});
});
test("overwrites AGENTS.md with the managed skeleton when the resolver returns 'overwrite'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Some old AGENTS.md to be replaced\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "overwrite",
});
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("overwrite");
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).not.toBe(original);
expect(updated).toContain("@AGENTS.cli.md");
});
});
test("leaves AGENTS.md untouched when the resolver returns 'skip'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Hand-managed AGENTS.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "skip",
});
expect(result.agentsCreated).toBe(false);
expect(result.agentsMigration).toBe("skip");
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(original);
});
});
test("defaults to 'append' when the resolver is not provided", async () => {
await withTempDir(async (tempDir) => {
const original = "# AGENTS.md\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsMigration).toBe("append");
const updated = await readFile(join(tempDir, "AGENTS.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.cli.md");
});
});
});
describe("writeAiGuidanceFiles — CLAUDE.md reconciliation", () => {
test("creates a skeleton CLAUDE.md (with @AGENTS.md include) when none exists", async () => {
await withTempDir(async (tempDir) => {
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.claudeCreated).toBe(true);
expect(result.claudeMigration).toBe("not-applicable");
const claudeMd = await readFile(join(tempDir, "CLAUDE.md"), "utf8");
expect(claudeMd).toContain("@AGENTS.md");
});
});
test("leaves an existing CLAUDE.md alone when it already references @AGENTS.md", async () => {
await withTempDir(async (tempDir) => {
const original = "# My CLAUDE.md\n\nlocal stuff\n\n@AGENTS.md\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("already-linked");
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original);
});
});
test("appends @AGENTS.md when the resolver returns 'append'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Existing custom CLAUDE.md\n\nBe helpful.\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "append",
});
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("append");
const updated = await readFile(join(tempDir, "CLAUDE.md"), "utf8");
expect(updated).toStartWith(original);
expect(updated).toContain("@AGENTS.md");
});
});
test("overwrites CLAUDE.md with the managed skeleton when the resolver returns 'overwrite'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Some old CLAUDE.md\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "overwrite",
});
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("overwrite");
expect(
await readFile(join(tempDir, "CLAUDE.md"), "utf8")
).not.toBe(original);
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toContain(
"@AGENTS.md"
);
});
});
test("leaves CLAUDE.md untouched when the resolver returns 'skip'", async () => {
await withTempDir(async (tempDir) => {
const original = "# Hand-managed CLAUDE.md\n";
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "skip",
});
expect(result.claudeCreated).toBe(false);
expect(result.claudeMigration).toBe("skip");
expect(await readFile(join(tempDir, "CLAUDE.md"), "utf8")).toBe(original);
});
});
test("resolver is invoked at most once even if both files need it", async () => {
await withTempDir(async (tempDir) => {
const original = "# old\n";
await writeFile(join(tempDir, "AGENTS.md"), original, "utf8");
await writeFile(join(tempDir, "CLAUDE.md"), original, "utf8");
let resolverCalls = 0;
await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => {
resolverCalls += 1;
return "append";
},
});
expect(resolverCalls).toBe(1);
});
});
});
describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () => {
test.each([
["bare line", "@AGENTS.cli.md"],
["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"],
["leading whitespace then include", " @AGENTS.cli.md\n"],
["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"],
])("treats %s as a reference (no append)", async (_label, content) => {
await withTempDir(async (tempDir) => {
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
const result = await writeAiGuidanceFiles({ targetDir: tempDir });
expect(result.agentsMigration).toBe("already-linked");
expect(await readFile(join(tempDir, "AGENTS.md"), "utf8")).toBe(content);
});
});
test.each([
["@AGENTS.cli.md.backup", "@AGENTS.cli.md.backup"],
["@AGENTS.cli.mdx", "@AGENTS.cli.mdx"],
["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"],
["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"],
["commented-out include", "<!-- @AGENTS.cli.md -->"],
["blockquoted include", "> @AGENTS.cli.md"],
])("does not treat %s as a reference (append happens)", async (_label, content) => {
await withTempDir(async (tempDir) => {
await writeFile(join(tempDir, "AGENTS.md"), content, "utf8");
const result = await writeAiGuidanceFiles({
targetDir: tempDir,
resolveAgentsMdMigration: async () => "append",
});
expect(result.agentsMigration).toBe("append");
});
});
});
describe("prompts freshness — hash marker", () => {
test("AGENTS.cli.md written by writeAiGuidanceFiles carries a hash marker", async () => {
await withTempDir(async (tempDir) => {
await writeAiGuidanceFiles({ targetDir: tempDir });
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
const hash = extractPromptsHash(agentsCli);
expect(hash).not.toBeNull();
expect(hash).toMatch(/^[0-9a-f]{12}$/);
});
});
test("the stored hash matches currentPromptsHash for the same nonDottedPaths", async () => {
await withTempDir(async (tempDir) => {
// writeAiGuidanceFiles defaults nonDottedPaths to `false` (matching
// core/conf.ts's missing-key default).
await writeAiGuidanceFiles({ targetDir: tempDir });
const agentsCli = await readFile(join(tempDir, "AGENTS.cli.md"), "utf8");
expect(extractPromptsHash(agentsCli)).toBe(currentPromptsHash(false));
});
});
test("nonDottedPaths setting changes the hash", () => {
expect(currentPromptsHash(true)).not.toBe(currentPromptsHash(false));
});
test("injectPromptsHashMarker places the marker after the H1 title", () => {
const input = "# Title\n\nbody line\n";
const out = injectPromptsHashMarker(input, "abc123def456");
const lines = out.split("\n");
expect(lines[0]).toBe("# Title");
expect(lines[1]).toBe("<!-- wmill-prompts-hash: abc123def456 -->");
expect(lines[2]).toBe("");
expect(lines[3]).toBe("body line");
});
test("injectPromptsHashMarker prepends when there's no H1", () => {
const input = "no heading\nrest\n";
const out = injectPromptsHashMarker(input, "abc123def456");
expect(out).toStartWith("<!-- wmill-prompts-hash: abc123def456 -->");
});
test("extractPromptsHash returns null when no marker is present", () => {
expect(extractPromptsHash("# Title\n\nno marker here\n")).toBeNull();
expect(extractPromptsHash("<!-- wmill-prompts-hash: tooshort -->")).toBeNull();
});
});
describe("prompts freshness — shouldRunFreshnessCheck", () => {
// Each input matches process.argv shape: [node, script, ...args].
test.each<[string, string[], boolean]>([
["empty argv", ["node", "wmill"], false],
["wmill --help", ["node", "wmill", "--help"], false],
["wmill -h on a subcommand", ["node", "wmill", "init", "-h"], false],
["wmill --version", ["node", "wmill", "--version"], false],
["wmill init", ["node", "wmill", "init"], false],
["wmill init prompts", ["node", "wmill", "init", "prompts"], false],
["wmill refresh prompts", ["node", "wmill", "refresh", "prompts"], false],
["wmill completions zsh", ["node", "wmill", "completions", "zsh"], false],
["wmill upgrade", ["node", "wmill", "upgrade"], false],
["wmill sync push", ["node", "wmill", "sync", "push"], true],
["wmill flow run", ["node", "wmill", "flow", "run"], true],
["wmill --verbose sync push", ["node", "wmill", "--verbose", "sync", "push"], true],
// Value-taking global options must skip their value when locating the
// first subcommand. Otherwise `wmill --workspace prod refresh prompts`
// would misread `"prod"` as the subcommand and trip the warning during
// the very command that's meant to fix it.
["wmill --workspace prod refresh prompts",
["node", "wmill", "--workspace", "prod", "refresh", "prompts"], false],
["wmill --token tok sync push",
["node", "wmill", "--token", "tok", "sync", "push"], true],
["wmill --base-url u --workspace w init",
["node", "wmill", "--base-url", "u", "--workspace", "w", "init"], false],
["wmill --config-dir /etc/wmill init prompts",
["node", "wmill", "--config-dir", "/etc/wmill", "init", "prompts"], false],
])("%s → %s", (_label, argv, expected) => {
expect(shouldRunFreshnessCheck(argv)).toBe(expected);
});
});
describe("prompts freshness — additional invariants", () => {
test("currentPromptsHash is deterministic across invocations in the same process", () => {
const h1 = currentPromptsHash(true);
const h2 = currentPromptsHash(true);
const h3 = currentPromptsHash(false);
const h4 = currentPromptsHash(false);
expect(h1).toBe(h2);
expect(h3).toBe(h4);
});
test("warnIfPromptsStale writes to stderr (never stdout)", async () => {
await withTempDir(async (tempDir) => {
// Write a tampered AGENTS.cli.md so the freshness check trips.
await writeFile(
join(tempDir, "AGENTS.cli.md"),
"# Windmill CLI Agent Instructions\n<!-- wmill-prompts-hash: 000000000000 -->\nbody\n",
"utf8"
);
const stdoutWrites: string[] = [];
const stderrWrites: string[] = [];
const originalStdout = process.stdout.write.bind(process.stdout);
const originalStderr = process.stderr.write.bind(process.stderr);
// @ts-expect-error — overriding write for the test
process.stdout.write = (chunk: any) => {
stdoutWrites.push(String(chunk));
return true;
};
// @ts-expect-error — overriding write for the test
process.stderr.write = (chunk: any) => {
stderrWrites.push(String(chunk));
return true;
};
try {
await warnIfPromptsStale({
cwd: tempDir,
nonDottedPaths: false,
argv: ["node", "wmill", "sync", "push"],
});
} finally {
process.stdout.write = originalStdout;
process.stderr.write = originalStderr;
}
const stderrJoined = stderrWrites.join("");
const stdoutJoined = stdoutWrites.join("");
expect(stderrJoined).toContain("out of date");
expect(stdoutJoined).not.toContain("out of date");
});
});
});
+8
View File
@@ -51,6 +51,14 @@ COPY dap_websocket_server.py .
# Expose the default port
EXPOSE 5679
# Create a non-root user 'windmill' with UID and GID 1000 (mirrors main Windmill image)
RUN addgroup --gid 1000 windmill && \
adduser --disabled-password --gecos "" --uid 1000 --gid 1000 windmill
# Ensure cache and work directories are writable by any UID
RUN mkdir -p /tmp/windmill/cache /tmp/windmill/cache_nomount /tmp/.cache && \
chmod -R 777 /tmp/windmill /tmp/.cache /app
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5679/health || exit 1
+9 -4
View File
@@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail
WORKDIR /nsjail
RUN apt-get -y update \
&& apt-get install -y \
&& apt-get install -y --no-install-recommends \
bison=2:3.8.* \
ca-certificates \
flex=2.6.* \
g++=4:12.2.* \
gcc=4:12.2.* \
@@ -15,7 +16,9 @@ RUN apt-get -y update \
libnl-route-3-dev=3.7.* \
make=4.3-4.1 \
pkg-config=1.8.* \
protobuf-compiler=3.21.*
protobuf-compiler=3.21.* \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
RUN make
@@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
@@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \
COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+9 -4
View File
@@ -5,8 +5,9 @@ FROM debian:bookworm-slim AS nsjail
WORKDIR /nsjail
RUN apt-get -y update \
&& apt-get install -y \
&& apt-get install -y --no-install-recommends \
bison=2:3.8.* \
ca-certificates \
flex=2.6.* \
g++=4:12.2.* \
gcc=4:12.2.* \
@@ -15,7 +16,9 @@ RUN apt-get -y update \
libnl-route-3-dev=3.7.* \
make=4.3-4.1 \
pkg-config=1.8.* \
protobuf-compiler=3.21.*
protobuf-compiler=3.21.* \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN git clone -b master --single-branch https://github.com/google/nsjail.git . && git checkout dccf911fd2659e7b08ce9507c25b2b38ec2c5800
RUN make
@@ -36,7 +39,8 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get install -y ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install latest PostgreSQL client (pg_dump) from official PostgreSQL apt repository
@@ -78,7 +82,8 @@ RUN curl -fsSL https://claude.ai/install.sh | bash \
COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/
# nsjail runtime deps and binary
RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \
RUN apt-get update \
&& apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY --from=nsjail /nsjail/nsjail /bin/nsjail
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.705.0",
"name": "@windmill-labs/components",
"version": "1.708.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.705.0",
"name": "@windmill-labs/components",
"version": "1.708.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.705.0",
"name": "@windmill-labs/components",
"version": "1.708.0",
"scripts": {
"dev": "vite dev",
"build": "vite build",
-2
View File
@@ -1,4 +1,2 @@
npm run package
sed -i -e 's/windmill/windmill-components/g' package.json
npm publish
sed -i -e 's/windmill-components/windmill/g' package.json
@@ -406,6 +406,15 @@
if (nullable && emptyString(v)) {
error = ''
valid && (valid = true)
} else if (
typeof v === 'string' &&
(v.startsWith('$var:') || v.startsWith('$res:') || v.startsWith('$jsonvar:'))
) {
// $var/$res/$jsonvar are placeholders resolved at runtime; the literal
// string won't match format constraints (email/ipv4/uuid/custom pattern),
// so format-checking it produces a false-positive "invalid format" error.
error = ''
!valid && (valid = true)
} else if (required && (v == undefined || v == null || v === '') && inputCat != 'object') {
error = 'Required'
valid && (valid = false)
@@ -76,8 +76,8 @@
>
<FlowGraphV2
{triggerNode}
earlyStop={flow.value.skip_expr !== undefined}
cache={flow.value.cache_ttl !== undefined}
earlyStop={flow?.value?.skip_expr !== undefined}
cache={flow?.value?.cache_ttl !== undefined}
path={flow?.path}
{download}
minHeight={fillAvailableHeight ? Math.max(minHeight, availableHeight) : minHeight}
@@ -113,7 +113,7 @@
noGraph ? 'border-0 w-max' : ''
)}
>
<FlowGraphViewerStep schema={flow.schema} {stepDetail} {hideDefaultInputs} />
<FlowGraphViewerStep schema={flow?.schema} {stepDetail} {hideDefaultInputs} />
</div>
{/if}
</div>
@@ -295,13 +295,21 @@
{#each githubState.workspaceGithubInstallations as installation (`current-${installation.installation_id}-${installation.workspace_id}`)}
<tr class="border-t border-gray-200 dark:border-gray-700">
<td class="py-2">
<div class="flex items-center gap-1">
<div class="flex items-center gap-1 flex-wrap">
{#if installation.error}
<span title={installation.error}>
<AlertTriangle class="w-4 h-4 text-yellow-500" />
</span>
{/if}
{installation.account_id}
{#if installation.provisioned_by_admin}
<span
class="text-2xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
title="Assigned by the instance super-admin from instance settings. Only the super-admin can remove it."
>
Provisioned by admin
</span>
{/if}
</div>
</td>
<td class="py-2">
@@ -310,34 +318,41 @@
</td>
<td class="py-2 text-primary">
{#if installation.error}
<span class="text-yellow-600 dark:text-yellow-400 text-xs" title={installation.error}>Token error</span>
<span
class="text-yellow-600 dark:text-yellow-400 text-xs"
title={installation.error}>Token error</span
>
{:else}
{installation.repositories.length} repos
{/if}
</td>
<td class="py-2 text-right">
<div class="flex justify-end gap-1">
<Button
size="xs2"
variant="accent"
title="Export installation to other instance"
startIcon={{ icon: Download }}
on:click={() =>
handleExportInstallation(installation.installation_id)}
>
Export
</Button>
<Button
size="xs2"
variant="default"
destructive
title="Remove installation from workspace"
startIcon={{ icon: Minus }}
on:click={() =>
handleDeleteInstallation(installation.installation_id)}
>
Remove
</Button>
{#if !installation.github_base_url}
<Button
size="xs2"
variant="accent"
title="Export installation to other instance"
startIcon={{ icon: Download }}
on:click={() =>
handleExportInstallation(installation.installation_id)}
>
Export
</Button>
{/if}
{#if !installation.provisioned_by_admin}
<Button
size="xs2"
variant="default"
destructive
title="Remove installation from workspace"
startIcon={{ icon: Minus }}
on:click={() =>
handleDeleteInstallation(installation.installation_id)}
>
Remove
</Button>
{/if}
</div>
</td>
</tr>
@@ -381,7 +396,10 @@
</td>
<td class="py-2 text-primary">
{#if installation.error}
<span class="text-yellow-600 dark:text-yellow-400 text-xs" title={installation.error}>Token error</span>
<span
class="text-yellow-600 dark:text-yellow-400 text-xs"
title={installation.error}>Token error</span
>
{:else}
{installation.repositories.length} repos
{/if}
@@ -414,26 +432,28 @@
</div>
</div>
<div class="mt-4 flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary"
>Import installation from other instance:</p
>
<div class="flex gap-2">
<input
type="text"
placeholder="Paste JWT token here"
bind:value={githubState.importJwt}
class="flex-1"
/>
<Button
variant="accent"
on:click={handleImportInstallation}
disabled={!githubState.importJwt}
{#if !githubState.isGhesSelfManaged}
<div class="mt-4 flex flex-col gap-2">
<p class="text-sm font-semibold text-secondary"
>Import installation from other instance:</p
>
Import
</Button>
<div class="flex gap-2">
<input
type="text"
placeholder="Paste JWT token here"
bind:value={githubState.importJwt}
class="flex-1"
/>
<Button
variant="accent"
on:click={handleImportInstallation}
disabled={!githubState.importJwt}
>
Import
</Button>
</div>
</div>
</div>
{/if}
</div>
</div>
{/snippet}
@@ -72,6 +72,20 @@
return false
}
}
// Hide the nsjail-only settings only when isolation is *explicitly* a
// non-nsjail mode. When `job_isolation` is unset, nsjail may still be
// enabled via the legacy env-driven path (`DISABLE_NSJAIL=false`), so
// keep the controls reachable.
if (setting == 'nsjail_tmp_backing' || setting == 'nsjail_tmpfs_size_mb') {
const isolation = values['job_isolation']
if (isolation === 'none' || isolation === 'unshare') {
return false
}
}
// The tmpfs size knob is meaningless when /tmp is disk-backed.
if (setting == 'nsjail_tmpfs_size_mb' && values['nsjail_tmp_backing'] === 'disk') {
return false
}
return true
}
@@ -36,7 +36,7 @@
onChange,
defaultValues = undefined,
workspace = undefined,
selected = $bindable()
selected: selectedProp = $bindable()
}: Props = $props()
type ResourceState = {
@@ -50,6 +50,10 @@
const dispatch = createEventDispatcher()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
// Fallback to `effectiveWorkspace` insulates against reactify-style
// parents that re-spread props without `selected` — otherwise it
// transiently resets and the form below remounts on every keystroke.
let selected = $derived(selectedProp ?? effectiveWorkspace)
let initialPath = path
// Per-workspace handles are driven by `useMany`. We track the workspace
@@ -205,27 +209,25 @@
})
)
// Bootstrap: ensure selected is set on mount (edit or new)
// New-resource bootstrap: seed empty state per workspace (edit mode
// is seeded by the lazy-fetch effect below).
$effect(() => {
if (selected !== undefined) return
if (!effectiveWorkspace) return
if (!selected) return
if (initialPath) return
if (selected in initialStates) return
untrack(() => {
selected = effectiveWorkspace
if (!initialPath) {
// New resource
const s: ResourceState = {
path: '',
description: '',
args: (defaultValues && Object.keys(defaultValues).length > 0
? defaultValues
: {}) as any,
labels: undefined,
wsSpecific: false
}
ensureHandle(effectiveWorkspace, s)
initialStates[effectiveWorkspace] = structuredClone(s)
existedInitially[effectiveWorkspace] = false
const s: ResourceState = {
path: '',
description: '',
args: (defaultValues && Object.keys(defaultValues).length > 0
? defaultValues
: {}) as any,
labels: undefined,
wsSpecific: false
}
ensureHandle(selected, s)
initialStates[selected] = structuredClone(s)
existedInitially[selected] = false
})
})
@@ -329,7 +331,14 @@
$effect(() => {
if (current)
onChange?.({ path: current.path, args: current.args, description: current.description })
// $state.snapshot deep-reads (so the effect re-runs on nested
// args mutations) and returns a plain object (React consumers
// can't diff a $state proxy by reference or JSON.stringify).
onChange?.({
path: current.path,
args: $state.snapshot(current.args) as Record<string, any>,
description: current.description
})
})
$effect(() => {
@@ -4,7 +4,10 @@
import AvailableContextList from './AvailableContextList.svelte'
import { type Snippet } from 'svelte'
import {
AlertTriangle,
ArrowDown,
ChevronDown,
ChevronsRight,
CheckIcon,
HistoryIcon,
Hourglass,
@@ -23,16 +26,43 @@
import ProviderModelSelector from './ProviderModelSelector.svelte'
import ChatMode from './ChatMode.svelte'
import DatatableCreationPolicy from './DatatableCreationPolicy.svelte'
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
import Markdown from 'svelte-exmarkdown'
import { twMerge } from 'tailwind-merge'
import { AIMode } from './AIChatManager.svelte'
import { AIAutonomyMode, AIMode } from './AIChatManager.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import ChatTypingIndicator from './ChatTypingIndicator.svelte'
import AIChatInput from './AIChatInput.svelte'
import { getModifierKey } from '$lib/utils'
import type { SelectedContext } from './app/core'
const MAX_YOLO_TOOLTIP_TOOLS = 8
const aiChatManager = getAiChatManager()
type AutonomyModeOption = { label: string; mode: AIAutonomyMode }
const autonomyModeOptions: AutonomyModeOption[] = [
{ label: 'auto accept off', mode: AIAutonomyMode.DEFAULT },
{ label: 'auto accept on', mode: AIAutonomyMode.ACCEPT_EDIT },
{ label: 'yolo on', mode: AIAutonomyMode.YOLO }
]
const autonomyModeLabel = (
mode: AIAutonomyMode,
options: AutonomyModeOption[] = autonomyModeOptions
) => options.find((option) => option.mode === mode)?.label ?? autonomyModeOptions[0].label
const isAutonomyModeAvailable = (
mode: AIAutonomyMode,
autoAcceptEditsAvailable: boolean,
autoAcceptToolConfirmationsAvailable: boolean
) => {
switch (mode) {
case AIAutonomyMode.DEFAULT:
return true
case AIAutonomyMode.ACCEPT_EDIT:
return autoAcceptEditsAvailable
case AIAutonomyMode.YOLO:
return autoAcceptToolConfirmationsAvailable
}
return false
}
let {
messages,
@@ -179,6 +209,37 @@
aiChatManager.mode === AIMode.GLOBAL ||
aiChatManager.mode === AIMode.APP
)
const availableAutonomyModeOptions = $derived.by(() =>
autonomyModeOptions.filter((option) =>
isAutonomyModeAvailable(
option.mode,
aiChatManager.autoAcceptEditsAvailable,
aiChatManager.autoAcceptToolConfirmationsAvailable
)
)
)
const effectiveAutonomyMode = $derived(
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
? aiChatManager.autonomyMode
: AIAutonomyMode.DEFAULT
)
const showAutonomyModeSelector = $derived(!disabled && availableAutonomyModeOptions.length > 1)
const autonomyModeTooltip = $derived.by(() => {
switch (effectiveAutonomyMode) {
case AIAutonomyMode.ACCEPT_EDIT:
return 'Automatically accepts script and flow edits. Tool calls still ask for confirmation.'
case AIAutonomyMode.YOLO:
if (!aiChatManager.autoAcceptEditsAvailable) {
return 'Automatically accepts tool confirmations.'
}
return 'Automatically accepts script and flow edits plus tool confirmations.'
default:
if (!aiChatManager.autoAcceptEditsAvailable) {
return 'Requires confirmation for tool calls.'
}
return 'Requires confirmation for edits and tool calls.'
}
})
// "Waiting for user" detection — when the latest tool message is staged
// for confirmation or has an unanswered askUserQuestion, the AI loop is
@@ -209,6 +270,29 @@
}
return aiChatManager.appAiChatHelpers.getSelectedContext()
})
const yoloBypassedTools = $derived.by(() => {
return aiChatManager.tools
.filter((tool) => tool.requiresConfirmation === true)
.map((tool) => ({
name: tool.def.function.name,
label: tool.confirmationMessage ?? tool.def.function.name
}))
})
const visibleYoloBypassedTools = $derived(yoloBypassedTools.slice(0, MAX_YOLO_TOOLTIP_TOOLS))
const hiddenYoloBypassedToolCount = $derived(
Math.max(0, yoloBypassedTools.length - visibleYoloBypassedTools.length)
)
const showFlowPendingActionControls = $derived(
(aiChatManager.flowAiChatHelpers?.hasPendingChanges() ?? false) &&
!aiChatManager.autoAcceptEditsActive
)
const showFooterLeftControls = $derived(
!disabled &&
(showContextPicker ||
showAutonomyModeSelector ||
(aiChatManager.mode === AIMode.SCRIPT && hasDiff))
)
</script>
<div class="flex flex-col h-full">
@@ -322,7 +406,7 @@
<div
class={twMerge(
'sticky z-10 mt-2 ml-2 self-start pointer-events-none',
aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-14' : 'bottom-2'
showFlowPendingActionControls ? 'bottom-14' : 'bottom-2'
)}
>
{#if waitingForUserAction}
@@ -345,7 +429,7 @@
transition:fade={{ duration: 120 }}
class={twMerge(
'absolute left-1/2 -translate-x-1/2 z-10 rounded-md bg-surface shadow-md',
aiChatManager.flowAiChatHelpers?.hasPendingChanges() ? 'bottom-12' : 'bottom-2'
showFlowPendingActionControls ? 'bottom-12' : 'bottom-2'
)}
>
<Button
@@ -370,7 +454,7 @@
? 'relative w-full max-w-3xl mx-auto px-6 pb-2'
: 'relative w-full max-w-2xl mx-auto px-2 pb-2'}
>
{#if aiChatManager.flowAiChatHelpers?.hasPendingChanges()}
{#if showFlowPendingActionControls}
<div class="absolute -top-10 w-full flex flex-row justify-center gap-2">
<Button
startIcon={{ icon: CheckIcon }}
@@ -409,49 +493,136 @@
{disabled}
isFirstMessage={messages.length === 0}
/>
<div class="flex flex-row justify-between items-center gap-x-1.5">
<div class="flex flex-row items-center gap-x-1.5">
{#if showContextPicker && !disabled}
<Popover>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
title="Add context"
>
@
</div>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{:else}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{/if}
{#if aiChatManager.mode === 'script' && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
<div
class="flex flex-row items-center gap-x-1.5"
class:justify-between={showFooterLeftControls}
class:justify-end={!showFooterLeftControls}
>
{#if showFooterLeftControls}
<div class="flex flex-row items-center gap-x-1.5 min-w-0 flex-wrap">
{#if showContextPicker && !disabled}
<Popover>
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal border px-1 rounded-lg hover:bg-surface-hover bg-surface"
title="Add context"
>
@
</div>
{/snippet}
{#snippet content({ close })}
{#if aiChatManager.mode === AIMode.APP}
<AppAvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{:else}
<AvailableContextList
{availableContext}
{selectedContext}
onSelect={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
onSelectWorkspaceItem={(element) => {
void aiChatInput?.addContextToSelection(element)
close()
}}
/>
{/if}
{/snippet}
</Popover>
{/if}
{#if showAutonomyModeSelector}
<div class="min-w-0">
<Popover class="max-w-full">
{#snippet trigger()}
<div
class="text-primary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
title={autonomyModeTooltip}
>
<ChevronsRight
size={13}
class={twMerge(
'shrink-0',
effectiveAutonomyMode === AIAutonomyMode.YOLO
? 'text-red-500'
: 'text-accent'
)}
/>
<span class="truncate"
>{autonomyModeLabel(
effectiveAutonomyMode,
availableAutonomyModeOptions
)}</span
>
<div class="shrink-0">
<ChevronDown size={16} />
</div>
</div>
{/snippet}
{#snippet content({ close })}
<div class="flex flex-col gap-1 p-1 min-w-32">
{#each availableAutonomyModeOptions as option (option.mode)}
<button
class={twMerge(
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
effectiveAutonomyMode === option.mode && 'bg-surface-hover'
)}
onclick={() => {
aiChatManager.setAutonomyMode(option.mode)
close()
}}
>
{option.label}
</button>
{/each}
</div>
{/snippet}
</Popover>
</div>
{/if}
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
<Tooltip small placement="top">
<AlertTriangle class="w-3 h-3 text-red-500" />
{#snippet text()}
<div class="max-w-64 text-xs">
<p class="font-semibold">
{aiChatManager.autoAcceptEditsAvailable
? 'Yolo auto-accepts edits and tool usage.'
: 'Yolo auto-accepts tool usage.'}
</p>
<p class="mt-1">
{aiChatManager.autoAcceptEditsAvailable
? 'This can result in edits being applied or tools being called without user confirmation.'
: 'This can result in tools being called without user confirmation.'}
</p>
{#if yoloBypassedTools.length > 0}
<p class="mt-2 font-semibold">Bypassed in current mode:</p>
<ul class="mt-1 list-disc pl-4 space-y-0.5">
{#each visibleYoloBypassedTools as tool (tool.name)}
<li class="break-words">{tool.label}</li>
{/each}
</ul>
{#if hiddenYoloBypassedToolCount > 0}
<p class="mt-1">+ {hiddenYoloBypassedToolCount} more</p>
{/if}
{:else}
<p class="mt-2">No tools in the current mode require confirmation.</p>
{/if}
</div>
{/snippet}
</Tooltip>
{/if}
{#if aiChatManager.mode === AIMode.SCRIPT && hasDiff}
<ChatQuickActions {askAi} {diffMode} />
{/if}
</div>
{/if}
{#if disabled}
<div class="text-primary text-xs my-2 px-2">
<Markdown md={disabledMessage} />
@@ -213,7 +213,7 @@
try {
const reply = await aiChatManager.sendInlineRequest(instructions, selectedCode, selection)
if (reply) {
aiChatManager.scriptEditorApplyCode?.(reply)
await aiChatManager.applyScriptEditorCode(reply)
}
} catch (error) {
console.error('Inline AI request failed:', error)

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