mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-24 00:00:46 +00:00
Merge remote-tracking branch 'origin/main' into gl/layout-ai
This commit is contained in:
@@ -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}`
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+40
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+20
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+35
@@ -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"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM skip_workspace_diff_tally",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8eb5866b6279cb386bbeb7c387a7c731317199b28694a9c3e04028ef1f1d7500"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+12
@@ -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"
|
||||
}
|
||||
Generated
+86
-86
@@ -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
@@ -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 @@
|
||||
daffe7bb81cfcaca666c61de1ee838a44d60ebc2
|
||||
da5189cf69a453de3855057f41be0d84e5910707
|
||||
|
||||
+24
-24
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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(),
|
||||
)?;
|
||||
|
||||
@@ -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()),
|
||||
)?;
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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),
|
||||
)?;
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
`;
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+4
-4
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user