mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 16:03:24 +00:00
Merge branch 'main' into feat/asset-graph-view
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
name: Check fixture is empty
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "fixtures/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "fixtures/**"
|
||||
|
||||
jobs:
|
||||
check-empty-fixture:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Ensure fixtures/cli-sync/ has no committed snapshot
|
||||
run: bash fixtures/check-empty.sh
|
||||
@@ -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,50 @@
|
||||
# Changelog
|
||||
|
||||
## [1.709.0](https://github.com/windmill-labs/windmill/compare/v1.708.0...v1.709.0) (2026-05-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add copy button to Path component ([#9311](https://github.com/windmill-labs/windmill/issues/9311)) ([98bd5e7](https://github.com/windmill-labs/windmill/commit/98bd5e7f2a437b8b534028838b6ed0d7c59f7011))
|
||||
* **ai-chat:** align footer bar + DropdownV2 mode/autonomy selectors ([#9308](https://github.com/windmill-labs/windmill/issues/9308)) ([2f50e8b](https://github.com/windmill-labs/windmill/commit/2f50e8bab0b5ae9ae297c79abfe96df441f405e2))
|
||||
* **ai-chat:** expand chat question answers ([#9310](https://github.com/windmill-labs/windmill/issues/9310)) ([3f219ae](https://github.com/windmill-labs/windmill/commit/3f219aed98d93158aefce01bb51ed12dcb4711a1))
|
||||
* plug global chat drafts into userdraft ([#9291](https://github.com/windmill-labs/windmill/issues/9291)) ([1eef531](https://github.com/windmill-labs/windmill/commit/1eef53170b1b2afb75b9812e33787d1f28cf50dd))
|
||||
* **raw_apps:** surface UI Builder build errors over the preview pane ([#9316](https://github.com/windmill-labs/windmill/issues/9316)) ([90a196d](https://github.com/windmill-labs/windmill/commit/90a196d8d81993ffc2377d7088ab98f7b0f5ddcc))
|
||||
* **raw_apps:** tab-based editor surface with split-with-preview ([#9273](https://github.com/windmill-labs/windmill/issues/9273)) ([368e677](https://github.com/windmill-labs/windmill/commit/368e6774194a58058f28d1b4a42f8f4a7ec4ab63))
|
||||
* **service-accounts:** allow choosing role at creation time ([#9307](https://github.com/windmill-labs/windmill/issues/9307)) ([b125eca](https://github.com/windmill-labs/windmill/commit/b125eca7628b07c071bd102b161d389259fd6c62))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **auth:** filter resource/variable listings by token scope (WIN-1981) ([#9302](https://github.com/windmill-labs/windmill/issues/9302)) ([b5a0d46](https://github.com/windmill-labs/windmill/commit/b5a0d46695fdfe692d64573d1cfa06511e3b33f5))
|
||||
* **jobs:** authorization bypass in only_result job updates (WIN-1980) ([#9301](https://github.com/windmill-labs/windmill/issues/9301)) ([108a88a](https://github.com/windmill-labs/windmill/commit/108a88a1801548c8570d56aa3e1eb80246367bf4))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -10,10 +10,6 @@ import { runSuite } from "../../core/runSuite";
|
||||
import type { BenchmarkRunResult, ModeRunner } from "../../core/types";
|
||||
import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettings";
|
||||
import { emitFrontendBenchmarkProgress } from "./progress";
|
||||
import { createAppModeRunner } from "../../modes/app";
|
||||
import { createFlowModeRunner } from "../../modes/flow";
|
||||
import { createGlobalModeRunner } from "../../modes/global";
|
||||
import { createScriptModeRunner } from "../../modes/script";
|
||||
import { DEFAULT_JUDGE_MODEL } from "../../core/judge";
|
||||
|
||||
export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global";
|
||||
@@ -40,7 +36,7 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
|
||||
const backendSettings = resolveWindmillBackendSettings();
|
||||
|
||||
const selectedCases = await loadSelectedCases(mode, caseIds);
|
||||
const modeRunner = getModeRunner(
|
||||
const modeRunner = await getModeRunner(
|
||||
mode,
|
||||
getFrontendEvalModel(model),
|
||||
backendValidation,
|
||||
@@ -69,25 +65,33 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
|
||||
});
|
||||
}
|
||||
|
||||
function getModeRunner(
|
||||
async function getModeRunner(
|
||||
mode: FrontendBenchmarkMode,
|
||||
model: ReturnType<typeof getFrontendEvalModel>,
|
||||
backendValidation: ReturnType<typeof resolveBackendValidationSettings>,
|
||||
backendSettings: ReturnType<typeof resolveWindmillBackendSettings>,
|
||||
): ModeRunner<any, any, any> {
|
||||
): Promise<ModeRunner<any, any, any>> {
|
||||
switch (mode) {
|
||||
case "flow":
|
||||
case "flow": {
|
||||
const { createFlowModeRunner } = await import("../../modes/flow");
|
||||
return createFlowModeRunner(model, backendValidation, backendSettings);
|
||||
case "app":
|
||||
}
|
||||
case "app": {
|
||||
const { createAppModeRunner } = await import("../../modes/app");
|
||||
return createAppModeRunner(model, backendSettings);
|
||||
case "script":
|
||||
}
|
||||
case "script": {
|
||||
const { createScriptModeRunner } = await import("../../modes/script");
|
||||
return createScriptModeRunner(
|
||||
model,
|
||||
backendValidation,
|
||||
backendSettings,
|
||||
);
|
||||
case "global":
|
||||
}
|
||||
case "global": {
|
||||
const { createGlobalModeRunner } = await import("../../modes/global");
|
||||
return createGlobalModeRunner(model, backendSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT operator, is_admin FROM usr WHERE email = $1 AND is_service_account IS true LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "operator",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "is_admin",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0cca68f11329cd41ab9372297b715af008ea7db408f49cf525656d6224092429"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "login_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "super_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "devops",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "company",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "operator_only",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_workspace_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "first_time_user",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "role_source",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1d0341bd8de94ab8d34a4bb1bb2005305fb3b29751ec987bece183444e89e7d1"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH capped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.capped'\n UNION ALL\n SELECT timestamp, operation, resource, parameters\n FROM audit\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.capped'\n ORDER BY timestamp DESC\n LIMIT 200\n ), uncapped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.uncapped'\n UNION ALL\n SELECT timestamp, operation, resource, parameters\n FROM audit\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.uncapped'\n ORDER BY timestamp DESC\n LIMIT 200\n )\n SELECT timestamp AS \"timestamp!\",\n operation::text AS \"operation!\",\n resource AS workspace_id,\n parameters\n FROM (SELECT * FROM capped UNION ALL SELECT * FROM uncapped) e\n ORDER BY timestamp DESC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "timestamp!",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operation!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "parameters",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a"
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, NULL::bool as is_workspace_admin, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", operator as operator_only, is_admin as is_workspace_admin, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operator_only",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "is_workspace_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "login_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "verified!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "super_admin!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "devops!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "company",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "first_time_user!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "role_source!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "disabled!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000"
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, operator as operator_only, is_admin as is_workspace_admin, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "login_type",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "verified!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "super_admin!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "devops!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "company",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "username",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "operator_only",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "is_workspace_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "first_time_user!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "role_source!",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "disabled!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 13,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646"
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, $4, $5, true)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28"
|
||||
}
|
||||
+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"
|
||||
}
|
||||
Generated
+112
-99
@@ -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"
|
||||
@@ -1837,18 +1837,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "btoi"
|
||||
version = "0.4.3"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad"
|
||||
checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[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",
|
||||
]
|
||||
@@ -5367,6 +5367,11 @@ name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashify"
|
||||
@@ -6721,9 +6726,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
version = "0.4.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
|
||||
|
||||
[[package]]
|
||||
name = "loom"
|
||||
@@ -6759,6 +6764,15 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru"
|
||||
version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
@@ -7197,26 +7211,26 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mysql_async"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1d9585dc9058886ff3a1f48a23024dd1d054264dee7c5ae0e4bd640c953bee5"
|
||||
checksum = "3519e91b0d254ac1ffa495bc42053286cb2172ad7241d5b3b1b9f8a891f21ee2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"crossbeam-queue",
|
||||
"crossbeam-utils",
|
||||
"flate2",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"keyed_priority_queue",
|
||||
"lru 0.16.4",
|
||||
"lru 0.18.0",
|
||||
"mysql_common",
|
||||
"native-tls",
|
||||
"pem 3.0.6",
|
||||
"percent-encoding",
|
||||
"rand 0.9.0",
|
||||
"rand 0.10.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
@@ -7227,9 +7241,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mysql_common"
|
||||
version = "0.35.5"
|
||||
version = "0.37.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbb9f371618ce723f095c61fbcdc36e8936956d2b62832f9c7648689b338e052"
|
||||
checksum = "4b42ced54aa8ac97226486337973f9bc3956e24f03a23e88a6e18f640959d6e2"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bitflags 2.11.1",
|
||||
@@ -13788,7 +13802,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -13869,12 +13883,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-ai"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"aws-config",
|
||||
"aws-credential-types",
|
||||
"aws-sdk-bedrock",
|
||||
"aws-sdk-bedrockruntime",
|
||||
"aws-smithy-types",
|
||||
"base64 0.22.1",
|
||||
@@ -13901,7 +13916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-alerting"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -13914,7 +13929,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -13923,13 +13938,8 @@ dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"async_zip",
|
||||
"aws-config",
|
||||
"aws-credential-types",
|
||||
"aws-sdk-bedrock",
|
||||
"aws-sdk-bedrockruntime",
|
||||
"aws-sdk-config",
|
||||
"aws-sigv4",
|
||||
"aws-smithy-types",
|
||||
"axum 0.8.9",
|
||||
"base32",
|
||||
"base64 0.22.1",
|
||||
@@ -14057,7 +14067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-agent-workers"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14080,7 +14090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-assets"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14093,7 +14103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-auth"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14119,7 +14129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-client"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
@@ -14129,7 +14139,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-configs"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14146,7 +14156,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-debug"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
@@ -14168,7 +14178,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-embeddings"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14191,7 +14201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flow-conversations"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14207,7 +14217,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-flows"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14228,7 +14238,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-groups"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14249,7 +14259,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-inputs"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14263,7 +14273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-integration-tests"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -14295,7 +14305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-jobs"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14320,7 +14330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-npm-proxy"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"flate2",
|
||||
@@ -14338,7 +14348,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-openapi"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14360,7 +14370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-schedule"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14380,7 +14390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-scripts"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14410,7 +14420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-settings"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14438,7 +14448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-sse"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"serde",
|
||||
@@ -14450,7 +14460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-users"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum 0.8.9",
|
||||
@@ -14475,7 +14485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workers"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14489,7 +14499,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-api-workspaces"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"axum 0.8.9",
|
||||
"chrono",
|
||||
@@ -14522,7 +14532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-audit"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"lazy_static",
|
||||
@@ -14536,7 +14546,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-autoscaling"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum 0.8.9",
|
||||
@@ -14555,7 +14565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"aho-corasick",
|
||||
@@ -14656,7 +14666,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-dep-map"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"itertools 0.14.0",
|
||||
@@ -14675,7 +14685,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-git-sync"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"regex",
|
||||
"serde",
|
||||
@@ -14690,7 +14700,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-indexer"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"astral-tokio-tar",
|
||||
@@ -14714,7 +14724,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-jseval"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14731,7 +14741,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
@@ -14747,7 +14757,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-mcp"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14768,7 +14778,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-native-triggers"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -14799,7 +14809,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-oauth"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
@@ -14824,7 +14834,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-object-store"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -14858,7 +14868,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-operator"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -14876,7 +14886,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"convert_case 0.6.0",
|
||||
"serde",
|
||||
@@ -14885,7 +14895,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14897,7 +14907,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14909,7 +14919,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -14921,7 +14931,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -14933,7 +14943,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -14945,7 +14955,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -14956,7 +14966,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14967,7 +14977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -14979,7 +14989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -14990,7 +15000,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15012,7 +15022,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -15024,7 +15034,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15038,7 +15048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case 0.6.0",
|
||||
@@ -15055,7 +15065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15068,7 +15078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15080,7 +15090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -15098,7 +15108,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -15114,7 +15124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -15130,7 +15140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -15141,7 +15151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-queue"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15160,6 +15170,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"prometheus",
|
||||
"quick_cache",
|
||||
"rand 0.9.0",
|
||||
"regex",
|
||||
"reqwest 0.13.1",
|
||||
"serde",
|
||||
@@ -15178,7 +15189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-runtime-nativets"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"const_format",
|
||||
@@ -15216,7 +15227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-sql-datatype-parser-wasm"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"wasm-bindgen",
|
||||
@@ -15227,7 +15238,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-store"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -15257,7 +15268,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-test-utils"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15281,7 +15292,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15314,7 +15325,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-azure"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15347,7 +15358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-email"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15367,7 +15378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-gcp"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15401,7 +15412,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-http"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15437,7 +15448,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-kafka"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15460,7 +15471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-mqtt"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15484,7 +15495,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-nats"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
@@ -15508,7 +15519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-postgres"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15543,7 +15554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-sqs"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -15571,11 +15582,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-trigger-websocket"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum 0.8.9",
|
||||
"base64 0.22.1",
|
||||
"futures",
|
||||
"http 1.4.0",
|
||||
"itertools 0.14.0",
|
||||
@@ -15585,6 +15597,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-tungstenite 0.24.0",
|
||||
"tracing",
|
||||
"url",
|
||||
"windmill-api-auth",
|
||||
"windmill-common",
|
||||
"windmill-git-sync",
|
||||
@@ -15594,7 +15607,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags 2.11.1",
|
||||
@@ -15613,7 +15626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-once-cell",
|
||||
@@ -15723,7 +15736,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-worker-volumes"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.706.1"
|
||||
version = "1.709.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.706.1"
|
||||
version = "1.709.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
eb18d7b4c0e37fea3f6e1e2cc44e0fddd74ff817
|
||||
2de9dc793360764dc81b9593d72cb50347656a52
|
||||
|
||||
+94
-102
@@ -174,10 +174,11 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||
|
||||
[[package]]
|
||||
name = "ast_node"
|
||||
version = "3.0.4"
|
||||
version = "0.9.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0a184645bcc6f52d69d8e7639720699c6a99efb711f886e251ed1d16db8dd90e"
|
||||
checksum = "f9184f2b369b3e8625712493c89b785881f27eedc6cde480a81883cef78868b2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"swc_macros_common",
|
||||
"syn 2.0.117",
|
||||
@@ -400,9 +401,9 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "better_scoped_tls"
|
||||
version = "1.0.1"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7cd228125315b132eed175bf47619ac79b945b26e56b848ba203ae4ea8603609"
|
||||
checksum = "297b153aa5e573b5863108a6ddc9d5c968bd0b20e75cc614ee9821d2f45679c7"
|
||||
dependencies = [
|
||||
"scoped-tls",
|
||||
]
|
||||
@@ -562,6 +563,9 @@ name = "bumpalo"
|
||||
version = "3.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byte-unit"
|
||||
@@ -609,16 +613,6 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bytes-str"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytesize"
|
||||
version = "1.3.3"
|
||||
@@ -1378,10 +1372,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "from_variant"
|
||||
version = "2.0.2"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "308530a56b099da144ebc5d8e179f343ad928fa2b3558d1eb3db9af18d6eff43"
|
||||
checksum = "32016f1242eb82af5474752d00fd8ebcd9004bd69b462b1c91de833972d08ed4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"swc_macros_common",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
@@ -1756,14 +1751,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hstr"
|
||||
version = "2.1.0"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "31f11d91d7befd2ffd9d216e9e5ea1fae6174b20a2a1b67a688138003d2f4122"
|
||||
checksum = "a1a26def229ea95a8709dad32868d975d0dd40235bd2ce82920e4a8fe692b5e0"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
"new_debug_unreachable",
|
||||
"once_cell",
|
||||
"rustc-hash 2.1.1",
|
||||
"phf 0.11.3",
|
||||
"rustc-hash 1.1.0",
|
||||
"triomphe",
|
||||
]
|
||||
|
||||
@@ -4139,17 +4135,11 @@ version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "seq-macro"
|
||||
version = "0.3.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
version = "1.0.220"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
checksum = "ceecad4c782e936ac90ecfd6b56532322e3262b14320abf30ce89a92ffdbfe22"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
@@ -4168,18 +4158,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
version = "1.0.220"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
checksum = "ddba47394f3b862d6ff6efdbd26ca4673e3566a307880a0ffb98f274bbe0ec32"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
version = "1.0.220"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
checksum = "60e1f3b1761e96def5ec6d04a6e7421c0404fa3cf5c0155f1e2848fae3d8cc08"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4702,10 +4692,11 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "string_enum"
|
||||
version = "1.0.2"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae36a4951ca7bd1cfd991c241584a9824a70f6aff1e7d4f693fb3f2465e4030e"
|
||||
checksum = "05e383308aebc257e7d7920224fa055c632478d92744eca77f99be8fa1545b90"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"swc_macros_common",
|
||||
"syn 2.0.117",
|
||||
@@ -4808,34 +4799,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
|
||||
|
||||
[[package]]
|
||||
name = "swc_atoms"
|
||||
version = "7.0.0"
|
||||
name = "swc_allocator"
|
||||
version = "0.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3500dcf04c84606b38464561edc5e46f5132201cb3e23cf9613ed4033d6b1bb2"
|
||||
checksum = "76aa0eb65c0f39f9b6d82a7e5192c30f7ac9a78f084a21f270de1d8c600ca388"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"hashbrown 0.14.5",
|
||||
"ptr_meta",
|
||||
"rustc-hash 1.1.0",
|
||||
"triomphe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swc_atoms"
|
||||
version = "0.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb6567e4e67485b3e7662b486f1565bdae54bd5b9d6b16b2ba1a9babb1e42125"
|
||||
dependencies = [
|
||||
"hstr",
|
||||
"once_cell",
|
||||
"rustc-hash 1.1.0",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swc_common"
|
||||
version = "14.0.4"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2bb772b3a26b8b71d4e8c112ced5b5867be2266364b58517407a270328a2696"
|
||||
checksum = "12d0a8eaaf1606c9207077d75828008cb2dfb51b095a766bd2b72ef893576e31"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"ast_node",
|
||||
"better_scoped_tls",
|
||||
"bytes-str",
|
||||
"cfg-if",
|
||||
"either",
|
||||
"from_variant",
|
||||
"new_debug_unreachable",
|
||||
"num-bigint",
|
||||
"once_cell",
|
||||
"rustc-hash 2.1.1",
|
||||
"rustc-hash 1.1.0",
|
||||
"serde",
|
||||
"siphasher 0.3.11",
|
||||
"swc_allocator",
|
||||
"swc_atoms",
|
||||
"swc_eq_ignore_macros",
|
||||
"swc_visit",
|
||||
@@ -4846,36 +4851,32 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "swc_ecma_ast"
|
||||
version = "15.0.0"
|
||||
version = "0.118.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c25af97d53cf8aab66a6c68f3418663313fc969ad267fc2a4d19402c329be1"
|
||||
checksum = "a6f866d12e4d519052b92a0a86d1ac7ff17570da1272ca0c89b3d6f802cd79df"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"is-macro",
|
||||
"num-bigint",
|
||||
"once_cell",
|
||||
"phf 0.11.3",
|
||||
"rustc-hash 2.1.1",
|
||||
"scoped-tls",
|
||||
"string_enum",
|
||||
"swc_atoms",
|
||||
"swc_common",
|
||||
"swc_visit",
|
||||
"unicode-id-start",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swc_ecma_lexer"
|
||||
version = "23.0.2"
|
||||
name = "swc_ecma_parser"
|
||||
version = "0.149.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "017d06ea85008234aa9fb34d805c7dc563f2ea6e03869ed5ac5a2dc27d561e4d"
|
||||
checksum = "683dada14722714588b56481399c699378b35b2ba4deb5c4db2fb627a97fb54b"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags",
|
||||
"either",
|
||||
"new_debug_unreachable",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"phf 0.11.3",
|
||||
"rustc-hash 2.1.1",
|
||||
"seq-macro",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"smartstring",
|
||||
@@ -4884,29 +4885,14 @@ dependencies = [
|
||||
"swc_common",
|
||||
"swc_ecma_ast",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swc_ecma_parser"
|
||||
version = "24.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e9011783c975ba592ffc09cd208ced92b1dfabb2e5e0ef453559e2e25286127"
|
||||
dependencies = [
|
||||
"either",
|
||||
"num-bigint",
|
||||
"serde",
|
||||
"swc_atoms",
|
||||
"swc_common",
|
||||
"swc_ecma_ast",
|
||||
"swc_ecma_lexer",
|
||||
"tracing",
|
||||
"typed-arena",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "swc_ecma_visit"
|
||||
version = "15.0.0"
|
||||
version = "0.104.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75a579aa8f9e212af521588df720ccead079c09fe5c8f61007cf724324aed3a0"
|
||||
checksum = "5b1c6802e68e51f336e8bc9644e9ff9da75d7da9c1a6247d532f2e908aa33e81"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"num-bigint",
|
||||
@@ -4919,9 +4905,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "swc_eq_ignore_macros"
|
||||
version = "1.0.1"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c16ce73424a6316e95e09065ba6a207eba7765496fed113702278b7711d4b632"
|
||||
checksum = "63db0adcff29d220c3d151c5b25c0eabe7e32dd936212b84cdaa1392e3130497"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4930,9 +4916,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "swc_macros_common"
|
||||
version = "1.0.1"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aae1efbaa74943dc5ad2a2fb16cbd78b77d7e4d63188f3c5b4df2b4dcd2faaae"
|
||||
checksum = "27e18fbfe83811ffae2bb23727e45829a0d19c6870bced7c0f545cc99ad248dd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -4941,9 +4927,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "swc_visit"
|
||||
version = "2.0.1"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "62fb71484b486c185e34d2172f0eabe7f4722742aad700f426a494bb2de232a2"
|
||||
checksum = "1ceb044142ba2719ef9eb3b6b454fce61ab849eb696c34d190f04651955c613d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"new_debug_unreachable",
|
||||
@@ -5079,7 +5065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -5609,6 +5595,12 @@ version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typed-arena"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
@@ -6191,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windmill-common"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"anyhow",
|
||||
@@ -6271,7 +6263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-macros"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6283,7 +6275,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"serde",
|
||||
@@ -6292,7 +6284,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-bash"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6304,7 +6296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-csharp"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6316,7 +6308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-go"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"gosyn",
|
||||
@@ -6328,7 +6320,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-graphql"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6340,7 +6332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-java"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6352,7 +6344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-nu"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"nu-parser",
|
||||
@@ -6363,7 +6355,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-php"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6374,7 +6366,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
@@ -6386,7 +6378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-asset"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6397,7 +6389,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-py-imports"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
@@ -6419,7 +6411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-r"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde_json",
|
||||
@@ -6431,7 +6423,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ruby"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6445,7 +6437,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-rust"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"convert_case",
|
||||
@@ -6462,7 +6454,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6475,7 +6467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-sql-asset"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6487,7 +6479,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"lazy_static",
|
||||
@@ -6505,7 +6497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-ts-asset"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde-wasm-bindgen",
|
||||
@@ -6521,7 +6513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wac"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"rustpython-ast",
|
||||
@@ -6537,7 +6529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-wasm"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"getrandom 0.2.17",
|
||||
@@ -6569,7 +6561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-parser-yaml"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
@@ -6580,7 +6572,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "windmill-types"
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
|
||||
@@ -12,7 +12,7 @@ resolver = "2"
|
||||
members = ["."]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.706.1"
|
||||
version = "1.709.0"
|
||||
edition = "2021"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
|
||||
|
||||
+26
-2
@@ -59,7 +59,9 @@ use windmill_common::{
|
||||
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,
|
||||
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,
|
||||
@@ -120,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,
|
||||
@@ -1765,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;
|
||||
}
|
||||
|
||||
+126
-1
@@ -69,6 +69,8 @@ use windmill_common::{
|
||||
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,
|
||||
@@ -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 {
|
||||
@@ -543,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;
|
||||
|
||||
@@ -1113,8 +1113,8 @@ async def main(item: str, qty: int, email: str):
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
language: ScriptLang::Python3,
|
||||
content,
|
||||
..RawCode::default()
|
||||
tag: None,
|
||||
..RawCode::default()
|
||||
}))
|
||||
.arg("item", json!("widget"))
|
||||
.arg("qty", json!(5))
|
||||
@@ -1299,8 +1299,8 @@ async def main(n: int):
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
language: ScriptLang::Python3,
|
||||
content,
|
||||
..RawCode::default()
|
||||
tag: None,
|
||||
..RawCode::default()
|
||||
}))
|
||||
.arg("n", json!(1))
|
||||
.run_until_complete(db, false, port),
|
||||
|
||||
@@ -40,8 +40,8 @@ def main(name: str):
|
||||
path: Some("f/test/my_script".to_string()),
|
||||
language: ScriptLang::Python3,
|
||||
modules: Some(modules),
|
||||
..RawCode::default()
|
||||
tag: None,
|
||||
..RawCode::default()
|
||||
});
|
||||
|
||||
let result = RunJob::from(job)
|
||||
@@ -93,8 +93,8 @@ def main(a: int, b: int):
|
||||
path: Some("f/test/my_script".to_string()),
|
||||
language: ScriptLang::Python3,
|
||||
modules: Some(modules),
|
||||
..RawCode::default()
|
||||
tag: None,
|
||||
..RawCode::default()
|
||||
});
|
||||
|
||||
let result = RunJob::from(job)
|
||||
@@ -145,8 +145,8 @@ export function main(name: string) {
|
||||
path: Some("f/test/my_script".to_string()),
|
||||
language: ScriptLang::Bun,
|
||||
modules: Some(modules),
|
||||
..RawCode::default()
|
||||
tag: None,
|
||||
..RawCode::default()
|
||||
});
|
||||
|
||||
let result = RunJob::from(job)
|
||||
|
||||
@@ -4997,8 +4997,8 @@ async fn test_workflow_as_code(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
language: ScriptLang::Python3,
|
||||
content: WORKFLOW_AS_CODE.into(),
|
||||
..RawCode::default()
|
||||
tag: None,
|
||||
..RawCode::default()
|
||||
}))
|
||||
.arg("n", json!(3))
|
||||
.run_until_complete(db, false, port),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"]
|
||||
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"]
|
||||
mcp = ["dep:windmill-mcp"]
|
||||
|
||||
[lib]
|
||||
@@ -42,4 +42,5 @@ ulid.workspace = true
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-credential-types = { workspace = true, optional = true }
|
||||
aws-smithy-types = { workspace = true, optional = true }
|
||||
aws-sdk-bedrock = { workspace = true, optional = true }
|
||||
aws-sdk-bedrockruntime = { workspace = true, optional = true }
|
||||
|
||||
@@ -7,21 +7,731 @@
|
||||
//! - Helper utilities
|
||||
|
||||
use crate::{
|
||||
ai_bedrock::{
|
||||
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
|
||||
bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta,
|
||||
bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config,
|
||||
format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai,
|
||||
BearerTokenProvider, BedrockClient, StreamingToolCall,
|
||||
},
|
||||
ai_providers::USE_ENV_REGION,
|
||||
ai_types::{OpenAIFunction, OpenAIToolCall, ToolDefFunction},
|
||||
image_handler::prepare_messages_for_api,
|
||||
proxy::ProxyBuildArgs,
|
||||
query_builder::{ParsedResponse, StreamEventSink},
|
||||
types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef},
|
||||
};
|
||||
use bytes::Bytes;
|
||||
use futures::{stream::BoxStream, StreamExt};
|
||||
use http::{HeaderMap, Method, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
|
||||
// Import shared Bedrock helpers for provider orchestration.
|
||||
use crate::ai_bedrock::{
|
||||
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
|
||||
bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta,
|
||||
bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config,
|
||||
format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai,
|
||||
BedrockClient, StreamingToolCall,
|
||||
};
|
||||
// ============================================================================
|
||||
// Native Proxy Execution
|
||||
// ============================================================================
|
||||
|
||||
/// OpenAI-format request body for Bedrock SDK proxy handlers.
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct OpenAIRequest {
|
||||
messages: Vec<OpenAIMessage>,
|
||||
#[serde(default)]
|
||||
tools: Option<Vec<OpenAIToolDef>>,
|
||||
#[serde(default)]
|
||||
tool_choice: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
max_tokens: Option<i32>,
|
||||
#[serde(default)]
|
||||
temperature: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct OpenAIToolDef {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
r#type: Option<String>,
|
||||
function: OpenAIToolFunction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct OpenAIToolFunction {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
parameters: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct BedrockProxyChatRequest {
|
||||
model: String,
|
||||
#[serde(default)]
|
||||
stream: bool,
|
||||
}
|
||||
|
||||
enum BedrockAuthConfig {
|
||||
BearerToken(String),
|
||||
IamCredentials {
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
session_token: Option<String>,
|
||||
},
|
||||
Environment,
|
||||
}
|
||||
|
||||
pub enum BedrockProxyResponseBody {
|
||||
Fixed(Bytes),
|
||||
Stream(BoxStream<'static, std::result::Result<Bytes, std::io::Error>>),
|
||||
}
|
||||
|
||||
pub struct BedrockProxyResponse {
|
||||
pub status_code: StatusCode,
|
||||
pub headers: HeaderMap,
|
||||
pub body: BedrockProxyResponseBody,
|
||||
}
|
||||
|
||||
/// Handle a workspace Bedrock proxy request through the AWS SDK.
|
||||
///
|
||||
/// The API still owns credential resolution, route authorization, auditing, and
|
||||
/// cache behavior. This helper owns Bedrock-specific control-plane and
|
||||
/// OpenAI-compatible Converse transformations.
|
||||
pub async fn handle_bedrock_proxy(
|
||||
args: &ProxyBuildArgs<'_>,
|
||||
) -> Result<BedrockProxyResponse, Error> {
|
||||
let region = args.credentials.region.as_deref().unwrap_or(USE_ENV_REGION);
|
||||
|
||||
if *args.method == Method::GET {
|
||||
return match args.path {
|
||||
"foundation-models" => list_foundation_models(args, region).await,
|
||||
"inference-profiles" => list_inference_profiles(args, region).await,
|
||||
_ => Err(Error::BadRequest(format!(
|
||||
"Unsupported AWS Bedrock proxy path: {}",
|
||||
args.path
|
||||
))),
|
||||
};
|
||||
}
|
||||
|
||||
if *args.method != Method::POST {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Unsupported AWS Bedrock proxy method: {}",
|
||||
args.method
|
||||
)));
|
||||
}
|
||||
|
||||
let request: BedrockProxyChatRequest = serde_json::from_slice(args.body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?;
|
||||
|
||||
if request.stream {
|
||||
handle_bedrock_sdk_streaming(&request.model, args.body, args, region).await
|
||||
} else {
|
||||
handle_bedrock_sdk_non_streaming(&request.model, args.body, args, region).await
|
||||
}
|
||||
}
|
||||
|
||||
fn determine_auth_config(
|
||||
api_key: Option<&str>,
|
||||
aws_access_key_id: Option<&str>,
|
||||
aws_secret_access_key: Option<&str>,
|
||||
aws_session_token: Option<&str>,
|
||||
) -> BedrockAuthConfig {
|
||||
if let Some(key) = api_key.filter(|k| !k.is_empty()) {
|
||||
BedrockAuthConfig::BearerToken(key.to_string())
|
||||
} else if let (Some(access_key_id), Some(secret_access_key)) = (
|
||||
aws_access_key_id.filter(|s| !s.is_empty()),
|
||||
aws_secret_access_key.filter(|s| !s.is_empty()),
|
||||
) {
|
||||
BedrockAuthConfig::IamCredentials {
|
||||
access_key_id: access_key_id.to_string(),
|
||||
secret_access_key: secret_access_key.to_string(),
|
||||
session_token: aws_session_token
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(str::to_string),
|
||||
}
|
||||
} else {
|
||||
BedrockAuthConfig::Environment
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_bedrock_client(
|
||||
args: &ProxyBuildArgs<'_>,
|
||||
region: &str,
|
||||
) -> Result<BedrockClient, Error> {
|
||||
match determine_auth_config(
|
||||
args.credentials.api_key.as_deref(),
|
||||
args.credentials.aws_access_key_id.as_deref(),
|
||||
args.credentials.aws_secret_access_key.as_deref(),
|
||||
args.credentials.aws_session_token.as_deref(),
|
||||
) {
|
||||
BedrockAuthConfig::BearerToken(key) => BedrockClient::from_bearer_token(key, region).await,
|
||||
BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => {
|
||||
BedrockClient::from_credentials(access_key_id, secret_access_key, session_token, region)
|
||||
.await
|
||||
}
|
||||
BedrockAuthConfig::Environment => BedrockClient::from_env(region).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tool_config_from_request(
|
||||
tools: Option<&[OpenAIToolDef]>,
|
||||
tool_choice: Option<&serde_json::Value>,
|
||||
enable_prompt_caching: bool,
|
||||
) -> Result<Option<aws_sdk_bedrockruntime::types::ToolConfiguration>, Error> {
|
||||
if let Some(tools) = tools {
|
||||
let tool_defs: Vec<ToolDef> = tools
|
||||
.iter()
|
||||
.map(|t| ToolDef {
|
||||
r#type: "function".to_string(),
|
||||
function: ToolDefFunction {
|
||||
name: t.function.name.clone(),
|
||||
description: t.function.description.clone(),
|
||||
parameters: Box::from(
|
||||
serde_json::value::RawValue::from_string(
|
||||
serde_json::to_string(
|
||||
&t.function
|
||||
.parameters
|
||||
.clone()
|
||||
.unwrap_or(serde_json::json!({})),
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
serde_json::value::RawValue::from_string("{}".to_string()).unwrap()
|
||||
}),
|
||||
),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
let force_tool_use = tool_choice
|
||||
.map(|tc| tc == "required" || tc.as_str() == Some("required"))
|
||||
.unwrap_or(false);
|
||||
|
||||
build_tool_config(Some(&tool_defs), force_tool_use, enable_prompt_caching)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_bedrock_control_client(
|
||||
args: &ProxyBuildArgs<'_>,
|
||||
region: &str,
|
||||
) -> Result<aws_sdk_bedrock::Client, Error> {
|
||||
use aws_config::BehaviorVersion;
|
||||
|
||||
let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string());
|
||||
|
||||
match determine_auth_config(
|
||||
args.credentials.api_key.as_deref(),
|
||||
args.credentials.aws_access_key_id.as_deref(),
|
||||
args.credentials.aws_secret_access_key.as_deref(),
|
||||
args.credentials.aws_session_token.as_deref(),
|
||||
) {
|
||||
BedrockAuthConfig::BearerToken(key) => {
|
||||
let config = aws_sdk_bedrock::config::Builder::new()
|
||||
.region(region_provider)
|
||||
.behavior_version(BehaviorVersion::latest())
|
||||
.token_provider(BearerTokenProvider::new(key))
|
||||
.build();
|
||||
Ok(aws_sdk_bedrock::Client::from_conf(config))
|
||||
}
|
||||
BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => {
|
||||
let credentials = aws_credential_types::Credentials::new(
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token,
|
||||
None,
|
||||
"windmill",
|
||||
);
|
||||
let config = aws_sdk_bedrock::config::Builder::new()
|
||||
.region(region_provider)
|
||||
.behavior_version(BehaviorVersion::latest())
|
||||
.credentials_provider(credentials)
|
||||
.build();
|
||||
Ok(aws_sdk_bedrock::Client::from_conf(config))
|
||||
}
|
||||
BedrockAuthConfig::Environment => {
|
||||
let config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.region(region_provider)
|
||||
.load()
|
||||
.await;
|
||||
Ok(aws_sdk_bedrock::Client::new(&config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_foundation_models(
|
||||
args: &ProxyBuildArgs<'_>,
|
||||
region: &str,
|
||||
) -> Result<BedrockProxyResponse, Error> {
|
||||
let client = create_bedrock_control_client(args, region).await?;
|
||||
|
||||
let response = client
|
||||
.list_foundation_models()
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to list foundation models: {}", e)))?;
|
||||
|
||||
let models: Vec<serde_json::Value> = response
|
||||
.model_summaries()
|
||||
.iter()
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"modelId": m.model_id(),
|
||||
"modelName": m.model_name(),
|
||||
"providerName": m.provider_name(),
|
||||
"modelArn": m.model_arn(),
|
||||
"inputModalities": m.input_modalities().iter().map(|i| i.as_str()).collect::<Vec<_>>(),
|
||||
"outputModalities": m.output_modalities().iter().map(|o| o.as_str()).collect::<Vec<_>>(),
|
||||
"responseStreamingSupported": m.response_streaming_supported(),
|
||||
"inferenceTypesSupported": m.inference_types_supported().iter().map(|i| i.as_str()).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body = serde_json::to_vec(&serde_json::json!({ "modelSummaries": models }))
|
||||
.map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?;
|
||||
|
||||
Ok(BedrockProxyResponse {
|
||||
status_code: StatusCode::OK,
|
||||
headers: json_response_headers(),
|
||||
body: BedrockProxyResponseBody::Fixed(Bytes::from(body)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_inference_profiles(
|
||||
args: &ProxyBuildArgs<'_>,
|
||||
region: &str,
|
||||
) -> Result<BedrockProxyResponse, Error> {
|
||||
let client = create_bedrock_control_client(args, region).await?;
|
||||
|
||||
let response =
|
||||
client.list_inference_profiles().send().await.map_err(|e| {
|
||||
Error::internal_err(format!("Failed to list inference profiles: {}", e))
|
||||
})?;
|
||||
|
||||
let profiles: Vec<serde_json::Value> = response
|
||||
.inference_profile_summaries()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"inferenceProfileId": p.inference_profile_id(),
|
||||
"inferenceProfileName": p.inference_profile_name(),
|
||||
"inferenceProfileArn": p.inference_profile_arn(),
|
||||
"description": p.description(),
|
||||
"status": p.status().as_str(),
|
||||
"type": p.r#type().as_str(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body = serde_json::to_vec(&serde_json::json!({ "inferenceProfileSummaries": profiles }))
|
||||
.map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?;
|
||||
|
||||
Ok(BedrockProxyResponse {
|
||||
status_code: StatusCode::OK,
|
||||
headers: json_response_headers(),
|
||||
body: BedrockProxyResponseBody::Fixed(Bytes::from(body)),
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_bedrock_sdk_streaming(
|
||||
model: &str,
|
||||
body: &[u8],
|
||||
args: &ProxyBuildArgs<'_>,
|
||||
region: &str,
|
||||
) -> Result<BedrockProxyResponse, Error> {
|
||||
let openai_req: OpenAIRequest = serde_json::from_slice(body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?;
|
||||
|
||||
let bedrock_client = create_bedrock_client(args, region).await?;
|
||||
let enable_prompt_caching = bedrock_model_supports_prompt_caching(model);
|
||||
let (bedrock_messages, system_prompts) =
|
||||
openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?;
|
||||
let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens);
|
||||
let tool_config = build_tool_config_from_request(
|
||||
openai_req.tools.as_deref(),
|
||||
openai_req.tool_choice.as_ref(),
|
||||
enable_prompt_caching,
|
||||
)?;
|
||||
|
||||
let mut request_builder = bedrock_client
|
||||
.client()
|
||||
.converse_stream()
|
||||
.model_id(model)
|
||||
.set_messages(Some(bedrock_messages));
|
||||
|
||||
if !system_prompts.is_empty() {
|
||||
request_builder = request_builder.set_system(Some(system_prompts));
|
||||
}
|
||||
|
||||
if let Some(config) = inference_config {
|
||||
request_builder = request_builder.inference_config(config);
|
||||
}
|
||||
|
||||
if let Some(config) = tool_config {
|
||||
request_builder = request_builder.set_tool_config(Some(config));
|
||||
}
|
||||
|
||||
tracing::debug!("Bedrock SDK streaming: sending converse_stream request");
|
||||
let stream_output = request_builder.send().await.map_err(|e| {
|
||||
let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e));
|
||||
tracing::error!("Bedrock SDK streaming failed: {}", error_msg);
|
||||
Error::internal_err(error_msg)
|
||||
})?;
|
||||
tracing::debug!("Bedrock SDK streaming: stream established successfully");
|
||||
|
||||
Ok(BedrockProxyResponse {
|
||||
status_code: StatusCode::OK,
|
||||
headers: event_stream_response_headers(),
|
||||
body: BedrockProxyResponseBody::Stream(
|
||||
sdk_stream_to_sse(stream_output.stream, model.to_string()).boxed(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sdk_stream_to_sse(
|
||||
stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver<
|
||||
aws_sdk_bedrockruntime::types::ConverseStreamOutput,
|
||||
aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError,
|
||||
>,
|
||||
model: String,
|
||||
) -> impl futures::Stream<Item = std::result::Result<Bytes, std::io::Error>> + Send {
|
||||
let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple());
|
||||
let created = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
struct StreamState {
|
||||
id: String,
|
||||
model: String,
|
||||
created: u64,
|
||||
tool_calls: HashMap<usize, (String, String, String)>,
|
||||
current_tool_index: usize,
|
||||
}
|
||||
|
||||
let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState {
|
||||
id,
|
||||
model,
|
||||
created,
|
||||
tool_calls: HashMap::new(),
|
||||
current_tool_index: 0,
|
||||
}));
|
||||
|
||||
async_stream::stream! {
|
||||
let mut stream = stream;
|
||||
let state = state.clone();
|
||||
|
||||
loop {
|
||||
match stream.recv().await {
|
||||
Ok(Some(event)) => {
|
||||
let mut state = state.lock().await;
|
||||
|
||||
if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) {
|
||||
let index = state.current_tool_index;
|
||||
state.tool_calls.insert(
|
||||
index,
|
||||
(tool_call.id.clone(), tool_call.name.clone(), String::new()),
|
||||
);
|
||||
|
||||
let chunk = serde_json::json!({
|
||||
"id": state.id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.created,
|
||||
"model": state.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": index,
|
||||
"id": tool_call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call.name,
|
||||
"arguments": ""
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": serde_json::Value::Null
|
||||
}]
|
||||
});
|
||||
|
||||
yield Ok(Bytes::from(format!("data: {}\n\n", chunk)));
|
||||
}
|
||||
|
||||
if let Some(text) = bedrock_stream_event_to_text(&event) {
|
||||
let chunk = serde_json::json!({
|
||||
"id": state.id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.created,
|
||||
"model": state.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"content": text
|
||||
},
|
||||
"finish_reason": serde_json::Value::Null
|
||||
}]
|
||||
});
|
||||
|
||||
yield Ok(Bytes::from(format!("data: {}\n\n", chunk)));
|
||||
}
|
||||
|
||||
if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) {
|
||||
let index = state.current_tool_index;
|
||||
if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) {
|
||||
args.push_str(&input_delta);
|
||||
|
||||
let chunk = serde_json::json!({
|
||||
"id": state.id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.created,
|
||||
"model": state.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": index,
|
||||
"function": {
|
||||
"arguments": input_delta
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": serde_json::Value::Null
|
||||
}]
|
||||
});
|
||||
|
||||
yield Ok(Bytes::from(format!("data: {}\n\n", chunk)));
|
||||
}
|
||||
}
|
||||
|
||||
if bedrock_stream_event_is_block_stop(&event) {
|
||||
state.current_tool_index += 1;
|
||||
}
|
||||
|
||||
if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = &event {
|
||||
let stop_reason = stop.stop_reason().as_str();
|
||||
let finish_reason = match stop_reason {
|
||||
"end_turn" => "stop",
|
||||
"max_tokens" => "length",
|
||||
"tool_use" => "tool_calls",
|
||||
"stop_sequence" => "stop",
|
||||
"guardrail_intervened" | "content_filtered" => "content_filter",
|
||||
_ => "stop",
|
||||
};
|
||||
|
||||
let chunk = serde_json::json!({
|
||||
"id": state.id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.created,
|
||||
"model": state.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": finish_reason
|
||||
}]
|
||||
});
|
||||
|
||||
yield Ok(Bytes::from(format!("data: {}\n\n", chunk)));
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
yield Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
e.to_string(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
yield Ok(Bytes::from("data: [DONE]\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_bedrock_sdk_non_streaming(
|
||||
model: &str,
|
||||
body: &[u8],
|
||||
args: &ProxyBuildArgs<'_>,
|
||||
region: &str,
|
||||
) -> Result<BedrockProxyResponse, Error> {
|
||||
let openai_req: OpenAIRequest = serde_json::from_slice(body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?;
|
||||
|
||||
let bedrock_client = create_bedrock_client(args, region).await?;
|
||||
let enable_prompt_caching = bedrock_model_supports_prompt_caching(model);
|
||||
let (bedrock_messages, system_prompts) =
|
||||
openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?;
|
||||
let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens);
|
||||
let tool_config = build_tool_config_from_request(
|
||||
openai_req.tools.as_deref(),
|
||||
openai_req.tool_choice.as_ref(),
|
||||
enable_prompt_caching,
|
||||
)?;
|
||||
|
||||
let mut request_builder = bedrock_client
|
||||
.client()
|
||||
.converse()
|
||||
.model_id(model)
|
||||
.set_messages(Some(bedrock_messages));
|
||||
|
||||
if !system_prompts.is_empty() {
|
||||
request_builder = request_builder.set_system(Some(system_prompts));
|
||||
}
|
||||
|
||||
if let Some(config) = inference_config {
|
||||
request_builder = request_builder.inference_config(config);
|
||||
}
|
||||
|
||||
if let Some(config) = tool_config {
|
||||
request_builder = request_builder.set_tool_config(Some(config));
|
||||
}
|
||||
|
||||
tracing::debug!("Bedrock SDK non-streaming: sending converse request");
|
||||
let response = request_builder.send().await.map_err(|e| {
|
||||
let error_msg = format!(
|
||||
"Bedrock SDK non-streaming error: {}",
|
||||
format_bedrock_error(&e)
|
||||
);
|
||||
tracing::error!("Bedrock SDK non-streaming failed: {}", error_msg);
|
||||
Error::internal_err(error_msg)
|
||||
})?;
|
||||
tracing::debug!(
|
||||
"Bedrock SDK non-streaming: response received, stop_reason={}",
|
||||
response.stop_reason().as_str()
|
||||
);
|
||||
|
||||
let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple());
|
||||
let created = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
let stop_reason = response.stop_reason().as_str();
|
||||
let finish_reason = match stop_reason {
|
||||
"end_turn" => "stop",
|
||||
"max_tokens" => "length",
|
||||
"tool_use" => "tool_calls",
|
||||
"stop_sequence" => "stop",
|
||||
"guardrail_intervened" | "content_filtered" => "content_filter",
|
||||
_ => "stop",
|
||||
};
|
||||
|
||||
let mut text_content = String::new();
|
||||
let mut tool_calls: Vec<OpenAIToolCall> = Vec::new();
|
||||
|
||||
if let Some(aws_sdk_bedrockruntime::types::ConverseOutput::Message(message)) = response.output()
|
||||
{
|
||||
for block in message.content() {
|
||||
match block {
|
||||
aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => {
|
||||
text_content.push_str(text);
|
||||
}
|
||||
aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => {
|
||||
let input_json = document_to_json(tool_use.input());
|
||||
tool_calls.push(OpenAIToolCall {
|
||||
id: tool_use.tool_use_id().to_string(),
|
||||
function: OpenAIFunction {
|
||||
name: tool_use.name().to_string(),
|
||||
arguments: serde_json::to_string(&input_json).unwrap_or_default(),
|
||||
},
|
||||
r#type: "function".to_string(),
|
||||
extra_content: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let message = if !tool_calls.is_empty() {
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) },
|
||||
"tool_calls": tool_calls
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": text_content
|
||||
})
|
||||
};
|
||||
|
||||
let usage = if let Some(usage_data) = response.usage() {
|
||||
serde_json::json!({
|
||||
"prompt_tokens": usage_data.input_tokens(),
|
||||
"completion_tokens": usage_data.output_tokens(),
|
||||
"total_tokens": usage_data.total_tokens()
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
})
|
||||
};
|
||||
|
||||
let openai_resp = serde_json::json!({
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": finish_reason
|
||||
}],
|
||||
"usage": usage
|
||||
});
|
||||
|
||||
let body = serde_json::to_vec(&openai_resp)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to serialize OpenAI response: {}", e)))?;
|
||||
|
||||
Ok(BedrockProxyResponse {
|
||||
status_code: StatusCode::OK,
|
||||
headers: json_response_headers(),
|
||||
body: BedrockProxyResponseBody::Fixed(Bytes::from(body)),
|
||||
})
|
||||
}
|
||||
|
||||
fn json_response_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("content-type", "application/json".parse().unwrap());
|
||||
headers
|
||||
}
|
||||
|
||||
fn event_stream_response_headers() -> HeaderMap {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("content-type", "text/event-stream".parse().unwrap());
|
||||
headers.insert("cache-control", "no-cache".parse().unwrap());
|
||||
headers.insert("connection", "keep-alive".parse().unwrap());
|
||||
headers
|
||||
}
|
||||
|
||||
fn document_to_json(doc: &aws_smithy_types::Document) -> serde_json::Value {
|
||||
match doc {
|
||||
aws_smithy_types::Document::Object(map) => {
|
||||
let mut json_map = serde_json::Map::new();
|
||||
for (key, value) in map {
|
||||
json_map.insert(key.clone(), document_to_json(value));
|
||||
}
|
||||
serde_json::Value::Object(json_map)
|
||||
}
|
||||
aws_smithy_types::Document::Array(values) => {
|
||||
serde_json::Value::Array(values.iter().map(document_to_json).collect())
|
||||
}
|
||||
aws_smithy_types::Document::Number(number) => match number {
|
||||
aws_smithy_types::Number::PosInt(number) => serde_json::Value::Number((*number).into()),
|
||||
aws_smithy_types::Number::NegInt(number) => serde_json::Value::Number((*number).into()),
|
||||
aws_smithy_types::Number::Float(number) => serde_json::json!(*number),
|
||||
},
|
||||
aws_smithy_types::Document::String(value) => serde_json::Value::String(value.clone()),
|
||||
aws_smithy_types::Document::Bool(value) => serde_json::Value::Bool(*value),
|
||||
aws_smithy_types::Document::Null => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Query Builder
|
||||
@@ -256,3 +966,60 @@ impl BedrockQueryBuilder {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn determine_auth_config_prioritizes_bearer_token() {
|
||||
let config = determine_auth_config(
|
||||
Some("bearer-token"),
|
||||
Some("AKIA123"),
|
||||
Some("secret"),
|
||||
Some("session-token"),
|
||||
);
|
||||
|
||||
match config {
|
||||
BedrockAuthConfig::BearerToken(token) => assert_eq!(token, "bearer-token"),
|
||||
_ => panic!("expected bearer token auth config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_auth_config_uses_iam_with_optional_session_token() {
|
||||
let config =
|
||||
determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("session-token"));
|
||||
|
||||
match config {
|
||||
BedrockAuthConfig::IamCredentials {
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token,
|
||||
} => {
|
||||
assert_eq!(access_key_id, "AKIA123");
|
||||
assert_eq!(secret_access_key, "secret");
|
||||
assert_eq!(session_token.as_deref(), Some("session-token"));
|
||||
}
|
||||
_ => panic!("expected IAM auth config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_auth_config_treats_empty_session_token_as_none() {
|
||||
let config = determine_auth_config(None, Some("AKIA123"), Some("secret"), Some(""));
|
||||
|
||||
match config {
|
||||
BedrockAuthConfig::IamCredentials { session_token, .. } => {
|
||||
assert!(session_token.is_none());
|
||||
}
|
||||
_ => panic!("expected IAM auth config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_auth_config_falls_back_to_environment() {
|
||||
let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token"));
|
||||
assert!(matches!(config, BedrockAuthConfig::Environment));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +408,8 @@ fn build_google_ai_model_endpoint(
|
||||
action: &str,
|
||||
is_vertex: bool,
|
||||
) -> String {
|
||||
let model = model.strip_prefix("models/").unwrap_or(model);
|
||||
|
||||
if is_vertex {
|
||||
format!("{}/{}:{}", base_url, model, action)
|
||||
} else {
|
||||
@@ -416,6 +418,10 @@ fn build_google_ai_model_endpoint(
|
||||
}
|
||||
|
||||
fn add_google_ai_auth_header(headers: &mut Vec<(String, String)>, api_key: &str, is_vertex: bool) {
|
||||
// Native Google AI proxy intentionally does not apply AI_HTTP_HEADERS or
|
||||
// resource custom headers yet. Gemini/Vertex header semantics are
|
||||
// provider-specific; keep this limited to required auth headers until
|
||||
// explicit custom-header support is designed.
|
||||
if is_vertex {
|
||||
headers.push(("Authorization".to_string(), format!("Bearer {}", api_key)));
|
||||
} else {
|
||||
@@ -749,6 +755,32 @@ mod tests {
|
||||
assert!(body["contents"].is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_standard_google_ai_endpoint_from_model_resource_name() {
|
||||
assert_eq!(
|
||||
build_google_ai_model_endpoint(
|
||||
"https://generativelanguage.googleapis.com/v1beta",
|
||||
"models/gemini-2.0-flash",
|
||||
"generateContent",
|
||||
false,
|
||||
),
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_vertex_google_ai_endpoint_from_model_resource_name() {
|
||||
assert_eq!(
|
||||
build_google_ai_model_endpoint(
|
||||
"https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models",
|
||||
"models/gemini-2.0-flash",
|
||||
"streamGenerateContent",
|
||||
true,
|
||||
),
|
||||
"https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_vertex_google_ai_streaming_proxy_request() {
|
||||
let credentials = credentials(
|
||||
|
||||
@@ -6,35 +6,15 @@ pub mod openai;
|
||||
pub mod openrouter;
|
||||
pub mod other;
|
||||
|
||||
use crate::{
|
||||
ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder,
|
||||
types::ProviderWithResource,
|
||||
};
|
||||
use crate::{ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder};
|
||||
|
||||
use self::{
|
||||
anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder,
|
||||
openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder,
|
||||
};
|
||||
|
||||
/// Factory function to create the appropriate query builder for a provider.
|
||||
pub fn create_query_builder(provider: &ProviderWithResource) -> Box<dyn QueryBuilder> {
|
||||
match provider.kind {
|
||||
AIProvider::GoogleAI => {
|
||||
Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone()))
|
||||
}
|
||||
AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())),
|
||||
AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new(
|
||||
provider.kind.clone(),
|
||||
provider.get_platform().clone(),
|
||||
provider.get_enable_1m_context(),
|
||||
)),
|
||||
AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()),
|
||||
_ => Box::new(OtherQueryBuilder::new(provider.kind.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Factory function to create the appropriate query builder from resolved proxy credentials.
|
||||
pub fn create_proxy_query_builder(credentials: &ProviderCredentials) -> Box<dyn QueryBuilder> {
|
||||
/// Factory function to create the appropriate query builder from resolved credentials.
|
||||
pub fn create_query_builder(credentials: &ProviderCredentials) -> Box<dyn QueryBuilder> {
|
||||
match credentials.provider {
|
||||
AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new(credentials.platform.clone())),
|
||||
AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(credentials.provider.clone())),
|
||||
|
||||
@@ -7,10 +7,11 @@ use windmill_common::error::{Error, Result};
|
||||
use crate::ai_providers::{AIPlatform, AIProvider};
|
||||
use crate::utils::AI_HTTP_HEADERS;
|
||||
|
||||
/// Resolved provider credentials and proxy-specific context.
|
||||
/// Resolved provider credentials shared by API proxy and worker execution.
|
||||
///
|
||||
/// This is intentionally separate from the worker's `ProviderWithResource`: API
|
||||
/// proxy credentials are already resolved from workspace or instance resources.
|
||||
/// Raw API resources and worker agent payloads convert into this shape at their
|
||||
/// execution boundaries. Request-specific state such as the selected model stays
|
||||
/// outside this type.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ProviderCredentials {
|
||||
pub provider: AIProvider,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct McpToolSource {
|
||||
use crate::{
|
||||
ai_google::sanitize_schema_for_google,
|
||||
ai_providers::{empty_string_as_none, AIProvider},
|
||||
proxy::ProviderCredentials,
|
||||
};
|
||||
use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule};
|
||||
use windmill_parser::Typ;
|
||||
@@ -222,6 +223,34 @@ impl ProviderWithResource {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Convert worker agent provider input into resolved runtime credentials.
|
||||
///
|
||||
/// Callers must only pass resources that were already authorized for the
|
||||
/// current job/workspace; this helper does not perform access checks.
|
||||
pub async fn to_provider_credentials(&self, db: &DB) -> Result<ProviderCredentials, Error> {
|
||||
let base_url = if self.kind == AIProvider::AWSBedrock {
|
||||
String::new()
|
||||
} else {
|
||||
self.get_base_url(db).await?
|
||||
};
|
||||
|
||||
Ok(ProviderCredentials {
|
||||
provider: self.kind.clone(),
|
||||
base_url,
|
||||
api_key: self.resource.api_key.clone(),
|
||||
access_token: None,
|
||||
organization_id: None,
|
||||
user: None,
|
||||
region: self.resource.region.clone(),
|
||||
aws_access_key_id: self.resource.aws_access_key_id.clone(),
|
||||
aws_secret_access_key: self.resource.aws_secret_access_key.clone(),
|
||||
aws_session_token: self.resource.aws_session_token.clone(),
|
||||
platform: self.resource.platform.clone(),
|
||||
enable_1m_context: self.resource.enable_1m_context,
|
||||
custom_headers: self.resource.headers.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
pub fn get_region(&self) -> Option<&str> {
|
||||
self.resource.region.as_deref()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -235,6 +235,50 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns a predicate that checks whether `path` is within the token's
|
||||
/// scope for `{domain}:{action}:{path}`. For tokens without scope
|
||||
/// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes),
|
||||
/// the predicate always returns `true`.
|
||||
///
|
||||
/// Pre-parses the token's scopes once so the returned closure can cheaply
|
||||
/// filter large listings without re-parsing on each call.
|
||||
pub fn build_scope_path_predicate(
|
||||
authed: &ApiAuthed,
|
||||
domain: &str,
|
||||
action: &str,
|
||||
) -> impl Fn(&str) -> bool {
|
||||
// Mirror check_scopes semantics: a token is "scope-restricted" iff it has
|
||||
// at least one non-`if_jobs:filter_tags:` scope. Unparseable scopes still
|
||||
// count as restrictive — they just match nothing.
|
||||
let (is_scoped_token, parsed): (bool, Vec<ScopeDefinition>) = match authed.scopes.as_ref() {
|
||||
Some(scopes) => {
|
||||
let mut is_scoped = false;
|
||||
let parsed = scopes
|
||||
.iter()
|
||||
.filter(|s| !s.starts_with("if_jobs:filter_tags:"))
|
||||
.inspect(|_| is_scoped = true)
|
||||
.filter_map(|s| ScopeDefinition::from_scope_string(s).ok())
|
||||
.collect();
|
||||
(is_scoped, parsed)
|
||||
}
|
||||
None => (false, Vec::new()),
|
||||
};
|
||||
let domain = domain.to_string();
|
||||
let action = action.to_string();
|
||||
|
||||
move |path: &str| -> bool {
|
||||
if !is_scoped_token {
|
||||
return true;
|
||||
}
|
||||
let required =
|
||||
match ScopeDefinition::from_scope_string(&format!("{}:{}:{}", domain, action, path)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return false,
|
||||
};
|
||||
parsed.iter().any(|s| s.includes(&required))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn require_devops_role(db: &DB, email: &str) -> error::Result<()> {
|
||||
let is_devops = is_devops_email(db, email).await?;
|
||||
|
||||
@@ -803,3 +847,65 @@ pub fn require_path_read_access_for_preview(
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn authed_with_scopes(scopes: Option<Vec<&str>>) -> ApiAuthed {
|
||||
ApiAuthed {
|
||||
scopes: scopes.map(|v| v.into_iter().map(String::from).collect()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicate_no_scopes_allows_all() {
|
||||
let authed = authed_with_scopes(None);
|
||||
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
assert!(allowed("u/alice/anything"));
|
||||
assert!(allowed("u/bob/other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicate_tag_filter_only_allows_all() {
|
||||
let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"]));
|
||||
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
assert!(allowed("u/alice/foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicate_single_resource_scope_filters_others() {
|
||||
// Regression test for WIN-1981: a token scoped to one resource must
|
||||
// not match unrelated paths in listings (e.g. /resources/list_search).
|
||||
let authed = authed_with_scopes(Some(vec!["resources:read:u/alice/allowed_resource"]));
|
||||
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
assert!(allowed("u/alice/allowed_resource"));
|
||||
assert!(!allowed("u/alice/other_resource"));
|
||||
assert!(!allowed("u/bob/foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicate_wildcard_scope_matches_subtree() {
|
||||
let authed = authed_with_scopes(Some(vec!["resources:read:f/team/*"]));
|
||||
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
assert!(allowed("f/team/db"));
|
||||
assert!(allowed("f/team/sub/nested"));
|
||||
assert!(!allowed("f/other/db"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicate_wrong_domain_is_rejected() {
|
||||
let authed = authed_with_scopes(Some(vec!["variables:read:u/alice/secret"]));
|
||||
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
assert!(!allowed("u/alice/secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn predicate_write_implies_read() {
|
||||
let authed = authed_with_scopes(Some(vec!["resources:write:u/alice/foo"]));
|
||||
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
assert!(allowed("u/alice/foo"));
|
||||
assert!(!allowed("u/alice/bar"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ use windmill_common::secret_backend::{
|
||||
AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings,
|
||||
};
|
||||
use windmill_common::{
|
||||
ee_oss::{get_license_plan, LicensePlan},
|
||||
email_oss::send_email_plain_text,
|
||||
error::{self, JsonResult, Result},
|
||||
get_database_url,
|
||||
@@ -54,12 +55,15 @@ 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,
|
||||
};
|
||||
use windmill_common::{error::to_anyhow, PgDatabase};
|
||||
use windmill_common::{error::to_anyhow, worker::CLOUD_HOSTED, PgDatabase};
|
||||
|
||||
/// Unauthenticated settings routes.
|
||||
///
|
||||
@@ -446,6 +450,27 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
/// Enterprise gate for the workspace-fairness settings. Workspace fairness is
|
||||
/// only useful on multi-tenant clusters where one workspace can starve other
|
||||
/// workspaces sharing the same worker pool, and the feature is licensed as
|
||||
/// part of Enterprise. Non-EE installs are rejected at write time; the runtime
|
||||
/// dispatch additionally honours the `WORKSPACE_FAIRNESS_ENABLED` toggle.
|
||||
async fn workspace_fairness_settings_allowed() -> bool {
|
||||
matches!(get_license_plan().await, LicensePlan::Enterprise)
|
||||
}
|
||||
|
||||
pub async fn set_global_setting(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
@@ -468,6 +493,31 @@ pub async fn set_global_setting_internal(
|
||||
value
|
||||
};
|
||||
|
||||
// EE gate for workspace-fairness settings. Workspace fairness only matters
|
||||
// on multi-tenant clusters; it is licensed as an Enterprise feature so the
|
||||
// setter rejects writes from non-EE builds. Disabling/clearing writes are
|
||||
// *always* allowed regardless of license, so an admin who downgrades from
|
||||
// EE (or imports a row from a cloned EE DB) can always turn the cap off:
|
||||
// - `Null` / empty-string → row delete
|
||||
// - `Bool(false)` on `workspace_fairness_enabled` → explicit disable
|
||||
// Without the `Bool(false)` carve-out, a stale `enabled=true` row from a
|
||||
// downgrade would be impossible to flip off through the normal API/UI
|
||||
// and the runtime path (which only checks the toggle) would keep
|
||||
// throttling.
|
||||
let is_clearing_value = matches!(&value, serde_json::Value::Null)
|
||||
|| matches!(&value, serde_json::Value::String(s) if s.trim().is_empty())
|
||||
|| (key == WORKSPACE_FAIRNESS_ENABLED_SETTING
|
||||
&& matches!(&value, serde_json::Value::Bool(false)));
|
||||
if is_workspace_fairness_setting(&key)
|
||||
&& !is_clearing_value
|
||||
&& !workspace_fairness_settings_allowed().await
|
||||
{
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"{} requires an Enterprise license",
|
||||
key
|
||||
)));
|
||||
}
|
||||
|
||||
run_setting_pre_write_hook(db, &key, &value).await?;
|
||||
|
||||
match value {
|
||||
@@ -545,7 +595,10 @@ async fn run_setting_pre_write_hook(
|
||||
)));
|
||||
};
|
||||
|
||||
if !*workspaced_route {
|
||||
// Cloud always scopes app custom paths by workspace_id (see
|
||||
// `custom_path_exists` in apps.rs), so duplicates across workspaces
|
||||
// are expected and this setting has no runtime effect on cloud.
|
||||
if !*workspaced_route && !*CLOUD_HOSTED {
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[allow(unused)]
|
||||
struct DuplicateApp {
|
||||
@@ -608,7 +661,11 @@ async fn run_setting_pre_write_hook(
|
||||
)));
|
||||
};
|
||||
|
||||
if !*workspaced_route {
|
||||
// Cloud always scopes routes by workspace_id (see
|
||||
// `route_path_key_exists` in windmill-trigger-http), so duplicates
|
||||
// across workspaces are expected and this setting has no runtime
|
||||
// effect on cloud.
|
||||
if !*workspaced_route && !*CLOUD_HOSTED {
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[allow(unused)]
|
||||
struct DuplicateRoute {
|
||||
@@ -726,6 +783,27 @@ async fn set_instance_config(
|
||||
.iter()
|
||||
.any(|(key, _)| key == AI_CONFIG_SETTING);
|
||||
|
||||
// Mirror the per-key EE gate in `set_global_setting_internal`. Without
|
||||
// this, the bulk endpoint would let a non-EE superadmin persist
|
||||
// `workspace_fairness_*` rows even though the per-key API rejects them.
|
||||
// Only block *non-disabling* upserts; deletes are allowed everywhere
|
||||
// (already filtered into `settings_diff.removals`) and a
|
||||
// `workspace_fairness_enabled=false` upsert is treated as a disable,
|
||||
// so a downgraded instance can always turn the cap off via the bulk
|
||||
// YAML endpoint too.
|
||||
let upserts_touch_fairness_non_disable = settings_diff.upserts.iter().any(|(k, v)| {
|
||||
if !is_workspace_fairness_setting(k) {
|
||||
return false;
|
||||
}
|
||||
!(k == WORKSPACE_FAIRNESS_ENABLED_SETTING
|
||||
&& matches!(v, serde_json::Value::Bool(false)))
|
||||
});
|
||||
if upserts_touch_fairness_non_disable && !workspace_fairness_settings_allowed().await {
|
||||
return Err(error::Error::BadRequest(
|
||||
"Workspace fairness settings require an Enterprise license".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
for (key, value) in &settings_diff.upserts {
|
||||
run_setting_pre_write_hook(&db, key, value).await?;
|
||||
}
|
||||
|
||||
@@ -207,6 +207,11 @@ pub struct GlobalUserInfo {
|
||||
username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
operator_only: Option<bool>,
|
||||
/// Populated only for service-account rows (which are workspace-scoped).
|
||||
/// `None` for password users since their admin status varies per workspace
|
||||
/// and is not surfaced by this aggregation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
is_workspace_admin: Option<bool>,
|
||||
first_time_user: bool,
|
||||
role_source: String,
|
||||
disabled: bool,
|
||||
@@ -455,11 +460,11 @@ async fn list_users_as_super_admin(
|
||||
GlobalUserInfo,
|
||||
r#"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),
|
||||
authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)
|
||||
SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id
|
||||
SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, NULL::bool as is_workspace_admin, login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id
|
||||
FROM password
|
||||
WHERE email IN (SELECT email FROM active_users)
|
||||
UNION ALL
|
||||
SELECT email as "email!", true as operator_only, 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id
|
||||
SELECT email as "email!", operator as operator_only, is_admin as is_workspace_admin, 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id
|
||||
FROM usr
|
||||
WHERE is_service_account IS true
|
||||
ORDER BY "super_admin!" DESC, "devops!" DESC
|
||||
@@ -472,9 +477,9 @@ async fn list_users_as_super_admin(
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
GlobalUserInfo,
|
||||
r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password
|
||||
r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password
|
||||
UNION ALL
|
||||
SELECT email as "email!", 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, true as operator_only, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id
|
||||
SELECT email as "email!", 'service_account'::text as login_type, true as "verified!", false as "super_admin!", false as "devops!", NULL::text as name, NULL::text as company, username, operator as operator_only, is_admin as is_workspace_admin, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id
|
||||
FROM usr
|
||||
WHERE is_service_account IS true
|
||||
ORDER BY "super_admin!" DESC, "devops!" DESC, "email!"
|
||||
@@ -727,7 +732,7 @@ async fn global_whoami(
|
||||
) -> JsonResult<GlobalUserInfo> {
|
||||
let user = sqlx::query_as!(
|
||||
GlobalUserInfo,
|
||||
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \
|
||||
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \
|
||||
email = $1",
|
||||
email
|
||||
)
|
||||
@@ -748,13 +753,24 @@ async fn global_whoami(
|
||||
company: None,
|
||||
username: None,
|
||||
operator_only: None,
|
||||
is_workspace_admin: None,
|
||||
first_time_user: false,
|
||||
role_source: "manual".to_string(),
|
||||
disabled: false,
|
||||
workspace_id: None,
|
||||
}))
|
||||
} else {
|
||||
// Service accounts don't have a password row
|
||||
// Service accounts don't have a password row. The SA email is unique
|
||||
// per (workspace, username) and pinpoints a single usr row, so we can
|
||||
// surface its real role rather than pinning to operator.
|
||||
let sa_role = sqlx::query!(
|
||||
"SELECT operator, is_admin FROM usr WHERE email = $1 AND is_service_account IS true LIMIT 1",
|
||||
email
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("fetching service-account role: {e:#}")))?;
|
||||
|
||||
Ok(Json(GlobalUserInfo {
|
||||
email: email.clone(),
|
||||
login_type: Some("service_account".to_string()),
|
||||
@@ -764,7 +780,8 @@ async fn global_whoami(
|
||||
name: None,
|
||||
company: None,
|
||||
username: None,
|
||||
operator_only: Some(true),
|
||||
operator_only: sa_role.as_ref().map(|r| r.operator).or(Some(true)),
|
||||
is_workspace_admin: sa_role.as_ref().map(|r| r.is_admin),
|
||||
first_time_user: false,
|
||||
role_source: "service_account".to_string(),
|
||||
disabled: false,
|
||||
|
||||
@@ -39,6 +39,10 @@ pub fn global_service() -> Router {
|
||||
.route("/queue_metrics", get(get_queue_metrics))
|
||||
.route("/queue_counts", get(get_queue_counts))
|
||||
.route("/queue_running_counts", get(get_queue_running_counts))
|
||||
.route(
|
||||
"/workspace_fairness_events",
|
||||
get(get_workspace_fairness_events),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
@@ -283,3 +287,77 @@ async fn get_queue_running_counts(
|
||||
let queue_running_counts = windmill_common::queue::get_queue_running_counts(&db).await;
|
||||
Ok(Json(queue_running_counts))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WorkspaceFairnessEvent {
|
||||
pub timestamp: chrono::DateTime<chrono::Utc>,
|
||||
pub operation: String,
|
||||
/// Affected workspace (stored in audit log `resource`). `None` only for very
|
||||
/// old rows pre-dating the resource convention — UI should treat as "unknown".
|
||||
pub workspace_id: Option<String>,
|
||||
/// Snapshot of the relevant fairness settings at the time of the transition
|
||||
/// (`max_percent`, `window_secs`, `total_overloaded`). `None` for uncap rows.
|
||||
pub parameters: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Return the most recent ~200 cap and ~200 uncap transitions (merged into
|
||||
/// at most 400 rows) written by `workspace_fairness::emit_transition_audit`.
|
||||
/// Workspace fairness is an Enterprise feature; on non-EE / non-enabled
|
||||
/// instances the table is naturally empty.
|
||||
async fn get_workspace_fairness_events(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
) -> JsonResult<Vec<WorkspaceFairnessEvent>> {
|
||||
require_devops_role(&db, &authed.email).await?;
|
||||
|
||||
// No cloud-host gate — workspace fairness is an Enterprise feature
|
||||
// available on any multi-tenant EE deployment. Non-EE / non-enabled
|
||||
// instances will simply have no audit rows of these operation types,
|
||||
// so the table is naturally empty.
|
||||
//
|
||||
// Return the most recent 200 cap **and** the most recent 200 uncap
|
||||
// events separately, then merge — without this, a long stretch of caps
|
||||
// can push every uncap off the unified `LIMIT 200` window and the UI
|
||||
// appears to "never record uncaps". (The unified ordered limit was a
|
||||
// real footgun in production audit drawers.)
|
||||
let events = sqlx::query_as!(
|
||||
WorkspaceFairnessEvent,
|
||||
r#"
|
||||
WITH capped AS (
|
||||
SELECT timestamp, operation, resource, parameters
|
||||
FROM audit_partitioned
|
||||
WHERE workspace_id = 'admins'
|
||||
AND operation = 'workspace_fairness.capped'
|
||||
UNION ALL
|
||||
SELECT timestamp, operation, resource, parameters
|
||||
FROM audit
|
||||
WHERE workspace_id = 'admins'
|
||||
AND operation = 'workspace_fairness.capped'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 200
|
||||
), uncapped AS (
|
||||
SELECT timestamp, operation, resource, parameters
|
||||
FROM audit_partitioned
|
||||
WHERE workspace_id = 'admins'
|
||||
AND operation = 'workspace_fairness.uncapped'
|
||||
UNION ALL
|
||||
SELECT timestamp, operation, resource, parameters
|
||||
FROM audit
|
||||
WHERE workspace_id = 'admins'
|
||||
AND operation = 'workspace_fairness.uncapped'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 200
|
||||
)
|
||||
SELECT timestamp AS "timestamp!",
|
||||
operation::text AS "operation!",
|
||||
resource AS workspace_id,
|
||||
parameters
|
||||
FROM (SELECT * FROM capped UNION ALL SELECT * FROM uncapped) e
|
||||
ORDER BY timestamp DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(events))
|
||||
}
|
||||
|
||||
@@ -5451,6 +5451,16 @@ If you do not have an account on {}, login with SSO or ask an admin to create an
|
||||
#[derive(Deserialize)]
|
||||
pub struct NewServiceAccount {
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub is_admin: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub operator: bool,
|
||||
#[serde(default)]
|
||||
pub add_to_deployers: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
async fn create_service_account(
|
||||
|
||||
@@ -40,7 +40,7 @@ gcp_trigger = ["dep:windmill-trigger-gcp", "windmill-store/gcp_trigger"]
|
||||
azure_trigger = ["dep:windmill-trigger-azure", "windmill-store/azure_trigger"]
|
||||
cloud = ["windmill-common/cloud", "windmill-api-auth/cloud", "windmill-store/cloud", "windmill-api-workspaces/cloud"]
|
||||
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth", "windmill-api-auth/mcp", "windmill-store/mcp"]
|
||||
bedrock = ["windmill-ai/bedrock", "dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
|
||||
bedrock = ["windmill-ai/bedrock"]
|
||||
python = ["windmill-dep-map/python", "dep:windmill-parser-py", "dep:windmill-parser-py-imports", "windmill-api-scripts/python", "windmill-api-configs/python", "windmill-api-agent-workers?/python", "windmill-trigger/python", "windmill-common/python"]
|
||||
no_auth = ["windmill-api-auth/no_auth", "windmill-store/no_auth", "windmill-api-users/no_auth"]
|
||||
quickjs = ["windmill-jseval/quickjs"]
|
||||
@@ -172,11 +172,6 @@ rustls = { workspace = true }
|
||||
|
||||
aws-sigv4 = { workspace = true, optional = true }
|
||||
aws-sdk-config = { workspace = true, optional = true }
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-credential-types = { workspace = true, optional = true }
|
||||
aws-sdk-bedrock = { workspace = true, optional = true }
|
||||
aws-sdk-bedrockruntime = { workspace = true, optional = true }
|
||||
aws-smithy-types = { workspace = true, optional = true }
|
||||
async-trait.workspace = true
|
||||
eventsource-stream.workspace = true
|
||||
windmill-jseval.workspace = true
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2554
-1503
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.706.1
|
||||
version: 1.709.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
|
||||
@@ -2718,6 +2816,15 @@ paths:
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
is_admin:
|
||||
type: boolean
|
||||
description: Grant the service account workspace admin. Defaults to false. Cannot be combined with operator=true.
|
||||
operator:
|
||||
type: boolean
|
||||
description: Make the service account an operator. Defaults to true for backward compatibility. Set to false to count as a developer (1 seat) instead of 0.5 seat.
|
||||
add_to_deployers:
|
||||
type: boolean
|
||||
description: Add the service account to the workspace `wm_deployers` group on creation. Recommended when the account will be used as a CLI sync / CI deploy identity so it can deploy on behalf of other users.
|
||||
required:
|
||||
- username
|
||||
responses:
|
||||
@@ -17653,6 +17760,38 @@ paths:
|
||||
additionalProperties:
|
||||
type: integer
|
||||
|
||||
/workers/workspace_fairness_events:
|
||||
get:
|
||||
summary: list last 100 workspace-fairness cap/uncap events (cloud-only)
|
||||
operationId: getWorkspaceFairnessEvents
|
||||
tags:
|
||||
- worker
|
||||
responses:
|
||||
"200":
|
||||
description: workspace fairness events (empty on non-cloud)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
operation:
|
||||
type: string
|
||||
workspace_id:
|
||||
type: string
|
||||
nullable: true
|
||||
parameters:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: true
|
||||
required:
|
||||
- timestamp
|
||||
- operation
|
||||
|
||||
/configs/list_worker_groups:
|
||||
get:
|
||||
summary: list worker groups
|
||||
@@ -26542,6 +26681,9 @@ components:
|
||||
type: string
|
||||
operator_only:
|
||||
type: boolean
|
||||
is_workspace_admin:
|
||||
type: boolean
|
||||
description: Populated only for service accounts. True if the service account has workspace admin in its (single) workspace.
|
||||
first_time_user:
|
||||
type: boolean
|
||||
role_source:
|
||||
@@ -27893,6 +28035,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
|
||||
|
||||
+175
-350
@@ -1,5 +1,3 @@
|
||||
#[cfg(feature = "bedrock")]
|
||||
use crate::bedrock;
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
use crate::utils::check_scopes;
|
||||
|
||||
@@ -20,8 +18,12 @@ use windmill_ai::ai_cache::current_instance_ai_config_revision;
|
||||
use windmill_ai::ai_providers::{
|
||||
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
|
||||
};
|
||||
#[cfg(feature = "bedrock")]
|
||||
use windmill_ai::providers::bedrock::{
|
||||
handle_bedrock_proxy, BedrockProxyResponse, BedrockProxyResponseBody,
|
||||
};
|
||||
use windmill_ai::providers::{
|
||||
create_proxy_query_builder,
|
||||
create_query_builder,
|
||||
google_ai::{
|
||||
handle_google_ai_chat_proxy, handle_google_ai_models_proxy, GoogleAIProxyResponse,
|
||||
GoogleAIProxyResponseBody,
|
||||
@@ -112,7 +114,7 @@ lazy_static::lazy_static! {
|
||||
|
||||
static ref OPENAI_AZURE_BASE_PATH: Option<String> = std::env::var("OPENAI_AZURE_BASE_PATH").ok();
|
||||
|
||||
pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringAIRequestConfig> = Cache::new(500);
|
||||
pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringProviderCredentials> = Cache::new(500);
|
||||
|
||||
}
|
||||
|
||||
@@ -179,26 +181,6 @@ enum AIResource {
|
||||
Standard(AIStandardResource),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
struct AIRequestConfig {
|
||||
pub base_url: String,
|
||||
pub api_key: Option<String>,
|
||||
pub access_token: Option<String>,
|
||||
pub organization_id: Option<String>,
|
||||
pub user: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub region: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
pub aws_session_token: Option<String>,
|
||||
pub platform: AIPlatform,
|
||||
pub enable_1m_context: bool,
|
||||
pub custom_headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Resolve a `$var:` reference. When `user_db`/`authed` are provided the query
|
||||
/// goes through an RLS-scoped connection so the caller can only read variables
|
||||
/// they are authorised to access. Without auth context the raw pool is used
|
||||
@@ -216,196 +198,142 @@ async fn resolve_var(
|
||||
}
|
||||
}
|
||||
|
||||
impl AIRequestConfig {
|
||||
pub async fn new(
|
||||
provider: &AIProvider,
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
resource: AIResource,
|
||||
authed: Option<&ApiAuthed>,
|
||||
) -> Result<Self> {
|
||||
// When authed is provided, resolve $var: references through RLS so that
|
||||
// users can only read variables they have permission to access.
|
||||
let user_db = authed.map(|_| UserDB::new(db.clone()));
|
||||
async fn resolve_provider_credentials(
|
||||
provider: &AIProvider,
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
resource: AIResource,
|
||||
authed: Option<&ApiAuthed>,
|
||||
) -> Result<ProviderCredentials> {
|
||||
// When authed is provided, resolve $var: references through RLS so that
|
||||
// users can only read variables they have permission to access.
|
||||
let user_db = authed.map(|_| UserDB::new(db.clone()));
|
||||
|
||||
let (
|
||||
api_key,
|
||||
access_token,
|
||||
organization_id,
|
||||
base_url,
|
||||
user,
|
||||
region,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
platform,
|
||||
enable_1m_context,
|
||||
custom_headers,
|
||||
) = match resource {
|
||||
AIResource::Standard(resource) => {
|
||||
let region = resource.region.clone();
|
||||
let platform = resource.platform.clone();
|
||||
let enable_1m_context = resource.enable_1m_context;
|
||||
let custom_headers = resource.headers.clone();
|
||||
// Skip get_base_url for Bedrock - it uses SDK directly, not HTTP
|
||||
let base_url = if matches!(provider, AIProvider::AWSBedrock) {
|
||||
String::new()
|
||||
} else {
|
||||
provider.get_base_url(resource.base_url, db).await?
|
||||
};
|
||||
let api_key = if let Some(api_key) = resource.api_key {
|
||||
Some(resolve_var(api_key, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let organization_id = if let Some(organization_id) = resource.organization_id {
|
||||
Some(resolve_var(organization_id, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let aws_access_key_id = if let Some(access_key_id) = resource.aws_access_key_id {
|
||||
Some(resolve_var(access_key_id, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let aws_secret_access_key = if let Some(secret_access_key) =
|
||||
resource.aws_secret_access_key
|
||||
{
|
||||
match resource {
|
||||
AIResource::Standard(resource) => {
|
||||
// Skip get_base_url for Bedrock - it uses SDK directly, not HTTP
|
||||
let base_url = if matches!(provider, AIProvider::AWSBedrock) {
|
||||
String::new()
|
||||
} else {
|
||||
provider.get_base_url(resource.base_url, db).await?
|
||||
};
|
||||
let api_key = if let Some(api_key) = resource.api_key {
|
||||
Some(resolve_var(api_key, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let organization_id = if let Some(organization_id) = resource.organization_id {
|
||||
Some(resolve_var(organization_id, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let aws_access_key_id = if let Some(access_key_id) = resource.aws_access_key_id {
|
||||
Some(resolve_var(access_key_id, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let aws_secret_access_key =
|
||||
if let Some(secret_access_key) = resource.aws_secret_access_key {
|
||||
Some(resolve_var(secret_access_key, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let aws_session_token = if let Some(session_token) = resource.aws_session_token {
|
||||
Some(resolve_var(session_token, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let aws_session_token = if let Some(session_token) = resource.aws_session_token {
|
||||
Some(resolve_var(session_token, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
(
|
||||
api_key,
|
||||
None,
|
||||
organization_id,
|
||||
base_url,
|
||||
None,
|
||||
region,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
platform,
|
||||
enable_1m_context,
|
||||
custom_headers,
|
||||
)
|
||||
}
|
||||
AIResource::OAuth(resource) => {
|
||||
let user = if let Some(user) = resource.user.clone() {
|
||||
Some(resolve_var(user, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let token =
|
||||
Self::get_token_using_oauth(resource, db, w_id, user_db.as_ref(), authed)
|
||||
.await?;
|
||||
let base_url = provider.get_base_url(None, db).await?;
|
||||
Ok(ProviderCredentials {
|
||||
provider: provider.clone(),
|
||||
base_url,
|
||||
api_key,
|
||||
access_token: None,
|
||||
organization_id,
|
||||
user: None,
|
||||
region: resource.region,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
platform: resource.platform,
|
||||
enable_1m_context: resource.enable_1m_context,
|
||||
custom_headers: resource.headers,
|
||||
})
|
||||
}
|
||||
AIResource::OAuth(resource) => {
|
||||
let user = if let Some(user) = resource.user.clone() {
|
||||
Some(resolve_var(user, db, w_id, user_db.as_ref(), authed).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let token = get_token_using_oauth(resource, db, w_id, user_db.as_ref(), authed).await?;
|
||||
let base_url = provider.get_base_url(None, db).await?;
|
||||
|
||||
(
|
||||
None,
|
||||
Some(token),
|
||||
None,
|
||||
base_url,
|
||||
user,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
AIPlatform::Standard,
|
||||
false,
|
||||
HashMap::new(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
base_url,
|
||||
organization_id,
|
||||
api_key,
|
||||
access_token,
|
||||
user,
|
||||
region,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
platform,
|
||||
enable_1m_context,
|
||||
custom_headers,
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_token_using_oauth(
|
||||
mut resource: AIOAuthResource,
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
user_db: Option<&UserDB>,
|
||||
authed: Option<&ApiAuthed>,
|
||||
) -> Result<String> {
|
||||
resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?;
|
||||
resource.client_secret =
|
||||
resolve_var(resource.client_secret, db, w_id, user_db, authed).await?;
|
||||
resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?;
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "client_credentials");
|
||||
params.insert("scope", "https://cognitiveservices.azure.com/.default");
|
||||
let response = HTTP_CLIENT
|
||||
.post(resource.token_url)
|
||||
.form(¶ms)
|
||||
.basic_auth(resource.client_id, Some(resource.client_secret))
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
.map_err(|err| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to get access token using credentials flow: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
let response = response.json::<OAuthTokens>().await.map_err(|err| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to parse access token from credentials flow: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
Ok(response.access_token)
|
||||
}
|
||||
|
||||
fn into_provider_credentials(self, provider: AIProvider) -> ProviderCredentials {
|
||||
ProviderCredentials {
|
||||
provider,
|
||||
base_url: self.base_url,
|
||||
api_key: self.api_key,
|
||||
access_token: self.access_token,
|
||||
organization_id: self.organization_id,
|
||||
user: self.user,
|
||||
region: self.region,
|
||||
aws_access_key_id: self.aws_access_key_id,
|
||||
aws_secret_access_key: self.aws_secret_access_key,
|
||||
aws_session_token: self.aws_session_token,
|
||||
platform: self.platform,
|
||||
enable_1m_context: self.enable_1m_context,
|
||||
custom_headers: self.custom_headers,
|
||||
Ok(ProviderCredentials {
|
||||
provider: provider.clone(),
|
||||
base_url,
|
||||
api_key: None,
|
||||
access_token: Some(token),
|
||||
organization_id: None,
|
||||
user,
|
||||
region: None,
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
platform: AIPlatform::Standard,
|
||||
enable_1m_context: false,
|
||||
custom_headers: HashMap::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_token_using_oauth(
|
||||
mut resource: AIOAuthResource,
|
||||
db: &DB,
|
||||
w_id: &str,
|
||||
user_db: Option<&UserDB>,
|
||||
authed: Option<&ApiAuthed>,
|
||||
) -> Result<String> {
|
||||
resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?;
|
||||
resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?;
|
||||
resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?;
|
||||
let mut params = HashMap::new();
|
||||
params.insert("grant_type", "client_credentials");
|
||||
params.insert("scope", "https://cognitiveservices.azure.com/.default");
|
||||
let response = HTTP_CLIENT
|
||||
.post(resource.token_url)
|
||||
.form(¶ms)
|
||||
.basic_auth(resource.client_id, Some(resource.client_secret))
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
.map_err(|err| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to get access token using credentials flow: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
let response = response.json::<OAuthTokens>().await.map_err(|err| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to parse access token from credentials flow: {}",
|
||||
err
|
||||
))
|
||||
})?;
|
||||
Ok(response.access_token)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExpiringAIRequestConfig {
|
||||
config: AIRequestConfig,
|
||||
pub struct ExpiringProviderCredentials {
|
||||
credentials: ProviderCredentials,
|
||||
expires_at: std::time::Instant,
|
||||
instance_ai_config_revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl ExpiringAIRequestConfig {
|
||||
fn new(config: AIRequestConfig, instance_ai_config_revision: Option<u64>) -> Self {
|
||||
impl ExpiringProviderCredentials {
|
||||
fn new(credentials: ProviderCredentials, instance_ai_config_revision: Option<u64>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
credentials,
|
||||
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
|
||||
instance_ai_config_revision,
|
||||
}
|
||||
@@ -540,6 +468,18 @@ fn google_ai_proxy_response_to_body(
|
||||
(response.status_code, response.headers, body)
|
||||
}
|
||||
|
||||
#[cfg(feature = "bedrock")]
|
||||
fn bedrock_proxy_response_to_body(
|
||||
response: BedrockProxyResponse,
|
||||
) -> (http::StatusCode, HeaderMap, axum::body::Body) {
|
||||
let body = match response.body {
|
||||
BedrockProxyResponseBody::Fixed(body) => axum::body::Body::from(body),
|
||||
BedrockProxyResponseBody::Stream(stream) => axum::body::Body::from_stream(stream),
|
||||
};
|
||||
|
||||
(response.status_code, response.headers, body)
|
||||
}
|
||||
|
||||
pub(crate) fn inject_keepalives<S>(
|
||||
upstream: S,
|
||||
interval: Duration,
|
||||
@@ -608,7 +548,7 @@ async fn global_proxy(
|
||||
enable_1m_context: false,
|
||||
custom_headers: HashMap::new(),
|
||||
};
|
||||
let query_builder = create_proxy_query_builder(&credentials);
|
||||
let query_builder = create_query_builder(&credentials);
|
||||
let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs {
|
||||
method: &method,
|
||||
path: &ai_path,
|
||||
@@ -701,9 +641,9 @@ async fn proxy(
|
||||
check_scopes(&authed, || format!("resources:read:{}", resource_path))?;
|
||||
}
|
||||
|
||||
let request_config = match workspace_cache {
|
||||
let credentials = match workspace_cache {
|
||||
Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => {
|
||||
request_cache.config
|
||||
request_cache.credentials
|
||||
}
|
||||
_ => {
|
||||
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
|
||||
@@ -811,7 +751,7 @@ async fn proxy(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let request_config = AIRequestConfig::new(
|
||||
let credentials = resolve_provider_credentials(
|
||||
&provider,
|
||||
&db,
|
||||
&resource_workspace,
|
||||
@@ -822,13 +762,13 @@ async fn proxy(
|
||||
if save_to_cache {
|
||||
AI_REQUEST_CACHE.insert(
|
||||
(w_id.clone(), provider.clone()),
|
||||
ExpiringAIRequestConfig::new(
|
||||
request_config.clone(),
|
||||
ExpiringProviderCredentials::new(
|
||||
credentials.clone(),
|
||||
instance_ai_config_revision,
|
||||
),
|
||||
);
|
||||
}
|
||||
request_config
|
||||
credentials
|
||||
}
|
||||
};
|
||||
|
||||
@@ -862,7 +802,6 @@ async fn proxy(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let credentials = request_config.into_provider_credentials(provider.clone());
|
||||
let proxy_args = ProxyBuildArgs {
|
||||
method: &method,
|
||||
path: &ai_path,
|
||||
@@ -885,95 +824,30 @@ async fn proxy(
|
||||
|
||||
// Handle Bedrock-specific logic when the feature is enabled
|
||||
#[cfg(feature = "bedrock")]
|
||||
{
|
||||
// Extract model and streaming flag for Bedrock transformation (only for POST requests)
|
||||
let (model, is_streaming) = if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock)
|
||||
&& method == Method::POST
|
||||
{
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct BedrockRequest {
|
||||
model: String,
|
||||
#[serde(default)]
|
||||
stream: bool,
|
||||
}
|
||||
let parsed: BedrockRequest = serde_json::from_slice(&body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?;
|
||||
(Some(parsed.model), parsed.stream)
|
||||
} else {
|
||||
(None, false)
|
||||
};
|
||||
if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) {
|
||||
let mut tx = db.begin().await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"ai.request",
|
||||
ActionKind::Execute,
|
||||
&w_id,
|
||||
Some(&authed.email),
|
||||
Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// For Bedrock requests, use the SDK-based approach
|
||||
if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) {
|
||||
let region = request_config
|
||||
.region
|
||||
.as_deref()
|
||||
.unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION);
|
||||
let response = handle_bedrock_proxy(&ProxyBuildArgs {
|
||||
method: &method,
|
||||
path: &ai_path,
|
||||
headers: &headers,
|
||||
body: &body,
|
||||
credentials: &credentials,
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Audit log before making the SDK request
|
||||
let mut tx = db.begin().await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"ai.request",
|
||||
ActionKind::Execute,
|
||||
&w_id,
|
||||
Some(&authed.email),
|
||||
Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()),
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
// Handle GET requests for control plane operations
|
||||
if method == Method::GET {
|
||||
if ai_path == "foundation-models" {
|
||||
return bedrock::list_foundation_models(
|
||||
request_config.api_key.as_deref(),
|
||||
request_config.aws_access_key_id.as_deref(),
|
||||
request_config.aws_secret_access_key.as_deref(),
|
||||
request_config.aws_session_token.as_deref(),
|
||||
region,
|
||||
)
|
||||
.await;
|
||||
} else if ai_path == "inference-profiles" {
|
||||
return bedrock::list_inference_profiles(
|
||||
request_config.api_key.as_deref(),
|
||||
request_config.aws_access_key_id.as_deref(),
|
||||
request_config.aws_secret_access_key.as_deref(),
|
||||
request_config.aws_session_token.as_deref(),
|
||||
region,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle POST requests for inference
|
||||
if method == Method::POST && model.is_some() {
|
||||
if is_streaming {
|
||||
return bedrock::handle_bedrock_sdk_streaming(
|
||||
model.as_ref().unwrap(),
|
||||
&body,
|
||||
request_config.api_key.as_deref(),
|
||||
request_config.aws_access_key_id.as_deref(),
|
||||
request_config.aws_secret_access_key.as_deref(),
|
||||
request_config.aws_session_token.as_deref(),
|
||||
region,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
return bedrock::handle_bedrock_sdk_non_streaming(
|
||||
model.as_ref().unwrap(),
|
||||
&body,
|
||||
request_config.api_key.as_deref(),
|
||||
request_config.aws_access_key_id.as_deref(),
|
||||
request_config.aws_secret_access_key.as_deref(),
|
||||
request_config.aws_session_token.as_deref(),
|
||||
region,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(bedrock_proxy_response_to_body(response));
|
||||
}
|
||||
|
||||
// When bedrock feature is disabled, return error for Bedrock provider
|
||||
@@ -986,8 +860,7 @@ async fn proxy(
|
||||
|
||||
let request = match proxy_mode {
|
||||
ProxyExecutionMode::HttpForward => {
|
||||
let credentials = request_config.into_provider_credentials(provider.clone());
|
||||
let query_builder = create_proxy_query_builder(&credentials);
|
||||
let query_builder = create_query_builder(&credentials);
|
||||
let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs {
|
||||
method: &method,
|
||||
path: &ai_path,
|
||||
@@ -1053,8 +926,9 @@ mod tests {
|
||||
|
||||
static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
fn sample_request_config() -> AIRequestConfig {
|
||||
AIRequestConfig {
|
||||
fn sample_provider_credentials() -> ProviderCredentials {
|
||||
ProviderCredentials {
|
||||
provider: AIProvider::OpenAI,
|
||||
base_url: "https://example.com".to_string(),
|
||||
api_key: None,
|
||||
access_token: None,
|
||||
@@ -1070,70 +944,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_request_config_to_provider_credentials() {
|
||||
let mut custom_headers = HashMap::new();
|
||||
custom_headers.insert("X-Test".to_string(), "yes".to_string());
|
||||
|
||||
let config = AIRequestConfig {
|
||||
base_url: "https://example.com".to_string(),
|
||||
api_key: Some("api-key".to_string()),
|
||||
access_token: Some("access-token".to_string()),
|
||||
organization_id: Some("org-id".to_string()),
|
||||
user: Some("user-id".to_string()),
|
||||
region: Some("us-east-1".to_string()),
|
||||
aws_access_key_id: Some("aws-access-key".to_string()),
|
||||
aws_secret_access_key: Some("aws-secret-key".to_string()),
|
||||
aws_session_token: Some("aws-session-token".to_string()),
|
||||
platform: AIPlatform::GoogleVertexAi,
|
||||
enable_1m_context: true,
|
||||
custom_headers,
|
||||
};
|
||||
|
||||
let credentials = config.into_provider_credentials(AIProvider::Anthropic);
|
||||
|
||||
assert_eq!(credentials.provider, AIProvider::Anthropic);
|
||||
assert_eq!(credentials.base_url, "https://example.com");
|
||||
assert_eq!(credentials.api_key.as_deref(), Some("api-key"));
|
||||
assert_eq!(credentials.access_token.as_deref(), Some("access-token"));
|
||||
assert_eq!(credentials.organization_id.as_deref(), Some("org-id"));
|
||||
assert_eq!(credentials.user.as_deref(), Some("user-id"));
|
||||
assert_eq!(credentials.region.as_deref(), Some("us-east-1"));
|
||||
assert_eq!(
|
||||
credentials.aws_access_key_id.as_deref(),
|
||||
Some("aws-access-key")
|
||||
);
|
||||
assert_eq!(
|
||||
credentials.aws_secret_access_key.as_deref(),
|
||||
Some("aws-secret-key")
|
||||
);
|
||||
assert_eq!(
|
||||
credentials.aws_session_token.as_deref(),
|
||||
Some("aws-session-token")
|
||||
);
|
||||
assert_eq!(credentials.platform, AIPlatform::GoogleVertexAi);
|
||||
assert!(credentials.enable_1m_context);
|
||||
assert_eq!(
|
||||
credentials.custom_headers.get("X-Test").map(String::as_str),
|
||||
Some("yes")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidates_all_cached_providers_for_workspace() {
|
||||
let _guard = TEST_LOCK.lock().unwrap();
|
||||
AI_REQUEST_CACHE.clear();
|
||||
AI_REQUEST_CACHE.insert(
|
||||
("workspace-a".to_string(), AIProvider::OpenAI),
|
||||
ExpiringAIRequestConfig::new(sample_request_config(), None),
|
||||
ExpiringProviderCredentials::new(sample_provider_credentials(), None),
|
||||
);
|
||||
AI_REQUEST_CACHE.insert(
|
||||
("workspace-a".to_string(), AIProvider::Anthropic),
|
||||
ExpiringAIRequestConfig::new(sample_request_config(), None),
|
||||
ExpiringProviderCredentials::new(sample_provider_credentials(), None),
|
||||
);
|
||||
AI_REQUEST_CACHE.insert(
|
||||
("workspace-b".to_string(), AIProvider::OpenAI),
|
||||
ExpiringAIRequestConfig::new(sample_request_config(), None),
|
||||
ExpiringProviderCredentials::new(sample_provider_credentials(), None),
|
||||
);
|
||||
|
||||
invalidate_ai_request_cache_for_workspace("workspace-a");
|
||||
@@ -1154,8 +979,8 @@ mod tests {
|
||||
let _guard = TEST_LOCK.lock().unwrap();
|
||||
AI_REQUEST_CACHE.clear();
|
||||
|
||||
let cached = ExpiringAIRequestConfig::new(
|
||||
sample_request_config(),
|
||||
let cached = ExpiringProviderCredentials::new(
|
||||
sample_provider_credentials(),
|
||||
Some(current_instance_ai_config_revision()),
|
||||
);
|
||||
assert!(!cached.is_expired());
|
||||
|
||||
@@ -1,873 +0,0 @@
|
||||
//! AWS Bedrock SDK-based operations for the AI chat proxy.
|
||||
//!
|
||||
//! This module provides SDK-based request handling for Bedrock:
|
||||
//!
|
||||
//! ## Inference (Runtime SDK):
|
||||
//! - `handle_bedrock_sdk_streaming`: Uses BedrockClient for streaming requests
|
||||
//! - `handle_bedrock_sdk_non_streaming`: Uses BedrockClient for non-streaming requests
|
||||
//! - `sdk_stream_to_sse`: Converts SDK ConverseStream events to SSE format
|
||||
//!
|
||||
//! ## Control Plane (Bedrock SDK):
|
||||
//! - `list_foundation_models`: Lists available foundation models
|
||||
//! - `list_inference_profiles`: Lists inference profiles
|
||||
//!
|
||||
//! Shared AWS SDK code is available in `windmill_common::ai_bedrock`, including:
|
||||
//! - `BedrockClient`: SDK wrapper with bearer token and IAM auth
|
||||
//! - Stream event parsing functions
|
||||
//! - Helper utilities
|
||||
|
||||
use axum::body::Bytes;
|
||||
use serde::Deserialize;
|
||||
use windmill_ai::ai_bedrock::build_tool_config;
|
||||
use windmill_ai::ai_bedrock::{
|
||||
bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text,
|
||||
bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_start, format_bedrock_error,
|
||||
BedrockClient,
|
||||
};
|
||||
use windmill_ai::ai_types::{
|
||||
OpenAIFunction, OpenAIMessage, OpenAIToolCall, ToolDef, ToolDefFunction,
|
||||
};
|
||||
use windmill_common::error::{Error, Result};
|
||||
|
||||
// ============================================================================
|
||||
// Shared Request Types for SDK-Based Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// OpenAI-format request body for Bedrock SDK handlers
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct OpenAIRequest {
|
||||
messages: Vec<OpenAIMessage>,
|
||||
#[serde(default)]
|
||||
tools: Option<Vec<OpenAIToolDef>>,
|
||||
#[serde(default)]
|
||||
tool_choice: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
max_tokens: Option<i32>,
|
||||
#[serde(default)]
|
||||
temperature: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct OpenAIToolDef {
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
r#type: Option<String>,
|
||||
function: OpenAIToolFunction,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct OpenAIToolFunction {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
parameters: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Shared Helper Functions for SDK-Based Handlers
|
||||
// ============================================================================
|
||||
|
||||
/// Authentication configuration for Bedrock clients
|
||||
enum BedrockAuthConfig {
|
||||
BearerToken(String),
|
||||
IamCredentials {
|
||||
access_key_id: String,
|
||||
secret_access_key: String,
|
||||
session_token: Option<String>,
|
||||
},
|
||||
Environment,
|
||||
}
|
||||
|
||||
/// Determine auth configuration with priority: bearer token → IAM credentials → environment
|
||||
fn determine_auth_config(
|
||||
api_key: Option<&str>,
|
||||
aws_access_key_id: Option<&str>,
|
||||
aws_secret_access_key: Option<&str>,
|
||||
aws_session_token: Option<&str>,
|
||||
) -> BedrockAuthConfig {
|
||||
if let Some(key) = api_key.filter(|k| !k.is_empty()) {
|
||||
BedrockAuthConfig::BearerToken(key.to_string())
|
||||
} else if let (Some(access_key_id), Some(secret_access_key)) = (
|
||||
aws_access_key_id.filter(|s| !s.is_empty()),
|
||||
aws_secret_access_key.filter(|s| !s.is_empty()),
|
||||
) {
|
||||
BedrockAuthConfig::IamCredentials {
|
||||
access_key_id: access_key_id.to_string(),
|
||||
secret_access_key: secret_access_key.to_string(),
|
||||
session_token: aws_session_token
|
||||
.filter(|token| !token.is_empty())
|
||||
.map(str::to_string),
|
||||
}
|
||||
} else {
|
||||
BedrockAuthConfig::Environment
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a BedrockClient with auth priority: bearer token → IAM credentials → environment
|
||||
async fn create_bedrock_client(
|
||||
api_key: Option<&str>,
|
||||
aws_access_key_id: Option<&str>,
|
||||
aws_secret_access_key: Option<&str>,
|
||||
aws_session_token: Option<&str>,
|
||||
region: &str,
|
||||
) -> Result<BedrockClient> {
|
||||
match determine_auth_config(
|
||||
api_key,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
) {
|
||||
BedrockAuthConfig::BearerToken(key) => BedrockClient::from_bearer_token(key, region).await,
|
||||
BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => {
|
||||
BedrockClient::from_credentials(access_key_id, secret_access_key, session_token, region)
|
||||
.await
|
||||
}
|
||||
BedrockAuthConfig::Environment => BedrockClient::from_env(region).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert OpenAIToolDef array to tool configuration for Bedrock SDK
|
||||
fn build_tool_config_from_request(
|
||||
tools: Option<&[OpenAIToolDef]>,
|
||||
tool_choice: Option<&serde_json::Value>,
|
||||
enable_prompt_caching: bool,
|
||||
) -> Result<Option<aws_sdk_bedrockruntime::types::ToolConfiguration>> {
|
||||
if let Some(tools) = tools {
|
||||
let tool_defs: Vec<ToolDef> = tools
|
||||
.iter()
|
||||
.map(|t| ToolDef {
|
||||
r#type: "function".to_string(),
|
||||
function: ToolDefFunction {
|
||||
name: t.function.name.clone(),
|
||||
description: t.function.description.clone(),
|
||||
parameters: Box::from(
|
||||
serde_json::value::RawValue::from_string(
|
||||
serde_json::to_string(
|
||||
&t.function
|
||||
.parameters
|
||||
.clone()
|
||||
.unwrap_or(serde_json::json!({})),
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
serde_json::value::RawValue::from_string("{}".to_string()).unwrap()
|
||||
}),
|
||||
),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Determine if we should force tool use based on tool_choice
|
||||
let force_tool_use = tool_choice
|
||||
.map(|tc| tc == "required" || tc.as_str() == Some("required"))
|
||||
.unwrap_or(false);
|
||||
|
||||
build_tool_config(Some(&tool_defs), force_tool_use, enable_prompt_caching)
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Control Plane Operations (using aws-sdk-bedrock)
|
||||
// ============================================================================
|
||||
|
||||
/// Create a Bedrock control plane client with auth priority: bearer token → IAM credentials → environment
|
||||
async fn create_bedrock_control_client(
|
||||
api_key: Option<&str>,
|
||||
aws_access_key_id: Option<&str>,
|
||||
aws_secret_access_key: Option<&str>,
|
||||
aws_session_token: Option<&str>,
|
||||
region: &str,
|
||||
) -> Result<aws_sdk_bedrock::Client> {
|
||||
use aws_config::BehaviorVersion;
|
||||
use windmill_ai::ai_bedrock::BearerTokenProvider;
|
||||
|
||||
let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string());
|
||||
|
||||
match determine_auth_config(
|
||||
api_key,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
) {
|
||||
BedrockAuthConfig::BearerToken(key) => {
|
||||
let config = aws_sdk_bedrock::config::Builder::new()
|
||||
.region(region_provider)
|
||||
.behavior_version(BehaviorVersion::latest())
|
||||
.token_provider(BearerTokenProvider::new(key))
|
||||
.build();
|
||||
Ok(aws_sdk_bedrock::Client::from_conf(config))
|
||||
}
|
||||
BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => {
|
||||
let credentials = aws_credential_types::Credentials::new(
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token,
|
||||
None,
|
||||
"windmill",
|
||||
);
|
||||
let config = aws_sdk_bedrock::config::Builder::new()
|
||||
.region(region_provider)
|
||||
.behavior_version(BehaviorVersion::latest())
|
||||
.credentials_provider(credentials)
|
||||
.build();
|
||||
Ok(aws_sdk_bedrock::Client::from_conf(config))
|
||||
}
|
||||
BedrockAuthConfig::Environment => {
|
||||
let config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.region(region_provider)
|
||||
.load()
|
||||
.await;
|
||||
Ok(aws_sdk_bedrock::Client::new(&config))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List foundation models using the Bedrock SDK
|
||||
pub async fn list_foundation_models(
|
||||
api_key: Option<&str>,
|
||||
aws_access_key_id: Option<&str>,
|
||||
aws_secret_access_key: Option<&str>,
|
||||
aws_session_token: Option<&str>,
|
||||
region: &str,
|
||||
) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> {
|
||||
let client = create_bedrock_control_client(
|
||||
api_key,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
region,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response = client
|
||||
.list_foundation_models()
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("Failed to list foundation models: {}", e)))?;
|
||||
|
||||
// Convert to JSON response
|
||||
let models: Vec<serde_json::Value> = response
|
||||
.model_summaries()
|
||||
.iter()
|
||||
.map(|m| {
|
||||
serde_json::json!({
|
||||
"modelId": m.model_id(),
|
||||
"modelName": m.model_name(),
|
||||
"providerName": m.provider_name(),
|
||||
"modelArn": m.model_arn(),
|
||||
"inputModalities": m.input_modalities().iter().map(|i| i.as_str()).collect::<Vec<_>>(),
|
||||
"outputModalities": m.output_modalities().iter().map(|o| o.as_str()).collect::<Vec<_>>(),
|
||||
"responseStreamingSupported": m.response_streaming_supported(),
|
||||
"inferenceTypesSupported": m.inference_types_supported().iter().map(|i| i.as_str()).collect::<Vec<_>>(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body = serde_json::json!({ "modelSummaries": models });
|
||||
let body_bytes = serde_json::to_vec(&body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?;
|
||||
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert("content-type", "application/json".parse().unwrap());
|
||||
|
||||
Ok((
|
||||
http::StatusCode::OK,
|
||||
headers,
|
||||
axum::body::Body::from(body_bytes),
|
||||
))
|
||||
}
|
||||
|
||||
/// List inference profiles using the Bedrock SDK
|
||||
pub async fn list_inference_profiles(
|
||||
api_key: Option<&str>,
|
||||
aws_access_key_id: Option<&str>,
|
||||
aws_secret_access_key: Option<&str>,
|
||||
aws_session_token: Option<&str>,
|
||||
region: &str,
|
||||
) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> {
|
||||
let client = create_bedrock_control_client(
|
||||
api_key,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
region,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let response =
|
||||
client.list_inference_profiles().send().await.map_err(|e| {
|
||||
Error::internal_err(format!("Failed to list inference profiles: {}", e))
|
||||
})?;
|
||||
|
||||
// Convert to JSON response
|
||||
let profiles: Vec<serde_json::Value> = response
|
||||
.inference_profile_summaries()
|
||||
.iter()
|
||||
.map(|p| {
|
||||
serde_json::json!({
|
||||
"inferenceProfileId": p.inference_profile_id(),
|
||||
"inferenceProfileName": p.inference_profile_name(),
|
||||
"inferenceProfileArn": p.inference_profile_arn(),
|
||||
"description": p.description(),
|
||||
"status": p.status().as_str(),
|
||||
"type": p.r#type().as_str(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body = serde_json::json!({ "inferenceProfileSummaries": profiles });
|
||||
let body_bytes = serde_json::to_vec(&body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?;
|
||||
|
||||
let mut headers = http::HeaderMap::new();
|
||||
headers.insert("content-type", "application/json".parse().unwrap());
|
||||
|
||||
Ok((
|
||||
http::StatusCode::OK,
|
||||
headers,
|
||||
axum::body::Body::from(body_bytes),
|
||||
))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Inference Operations (using aws-sdk-bedrockruntime)
|
||||
// ============================================================================
|
||||
|
||||
/// Handle Bedrock streaming request using the AWS SDK.
|
||||
///
|
||||
/// This function uses the shared BedrockClient to make streaming requests
|
||||
/// and converts the SDK stream events to SSE format for the proxy response.
|
||||
///
|
||||
/// Auth priority: bearer token → IAM credentials → environment credentials
|
||||
pub async fn handle_bedrock_sdk_streaming(
|
||||
model: &str,
|
||||
body: &Bytes,
|
||||
api_key: Option<&str>,
|
||||
aws_access_key_id: Option<&str>,
|
||||
aws_secret_access_key: Option<&str>,
|
||||
aws_session_token: Option<&str>,
|
||||
region: &str,
|
||||
) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> {
|
||||
let openai_req: OpenAIRequest = serde_json::from_slice(body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?;
|
||||
|
||||
// Create Bedrock client using shared helper
|
||||
let bedrock_client = create_bedrock_client(
|
||||
api_key,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
region,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Convert messages using shared conversion
|
||||
let enable_prompt_caching =
|
||||
windmill_ai::ai_bedrock::bedrock_model_supports_prompt_caching(model);
|
||||
let (bedrock_messages, system_prompts) =
|
||||
windmill_ai::ai_bedrock::openai_messages_to_bedrock(
|
||||
&openai_req.messages,
|
||||
enable_prompt_caching,
|
||||
)?;
|
||||
|
||||
// Build inference configuration
|
||||
let inference_config = windmill_ai::ai_bedrock::create_inference_config(
|
||||
openai_req.temperature,
|
||||
openai_req.max_tokens,
|
||||
);
|
||||
|
||||
// Convert tools using shared helper
|
||||
let tool_config = build_tool_config_from_request(
|
||||
openai_req.tools.as_deref(),
|
||||
openai_req.tool_choice.as_ref(),
|
||||
enable_prompt_caching,
|
||||
)?;
|
||||
|
||||
// Build the SDK request
|
||||
let mut request_builder = bedrock_client
|
||||
.client()
|
||||
.converse_stream()
|
||||
.model_id(model)
|
||||
.set_messages(Some(bedrock_messages));
|
||||
|
||||
if !system_prompts.is_empty() {
|
||||
request_builder = request_builder.set_system(Some(system_prompts));
|
||||
}
|
||||
|
||||
if let Some(config) = inference_config {
|
||||
request_builder = request_builder.inference_config(config);
|
||||
}
|
||||
|
||||
if let Some(config) = tool_config {
|
||||
request_builder = request_builder.set_tool_config(Some(config));
|
||||
}
|
||||
|
||||
// Send the request and get the stream
|
||||
tracing::debug!("Bedrock SDK streaming: sending converse_stream request");
|
||||
let stream_output = request_builder.send().await.map_err(|e| {
|
||||
let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e));
|
||||
tracing::error!("Bedrock SDK streaming failed: {}", error_msg);
|
||||
Error::internal_err(error_msg)
|
||||
})?;
|
||||
tracing::debug!("Bedrock SDK streaming: stream established successfully");
|
||||
|
||||
// Convert SDK stream to SSE (pass the inner stream, not the full output)
|
||||
let sse_stream = sdk_stream_to_sse(stream_output.stream, model.to_string());
|
||||
|
||||
// Build response headers
|
||||
let mut response_headers = http::HeaderMap::new();
|
||||
response_headers.insert("content-type", "text/event-stream".parse().unwrap());
|
||||
response_headers.insert("cache-control", "no-cache".parse().unwrap());
|
||||
response_headers.insert("connection", "keep-alive".parse().unwrap());
|
||||
|
||||
Ok((
|
||||
http::StatusCode::OK,
|
||||
response_headers,
|
||||
axum::body::Body::from_stream(sse_stream),
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert AWS SDK ConverseStream events to SSE format.
|
||||
///
|
||||
/// Uses shared stream parsing functions from windmill_common::ai_bedrock
|
||||
/// to extract text deltas and tool calls from the SDK stream events.
|
||||
pub fn sdk_stream_to_sse(
|
||||
stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver<
|
||||
aws_sdk_bedrockruntime::types::ConverseStreamOutput,
|
||||
aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError,
|
||||
>,
|
||||
model: String,
|
||||
) -> impl futures::Stream<Item = std::result::Result<bytes::Bytes, std::io::Error>> + Send {
|
||||
use std::collections::HashMap;
|
||||
|
||||
let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple());
|
||||
let created = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// State to track partial tool calls
|
||||
struct StreamState {
|
||||
id: String,
|
||||
model: String,
|
||||
created: u64,
|
||||
tool_calls: HashMap<usize, (String, String, String)>, // index -> (id, name, args)
|
||||
current_tool_index: usize,
|
||||
}
|
||||
|
||||
let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState {
|
||||
id: id.clone(),
|
||||
model: model.clone(),
|
||||
created,
|
||||
tool_calls: HashMap::new(),
|
||||
current_tool_index: 0,
|
||||
}));
|
||||
|
||||
async_stream::stream! {
|
||||
let mut stream = stream;
|
||||
let state = state.clone();
|
||||
|
||||
loop {
|
||||
match stream.recv().await {
|
||||
Ok(Some(event)) => {
|
||||
let mut state = state.lock().await;
|
||||
|
||||
// Handle tool use start
|
||||
if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) {
|
||||
let index = state.current_tool_index;
|
||||
state.tool_calls.insert(
|
||||
index,
|
||||
(tool_call.id.clone(), tool_call.name.clone(), String::new()),
|
||||
);
|
||||
|
||||
// Send initial tool call chunk
|
||||
let chunk = serde_json::json!({
|
||||
"id": state.id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.created,
|
||||
"model": state.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": index,
|
||||
"id": tool_call.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tool_call.name,
|
||||
"arguments": ""
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": serde_json::Value::Null
|
||||
}]
|
||||
});
|
||||
|
||||
yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk)));
|
||||
}
|
||||
|
||||
// Handle text delta
|
||||
if let Some(text) = bedrock_stream_event_to_text(&event) {
|
||||
let chunk = serde_json::json!({
|
||||
"id": state.id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.created,
|
||||
"model": state.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"content": text
|
||||
},
|
||||
"finish_reason": serde_json::Value::Null
|
||||
}]
|
||||
});
|
||||
|
||||
yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk)));
|
||||
}
|
||||
|
||||
// Handle tool use input delta
|
||||
if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) {
|
||||
let index = state.current_tool_index;
|
||||
if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) {
|
||||
args.push_str(&input_delta);
|
||||
|
||||
let chunk = serde_json::json!({
|
||||
"id": state.id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.created,
|
||||
"model": state.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": index,
|
||||
"function": {
|
||||
"arguments": input_delta
|
||||
}
|
||||
}]
|
||||
},
|
||||
"finish_reason": serde_json::Value::Null
|
||||
}]
|
||||
});
|
||||
|
||||
yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk)));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle content block stop
|
||||
if bedrock_stream_event_is_block_stop(&event) {
|
||||
state.current_tool_index += 1;
|
||||
}
|
||||
|
||||
// Handle message stop
|
||||
if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = &event {
|
||||
let stop_reason = stop.stop_reason().as_str();
|
||||
let finish_reason = match stop_reason {
|
||||
"end_turn" => "stop",
|
||||
"max_tokens" => "length",
|
||||
"tool_use" => "tool_calls",
|
||||
"stop_sequence" => "stop",
|
||||
"guardrail_intervened" | "content_filtered" => "content_filter",
|
||||
_ => "stop",
|
||||
};
|
||||
|
||||
let chunk = serde_json::json!({
|
||||
"id": state.id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": state.created,
|
||||
"model": state.model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": finish_reason
|
||||
}]
|
||||
});
|
||||
|
||||
yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk)));
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
yield Err(std::io::Error::new(
|
||||
std::io::ErrorKind::Other,
|
||||
e.to_string(),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send [DONE] at the end
|
||||
yield Ok(bytes::Bytes::from("data: [DONE]\n\n"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle non-streaming Bedrock request using the AWS SDK.
|
||||
///
|
||||
/// Auth priority: bearer token → IAM credentials → environment credentials
|
||||
pub async fn handle_bedrock_sdk_non_streaming(
|
||||
model: &str,
|
||||
body: &Bytes,
|
||||
api_key: Option<&str>,
|
||||
aws_access_key_id: Option<&str>,
|
||||
aws_secret_access_key: Option<&str>,
|
||||
aws_session_token: Option<&str>,
|
||||
region: &str,
|
||||
) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> {
|
||||
let openai_req: OpenAIRequest = serde_json::from_slice(body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?;
|
||||
|
||||
// Create Bedrock client using shared helper
|
||||
let bedrock_client = create_bedrock_client(
|
||||
api_key,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
aws_session_token,
|
||||
region,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Convert messages using shared conversion
|
||||
let enable_prompt_caching =
|
||||
windmill_ai::ai_bedrock::bedrock_model_supports_prompt_caching(model);
|
||||
let (bedrock_messages, system_prompts) =
|
||||
windmill_ai::ai_bedrock::openai_messages_to_bedrock(
|
||||
&openai_req.messages,
|
||||
enable_prompt_caching,
|
||||
)?;
|
||||
|
||||
// Build inference configuration
|
||||
let inference_config = windmill_ai::ai_bedrock::create_inference_config(
|
||||
openai_req.temperature,
|
||||
openai_req.max_tokens,
|
||||
);
|
||||
|
||||
// Convert tools using shared helper
|
||||
let tool_config = build_tool_config_from_request(
|
||||
openai_req.tools.as_deref(),
|
||||
openai_req.tool_choice.as_ref(),
|
||||
enable_prompt_caching,
|
||||
)?;
|
||||
|
||||
// Build the SDK request (non-streaming)
|
||||
let mut request_builder = bedrock_client
|
||||
.client()
|
||||
.converse()
|
||||
.model_id(model)
|
||||
.set_messages(Some(bedrock_messages));
|
||||
|
||||
if !system_prompts.is_empty() {
|
||||
request_builder = request_builder.set_system(Some(system_prompts));
|
||||
}
|
||||
|
||||
if let Some(config) = inference_config {
|
||||
request_builder = request_builder.inference_config(config);
|
||||
}
|
||||
|
||||
if let Some(config) = tool_config {
|
||||
request_builder = request_builder.set_tool_config(Some(config));
|
||||
}
|
||||
|
||||
// Send the request
|
||||
tracing::debug!("Bedrock SDK non-streaming: sending converse request");
|
||||
let response = request_builder.send().await.map_err(|e| {
|
||||
let error_msg = format!(
|
||||
"Bedrock SDK non-streaming error: {}",
|
||||
format_bedrock_error(&e)
|
||||
);
|
||||
tracing::error!("Bedrock SDK non-streaming failed: {}", error_msg);
|
||||
Error::internal_err(error_msg)
|
||||
})?;
|
||||
tracing::debug!(
|
||||
"Bedrock SDK non-streaming: response received, stop_reason={}",
|
||||
response.stop_reason().as_str()
|
||||
);
|
||||
|
||||
// Convert response to OpenAI format
|
||||
let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple());
|
||||
let created = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
|
||||
// Extract stop reason
|
||||
let stop_reason = response.stop_reason().as_str();
|
||||
let finish_reason = match stop_reason {
|
||||
"end_turn" => "stop",
|
||||
"max_tokens" => "length",
|
||||
"tool_use" => "tool_calls",
|
||||
"stop_sequence" => "stop",
|
||||
"guardrail_intervened" | "content_filtered" => "content_filter",
|
||||
_ => "stop",
|
||||
};
|
||||
|
||||
// Extract message content
|
||||
let mut text_content = String::new();
|
||||
let mut tool_calls: Vec<OpenAIToolCall> = Vec::new();
|
||||
|
||||
if let Some(output) = response.output() {
|
||||
if let aws_sdk_bedrockruntime::types::ConverseOutput::Message(message) = output {
|
||||
for block in message.content() {
|
||||
match block {
|
||||
aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => {
|
||||
text_content.push_str(text);
|
||||
}
|
||||
aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => {
|
||||
// Convert Document back to JSON string
|
||||
let input_json = document_to_json(tool_use.input());
|
||||
tool_calls.push(OpenAIToolCall {
|
||||
id: tool_use.tool_use_id().to_string(),
|
||||
function: OpenAIFunction {
|
||||
name: tool_use.name().to_string(),
|
||||
arguments: serde_json::to_string(&input_json).unwrap_or_default(),
|
||||
},
|
||||
r#type: "function".to_string(),
|
||||
extra_content: None,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the message
|
||||
let message = if !tool_calls.is_empty() {
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) },
|
||||
"tool_calls": tool_calls
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": text_content
|
||||
})
|
||||
};
|
||||
|
||||
// Extract usage information
|
||||
let usage = if let Some(usage_data) = response.usage() {
|
||||
serde_json::json!({
|
||||
"prompt_tokens": usage_data.input_tokens(),
|
||||
"completion_tokens": usage_data.output_tokens(),
|
||||
"total_tokens": usage_data.total_tokens()
|
||||
})
|
||||
} else {
|
||||
serde_json::json!({
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0
|
||||
})
|
||||
};
|
||||
|
||||
// Build OpenAI-format response
|
||||
let openai_resp = serde_json::json!({
|
||||
"id": id,
|
||||
"object": "chat.completion",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": finish_reason
|
||||
}],
|
||||
"usage": usage
|
||||
});
|
||||
|
||||
let response_body = serde_json::to_vec(&openai_resp)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to serialize OpenAI response: {}", e)))?;
|
||||
|
||||
let mut response_headers = http::HeaderMap::new();
|
||||
response_headers.insert("content-type", "application/json".parse().unwrap());
|
||||
|
||||
Ok((
|
||||
http::StatusCode::OK,
|
||||
response_headers,
|
||||
axum::body::Body::from(response_body),
|
||||
))
|
||||
}
|
||||
|
||||
/// Convert AWS Smithy Document to serde_json::Value
|
||||
fn document_to_json(doc: &aws_smithy_types::Document) -> serde_json::Value {
|
||||
match doc {
|
||||
aws_smithy_types::Document::Object(map) => {
|
||||
let mut json_map = serde_json::Map::new();
|
||||
for (k, v) in map {
|
||||
json_map.insert(k.clone(), document_to_json(v));
|
||||
}
|
||||
serde_json::Value::Object(json_map)
|
||||
}
|
||||
aws_smithy_types::Document::Array(arr) => {
|
||||
serde_json::Value::Array(arr.iter().map(document_to_json).collect())
|
||||
}
|
||||
aws_smithy_types::Document::Number(num) => match num {
|
||||
aws_smithy_types::Number::PosInt(n) => serde_json::Value::Number((*n).into()),
|
||||
aws_smithy_types::Number::NegInt(n) => serde_json::Value::Number((*n).into()),
|
||||
aws_smithy_types::Number::Float(f) => serde_json::json!(*f),
|
||||
},
|
||||
aws_smithy_types::Document::String(s) => serde_json::Value::String(s.clone()),
|
||||
aws_smithy_types::Document::Bool(b) => serde_json::Value::Bool(*b),
|
||||
aws_smithy_types::Document::Null => serde_json::Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn determine_auth_config_prioritizes_bearer_token() {
|
||||
let config = determine_auth_config(
|
||||
Some("bearer-token"),
|
||||
Some("AKIA123"),
|
||||
Some("secret"),
|
||||
Some("session-token"),
|
||||
);
|
||||
|
||||
match config {
|
||||
BedrockAuthConfig::BearerToken(token) => assert_eq!(token, "bearer-token"),
|
||||
_ => panic!("expected bearer token auth config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_auth_config_uses_iam_with_optional_session_token() {
|
||||
let config =
|
||||
determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("session-token"));
|
||||
|
||||
match config {
|
||||
BedrockAuthConfig::IamCredentials {
|
||||
access_key_id,
|
||||
secret_access_key,
|
||||
session_token,
|
||||
} => {
|
||||
assert_eq!(access_key_id, "AKIA123");
|
||||
assert_eq!(secret_access_key, "secret");
|
||||
assert_eq!(session_token.as_deref(), Some("session-token"));
|
||||
}
|
||||
_ => panic!("expected IAM auth config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_auth_config_treats_empty_session_token_as_none() {
|
||||
let config = determine_auth_config(None, Some("AKIA123"), Some("secret"), Some(""));
|
||||
|
||||
match config {
|
||||
BedrockAuthConfig::IamCredentials { session_token, .. } => {
|
||||
assert!(session_token.is_none());
|
||||
}
|
||||
_ => panic!("expected IAM auth config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn determine_auth_config_falls_back_to_environment() {
|
||||
let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token"));
|
||||
assert!(matches!(config, BedrockAuthConfig::Environment));
|
||||
}
|
||||
}
|
||||
@@ -7218,6 +7218,7 @@ async fn get_job_update(
|
||||
None,
|
||||
false,
|
||||
&mut false,
|
||||
&mut false,
|
||||
)
|
||||
.await?,
|
||||
))
|
||||
@@ -7308,6 +7309,10 @@ pub fn start_job_update_sse_stream(
|
||||
// Latched once the early_return node's failure is observed alongside a
|
||||
// failure_module — subsequent polls then skip the redundant per-node lookup.
|
||||
let mut early_return_suppressed = false;
|
||||
// Latched once we've verified the job was created by "anonymous" — for
|
||||
// unauthenticated SSE streams, this gates access and is checked once per
|
||||
// stream rather than once per poll (created_by cannot change).
|
||||
let mut anonymous_verified = false;
|
||||
|
||||
// Send initial update immediately
|
||||
let mut running = running;
|
||||
@@ -7332,6 +7337,7 @@ pub fn start_job_update_sse_stream(
|
||||
early_return.as_deref(),
|
||||
has_failure_module,
|
||||
&mut early_return_suppressed,
|
||||
&mut anonymous_verified,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -7452,6 +7458,7 @@ pub fn start_job_update_sse_stream(
|
||||
early_return.as_deref(),
|
||||
has_failure_module,
|
||||
&mut early_return_suppressed,
|
||||
&mut anonymous_verified,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -7585,6 +7592,7 @@ async fn get_job_update_data(
|
||||
early_return: Option<&str>,
|
||||
has_failure_module: bool,
|
||||
early_return_suppressed: &mut bool,
|
||||
anonymous_verified: &mut bool,
|
||||
) -> error::Result<JobUpdate> {
|
||||
let tags = if log_view {
|
||||
log_job_view(
|
||||
@@ -7606,6 +7614,32 @@ async fn get_job_update_data(
|
||||
let ignore_flow_stream_job_id = is_flow.is_some_and(|x| !x) || flow_stream_job_id.is_some();
|
||||
|
||||
if only_result.unwrap_or(false) {
|
||||
// Unauthenticated callers may only read jobs whose creator is "anonymous".
|
||||
// The non-only_result branch enforces this via `record.created_by` from its
|
||||
// main query, but the only_result branch below fetches solely the result by
|
||||
// (workspace_id, job_id), so we guard here to close the gap. The
|
||||
// `anonymous_verified` flag is preserved across SSE poll iterations so the
|
||||
// lookup only happens once per stream — `created_by` cannot change for a
|
||||
// given job once it has been created.
|
||||
if opt_authed.is_none() && !*anonymous_verified {
|
||||
let created_by = sqlx::query_scalar!(
|
||||
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
job_id,
|
||||
w_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?;
|
||||
|
||||
if created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
*anonymous_verified = true;
|
||||
}
|
||||
|
||||
let (result, running, mut result_stream, mut new_stream_offset, new_flow_stream_job_id) =
|
||||
if let Some(tags) = tags {
|
||||
let r = sqlx::query!(
|
||||
|
||||
@@ -73,8 +73,6 @@ pub mod auth;
|
||||
#[cfg(all(feature = "private", feature = "parquet"))]
|
||||
pub mod azure_proxy_ee;
|
||||
mod azure_proxy_oss;
|
||||
#[cfg(feature = "bedrock")]
|
||||
mod bedrock;
|
||||
mod capture;
|
||||
mod concurrency_groups;
|
||||
mod db;
|
||||
|
||||
@@ -72,7 +72,7 @@ pub enum Error {
|
||||
ExecutionRawError(Box<serde_json::value::RawValue>),
|
||||
#[error("Error: {error:#} @{location:#}")]
|
||||
Anyhow { error: anyhow::Error, location: String },
|
||||
#[error("Error: {0:#?}")]
|
||||
#[error("{}", format_json_err_message(.0))]
|
||||
JsonErr(serde_json::Value),
|
||||
#[error("{0}")]
|
||||
AIError(String),
|
||||
@@ -256,6 +256,7 @@ impl IntoResponse for Error {
|
||||
Self::SqlErr { .. }
|
||||
| Self::BadRequest(_)
|
||||
| Self::AIError(_)
|
||||
| Self::JsonErr(_)
|
||||
| Self::QuotaExceeded(_) => axum::http::StatusCode::BAD_REQUEST,
|
||||
Self::BadGateway(_) => axum::http::StatusCode::BAD_GATEWAY,
|
||||
Self::Generic(status_code, _) => status_code,
|
||||
@@ -280,6 +281,59 @@ impl IntoResponse for Error {
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a `JsonErr` payload as a readable message suitable for direct
|
||||
/// display in a toast: surface the `error` field as the headline, append a
|
||||
/// short summary of `details` (e.g. duplicate paths) when present, and fall
|
||||
/// back to pretty JSON for unknown shapes. Avoids the Rust `Debug` output
|
||||
/// (`Object { "error": String("..."), ... }`) that previously leaked to users.
|
||||
fn format_json_err_message(v: &serde_json::Value) -> String {
|
||||
if let Some(obj) = v.as_object() {
|
||||
let headline = obj
|
||||
.get("error")
|
||||
.and_then(|e| e.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let details_summary = obj.get("details").and_then(|d| {
|
||||
let arr = d.as_array()?;
|
||||
if arr.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let preview = arr
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|item| match item {
|
||||
serde_json::Value::Object(o) => {
|
||||
let parts: Vec<String> = o
|
||||
.iter()
|
||||
.map(|(k, val)| match val {
|
||||
serde_json::Value::String(s) => format!("{k}={s}"),
|
||||
_ => format!("{k}={val}"),
|
||||
})
|
||||
.collect();
|
||||
format!("- {}", parts.join(", "))
|
||||
}
|
||||
serde_json::Value::String(s) => format!("- {s}"),
|
||||
other => format!("- {other}"),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let suffix = if arr.len() > 5 {
|
||||
format!("\n... ({} more)", arr.len() - 5)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
Some(format!("{preview}{suffix}"))
|
||||
});
|
||||
|
||||
match (headline, details_summary) {
|
||||
(Some(h), Some(d)) => return format!("{h}\n{d}"),
|
||||
(Some(h), None) => return h,
|
||||
(None, Some(d)) => return d,
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
|
||||
}
|
||||
|
||||
pub trait OrElseNotFound<T> {
|
||||
fn or_else_not_found(self, s: impl ToString) -> Result<T>;
|
||||
}
|
||||
@@ -316,3 +370,53 @@ where
|
||||
Self(err.into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn json_err_message_error_and_details() {
|
||||
let v = json!({
|
||||
"error": "Duplicate HTTP route paths detected",
|
||||
"details": [
|
||||
{ "route_path": "a", "workspace_id": "admins", "http_method": "post" },
|
||||
{ "route_path": "a", "workspace_id": "starter", "http_method": "post" },
|
||||
],
|
||||
});
|
||||
let rendered = Error::JsonErr(v).to_string();
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"Duplicate HTTP route paths detected\n\
|
||||
- route_path=a, workspace_id=admins, http_method=post\n\
|
||||
- route_path=a, workspace_id=starter, http_method=post"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_err_message_error_only() {
|
||||
let v = json!({ "error": "Something went wrong" });
|
||||
assert_eq!(Error::JsonErr(v).to_string(), "Something went wrong");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_err_message_truncates_long_details() {
|
||||
let details: Vec<_> = (0..8).map(|i| json!({ "k": i })).collect();
|
||||
let v = json!({ "error": "boom", "details": details });
|
||||
let rendered = Error::JsonErr(v).to_string();
|
||||
assert!(rendered.starts_with("boom\n- k=0\n- k=1\n- k=2\n- k=3\n- k=4"));
|
||||
assert!(rendered.ends_with("... (3 more)"));
|
||||
// Items beyond the cap aren't enumerated.
|
||||
assert!(!rendered.contains("- k=5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_err_message_fallback_to_pretty_json() {
|
||||
let v = json!([1, 2, 3]);
|
||||
// Non-object payload falls back to pretty JSON instead of leaking
|
||||
// Rust `Debug` syntax.
|
||||
let rendered = Error::JsonErr(v).to_string();
|
||||
assert_eq!(rendered, "[\n 1,\n 2,\n 3\n]");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,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! {
|
||||
|
||||
@@ -260,6 +260,12 @@ lazy_static::lazy_static! {
|
||||
|
||||
pub static ref QUIET_LOGS: bool = std::env::var("QUIET_LOGS").map(|s| s.parse::<bool>().unwrap_or(false)).unwrap_or(false);
|
||||
|
||||
/// Snapshot of the standard outbound-proxy env vars, read once at startup.
|
||||
/// Lowercase (`no_proxy`, `http_proxy`, `https_proxy`) is preferred to match
|
||||
/// the convention used by libcurl / reqwest; uppercase is the fallback.
|
||||
pub static ref NO_PROXY: Option<String> = std::env::var("no_proxy").ok().or_else(|| std::env::var("NO_PROXY").ok());
|
||||
pub static ref HTTP_PROXY: Option<String> = std::env::var("http_proxy").ok().or_else(|| std::env::var("HTTP_PROXY").ok());
|
||||
pub static ref HTTPS_PROXY: Option<String> = std::env::var("https_proxy").ok().or_else(|| std::env::var("HTTPS_PROXY").ok());
|
||||
}
|
||||
|
||||
const LATEST_VERSION_ID_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -33,6 +33,7 @@ uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
hex.workspace = true
|
||||
rand.workspace = true
|
||||
reqwest.workspace = true
|
||||
lazy_static.workspace = true
|
||||
prometheus = { workspace = true, optional = true }
|
||||
|
||||
@@ -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,74 @@ 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 (Enterprise): if the fairness refresh has flagged
|
||||
// any overloaded workspaces, this pull is randomly routed between two
|
||||
// pull queries:
|
||||
// * with probability `(cap + ε)/100`, the standard query (capped
|
||||
// workspaces are admissible — they win via FIFO when noisy)
|
||||
// * with probability `1 - (cap + ε)/100`, the fairness query (the
|
||||
// overloaded workspace_ids are filtered out)
|
||||
// Over many pulls this converges to a steady share around the cap,
|
||||
// without the on/off oscillation a binary cap/uncap dispatch produces.
|
||||
// If the chosen query returns nothing, we always fall back to the
|
||||
// standard query so workers don't idle when only capped jobs remain.
|
||||
// Lazy refresh fires from the same place: runs at most once per
|
||||
// process per refresh interval, 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 && !crate::workspace_fairness::should_admit_capped() {
|
||||
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());
|
||||
if let Some(pulled_job) = r {
|
||||
highest_priority_job = Some(pulled_job);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 is
|
||||
// re-asserted statistically on subsequent pulls.)
|
||||
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")]
|
||||
|
||||
@@ -15,6 +15,9 @@ pub mod schedule;
|
||||
pub use jobs::*;
|
||||
pub mod flow_status;
|
||||
pub mod tags;
|
||||
pub mod workspace_fairness;
|
||||
#[cfg(feature = "private")]
|
||||
pub mod workspace_fairness_ee;
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
pub mod cloud_usage;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Per-workspace fairness for the shared worker pool (Enterprise feature).
|
||||
//!
|
||||
//! The real algorithm — overloaded-set aggregation, coordinated refresh on
|
||||
//! `background_task_state`, audit emission, stochastic admission decision —
|
||||
//! lives in [`crate::workspace_fairness_ee`] and only compiles when the
|
||||
//! `private` feature is on. This module is the public surface used by the
|
||||
//! pull dispatch in `jobs.rs` and the integration tests; when EE is on it
|
||||
//! transparently re-exports the EE implementation, when EE is off it
|
||||
//! provides no-op stubs so the OSS build stays bit-identical to the
|
||||
//! pre-fairness pull path.
|
||||
//!
|
||||
//! See [`crate::workspace_fairness_ee`] for design notes and SQL details.
|
||||
|
||||
#[cfg(feature = "private")]
|
||||
#[allow(unused)]
|
||||
pub use crate::workspace_fairness_ee::*;
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
mod oss_stubs {
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
/// No-op on OSS — workspace fairness is an Enterprise feature.
|
||||
#[inline]
|
||||
pub fn maybe_refresh_overloaded(_db: &Pool<Postgres>) {}
|
||||
|
||||
/// No-op on OSS — always returns `true` so the dispatch never reaches
|
||||
/// the fairness pull query (which is empty anyway, since
|
||||
/// `store_pull_query` only materialises it when fairness is enabled).
|
||||
#[inline]
|
||||
pub fn should_admit_capped() -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub use oss_stubs::*;
|
||||
@@ -10,8 +10,8 @@ use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use windmill_api_auth::{
|
||||
check_scopes, maybe_refresh_folders, require_owner_of_path, require_super_admin, ApiAuthed,
|
||||
Tokened,
|
||||
build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
|
||||
require_super_admin, ApiAuthed, Tokened,
|
||||
};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
|
||||
@@ -194,6 +194,7 @@ async fn list_names(
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<Vec<NamePath>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
let rows = sqlx::query!(
|
||||
"SELECT value->>'name' as name, path from resource WHERE resource_type = $1 AND workspace_id = $2",
|
||||
rt,
|
||||
@@ -203,6 +204,7 @@ async fn list_names(
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter_map(|x| x.name.map(|name| NamePath { name, path: x.path }))
|
||||
.filter(|np| allowed(&np.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
@@ -225,6 +227,7 @@ async fn list_search_resources(
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let n = 3;
|
||||
|
||||
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
let rows = sqlx::query_as!(
|
||||
SearchResource,
|
||||
"SELECT path, value from resource WHERE workspace_id = $1 LIMIT $2",
|
||||
@@ -234,6 +237,7 @@ async fn list_search_resources(
|
||||
.fetch_all(&mut *tx)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
@@ -338,9 +342,13 @@ async fn list_resources(
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let allowed = build_scope_path_predicate(&authed, "resources", "read");
|
||||
let rows = sqlx::query_as::<_, ListableResource>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
* LICENSE-AGPL for a copy of the license.
|
||||
*/
|
||||
|
||||
use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed};
|
||||
use windmill_api_auth::{
|
||||
build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
|
||||
ApiAuthed,
|
||||
};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult};
|
||||
|
||||
@@ -188,9 +191,13 @@ async fn list_variables(
|
||||
|
||||
let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let allowed = build_scope_path_predicate(&authed, "variables", "read");
|
||||
let rows = sqlx::query_as::<_, ListableVariable>(&sql)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|r| allowed(&r.path))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(Json(rows))
|
||||
|
||||
@@ -20,6 +20,8 @@ windmill-trigger.workspace = true
|
||||
windmill-git-sync.workspace = true
|
||||
windmill-queue.workspace = true
|
||||
tokio-tungstenite.workspace = true
|
||||
base64.workspace = true
|
||||
url.workspace = true
|
||||
axum.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -4,7 +4,6 @@ use async_trait::async_trait;
|
||||
use itertools::Itertools;
|
||||
use serde_json::value::RawValue;
|
||||
use sqlx::{types::Json as SqlxJson, PgConnection};
|
||||
use tokio_tungstenite::connect_async;
|
||||
use windmill_api_auth::ApiAuthed;
|
||||
use windmill_common::DB;
|
||||
use windmill_common::{
|
||||
@@ -16,8 +15,8 @@ use windmill_git_sync::DeployedObject;
|
||||
use windmill_trigger::{Trigger, TriggerCrud, TriggerData};
|
||||
|
||||
use super::{
|
||||
get_url_from_runnable_value, TestWebsocketConfig, WebsocketConfig, WebsocketConfigRequest,
|
||||
WebsocketTrigger,
|
||||
get_url_from_runnable_value, proxy::connect_async_with_proxy, TestWebsocketConfig,
|
||||
WebsocketConfig, WebsocketConfigRequest, WebsocketTrigger,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
@@ -277,12 +276,14 @@ impl TriggerCrud for WebsocketTrigger {
|
||||
Cow::Borrowed(&url)
|
||||
};
|
||||
|
||||
connect_async(&*connect_url).await.map_err(|err| {
|
||||
Error::BadConfig(format!(
|
||||
"Error connecting to WebSocket: {}",
|
||||
err.to_string()
|
||||
))
|
||||
})?;
|
||||
connect_async_with_proxy(&*connect_url)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
Error::BadConfig(format!(
|
||||
"Error connecting to WebSocket: {}",
|
||||
err.to_string()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ use windmill_trigger::trigger_helpers::{
|
||||
|
||||
pub mod handler;
|
||||
pub mod listener;
|
||||
pub mod proxy;
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct WebsocketTrigger;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::{get_url_from_runnable_value, WebsocketConfig, WebsocketTrigger};
|
||||
use super::{
|
||||
get_url_from_runnable_value, proxy::connect_async_with_proxy, WebsocketConfig, WebsocketTrigger,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use futures::{stream::SplitSink, SinkExt, StreamExt};
|
||||
@@ -8,7 +10,7 @@ use serde::Deserialize;
|
||||
use serde_json::value::RawValue;
|
||||
use std::{borrow::Cow, collections::HashMap, sync::Arc};
|
||||
use tokio::{net::TcpStream, sync::RwLock};
|
||||
use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream};
|
||||
use windmill_common::{
|
||||
error::{to_anyhow, Error, Result},
|
||||
jobs::JobTriggerKind,
|
||||
@@ -171,7 +173,7 @@ impl Listener for WebsocketTrigger {
|
||||
Cow::Borrowed(&url)
|
||||
};
|
||||
|
||||
let connection = connect_async(&*connect_url)
|
||||
let connection = connect_async_with_proxy(&*connect_url)
|
||||
.await
|
||||
.map(|conn| Some(conn))
|
||||
.map_err(|err| to_anyhow(err).into());
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
//! HTTP CONNECT proxy support for outbound WebSocket connections.
|
||||
//!
|
||||
//! `tokio-tungstenite::connect_async` opens a raw TCP socket and does not
|
||||
//! honour `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`. On networks without
|
||||
//! direct egress this leaves WebSocket triggers unable to reach the
|
||||
//! upstream service. This module re-uses the env-var snapshots already
|
||||
//! parsed by `windmill-common` and, when a proxy applies to the target
|
||||
//! host, opens an HTTP CONNECT tunnel before delegating the TLS +
|
||||
//! WebSocket handshake back to tungstenite.
|
||||
//!
|
||||
//! When no proxy env vars are set (the common case), this module
|
||||
//! forwards straight to `tokio_tungstenite::connect_async` so the
|
||||
//! networking path stays byte-for-byte identical to the previous
|
||||
//! behaviour.
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use std::io;
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
|
||||
net::TcpStream,
|
||||
};
|
||||
use tokio_tungstenite::{
|
||||
client_async_tls_with_config, connect_async,
|
||||
tungstenite::{
|
||||
client::IntoClientRequest,
|
||||
error::{Error as WsError, UrlError},
|
||||
handshake::client::Response,
|
||||
},
|
||||
MaybeTlsStream, WebSocketStream,
|
||||
};
|
||||
use url::Url;
|
||||
use windmill_common::{HTTPS_PROXY, HTTP_PROXY, NO_PROXY};
|
||||
|
||||
/// Drop-in replacement for `tokio_tungstenite::connect_async` that routes
|
||||
/// the underlying TCP connection through `HTTPS_PROXY` / `HTTP_PROXY`
|
||||
/// (with `NO_PROXY` exclusions) when those env vars are set. When none
|
||||
/// is set we short-circuit straight to `connect_async`, keeping the
|
||||
/// behaviour for non-proxied deployments unchanged.
|
||||
pub async fn connect_async_with_proxy<R>(
|
||||
request: R,
|
||||
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response), WsError>
|
||||
where
|
||||
R: IntoClientRequest + Unpin,
|
||||
{
|
||||
if HTTPS_PROXY.is_none() && HTTP_PROXY.is_none() {
|
||||
return connect_async(request).await;
|
||||
}
|
||||
|
||||
let request = request.into_client_request()?;
|
||||
let uri = request.uri().clone();
|
||||
let scheme = uri.scheme_str().unwrap_or_default().to_ascii_lowercase();
|
||||
let host = uri
|
||||
.host()
|
||||
.ok_or(WsError::Url(UrlError::NoHostName))?
|
||||
.to_string();
|
||||
let port = uri
|
||||
.port_u16()
|
||||
.or_else(|| match scheme.as_str() {
|
||||
"wss" => Some(443),
|
||||
"ws" => Some(80),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or(WsError::Url(UrlError::UnsupportedUrlScheme))?;
|
||||
|
||||
let proxy = proxy_url_for(&scheme, &host).and_then(|raw| parse_proxy_target(&raw));
|
||||
|
||||
let Some(proxy) = proxy else {
|
||||
// Proxy env was set but doesn't apply to this host (NO_PROXY hit
|
||||
// or unparseable URL): preserve the original connect path.
|
||||
return connect_async(request).await;
|
||||
};
|
||||
|
||||
tracing::debug!(
|
||||
"Connecting to WebSocket {}:{} through HTTP proxy {}:{}",
|
||||
host,
|
||||
port,
|
||||
proxy.host,
|
||||
proxy.port,
|
||||
);
|
||||
let socket = http_connect_tunnel(&proxy, &host, port)
|
||||
.await
|
||||
.map_err(WsError::Io)?;
|
||||
|
||||
client_async_tls_with_config(request, socket, None, None).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ProxyTarget {
|
||||
host: String,
|
||||
port: u16,
|
||||
/// Base64-encoded `user:pass` from URL userinfo, ready to drop into
|
||||
/// the `Proxy-Authorization: Basic …` header value.
|
||||
basic_auth: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve the proxy URL string to use for outbound `(scheme, host)`.
|
||||
///
|
||||
/// `wss://`/`https://` reads `HTTPS_PROXY`, `ws://`/`http://` reads
|
||||
/// `HTTP_PROXY`. `NO_PROXY` short-circuits to `None`. The env-var
|
||||
/// snapshots come from `windmill-common` so they share a single source
|
||||
/// of truth with the worker's `PROXY_ENVS`.
|
||||
fn proxy_url_for(scheme: &str, host: &str) -> Option<String> {
|
||||
if let Some(no_proxy) = NO_PROXY.as_deref() {
|
||||
if matches_no_proxy(host, no_proxy) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let primary = if scheme.eq_ignore_ascii_case("wss") || scheme.eq_ignore_ascii_case("https") {
|
||||
HTTPS_PROXY.as_deref()
|
||||
} else {
|
||||
HTTP_PROXY.as_deref()
|
||||
};
|
||||
primary
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Match a host against a `NO_PROXY` value (comma-separated list).
|
||||
///
|
||||
/// Supports the conventional rules used by `curl`/`reqwest`:
|
||||
/// - `*` matches everything
|
||||
/// - exact host match
|
||||
/// - bare-domain entry (`example.com`) matches `example.com` and any
|
||||
/// subdomain (`foo.example.com`)
|
||||
/// - leading-dot entry (`.example.com`) is normalised to the bare form
|
||||
/// (matches `example.com` and any subdomain), to match what `reqwest`
|
||||
/// and most ops folks expect
|
||||
/// - any `:port` suffix on entries is ignored
|
||||
///
|
||||
/// CIDR/IP-range matches are intentionally not supported.
|
||||
fn matches_no_proxy(host: &str, no_proxy: &str) -> bool {
|
||||
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
for raw in no_proxy.split(',') {
|
||||
let entry = raw.trim().to_ascii_lowercase();
|
||||
if entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if entry == "*" {
|
||||
return true;
|
||||
}
|
||||
let entry = entry.split(':').next().unwrap_or(&entry);
|
||||
let entry = entry.trim_end_matches('.');
|
||||
let bare = entry.trim_start_matches('.');
|
||||
if bare.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if host == bare {
|
||||
return true;
|
||||
}
|
||||
if host.ends_with(&format!(".{}", bare)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Parse a proxy URL string into host/port and optional pre-encoded basic
|
||||
/// auth. Accepts `host`, `host:port`, `scheme://host[:port]`, with an
|
||||
/// optional `user[:pass]@` userinfo prefix. The scheme is used only to
|
||||
/// pick a default port (`https` → 443, anything else → 80).
|
||||
fn parse_proxy_target(raw: &str) -> Option<ProxyTarget> {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// `url::Url::parse` requires an explicit scheme — prepend `http://`
|
||||
// when the user passed a bare `host[:port]`.
|
||||
let prepended = if raw.contains("://") {
|
||||
std::borrow::Cow::Borrowed(raw)
|
||||
} else {
|
||||
std::borrow::Cow::Owned(format!("http://{raw}"))
|
||||
};
|
||||
let url = Url::parse(&prepended).ok()?;
|
||||
|
||||
// `host_str()` keeps brackets around IPv6 literals; strip them so
|
||||
// `TcpStream::connect((host, port))` resolves the address correctly.
|
||||
let host = url
|
||||
.host_str()?
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']');
|
||||
if host.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let host = host.to_string();
|
||||
let port =
|
||||
url.port_or_known_default()
|
||||
.unwrap_or(if url.scheme().eq_ignore_ascii_case("https") {
|
||||
443
|
||||
} else {
|
||||
80
|
||||
});
|
||||
|
||||
let basic_auth = match (url.username(), url.password()) {
|
||||
("", None) => None,
|
||||
(user, pass) => {
|
||||
let creds = match pass {
|
||||
Some(p) => format!("{user}:{p}"),
|
||||
None => user.to_string(),
|
||||
};
|
||||
Some(BASE64_STANDARD.encode(creds))
|
||||
}
|
||||
};
|
||||
|
||||
Some(ProxyTarget { host, port, basic_auth })
|
||||
}
|
||||
|
||||
/// Open a TCP connection to `proxy` and ask it to tunnel to
|
||||
/// `(target_host, target_port)` via HTTP CONNECT. Returns the raw socket
|
||||
/// once the proxy has acknowledged with a 2xx response — subsequent bytes
|
||||
/// belong to the tunneled connection.
|
||||
async fn http_connect_tunnel(
|
||||
proxy: &ProxyTarget,
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
) -> io::Result<TcpStream> {
|
||||
let mut stream = TcpStream::connect((proxy.host.as_str(), proxy.port)).await?;
|
||||
|
||||
let host_header = format!("{}:{}", target_host, target_port);
|
||||
let mut req = format!("CONNECT {h} HTTP/1.1\r\nHost: {h}\r\n", h = host_header,);
|
||||
if let Some(ref auth) = proxy.basic_auth {
|
||||
req.push_str("Proxy-Authorization: Basic ");
|
||||
req.push_str(auth);
|
||||
req.push_str("\r\n");
|
||||
}
|
||||
req.push_str("Proxy-Connection: keep-alive\r\n\r\n");
|
||||
|
||||
stream.write_all(req.as_bytes()).await?;
|
||||
stream.flush().await?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut status_line = String::new();
|
||||
let n = reader.read_line(&mut status_line).await?;
|
||||
if n == 0 {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::UnexpectedEof,
|
||||
"HTTP proxy closed connection before sending CONNECT response",
|
||||
));
|
||||
}
|
||||
|
||||
let status_ok = status_line
|
||||
.split_whitespace()
|
||||
.nth(1)
|
||||
.map(|s| s == "200")
|
||||
.unwrap_or(false);
|
||||
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).await?;
|
||||
if n == 0 || line == "\r\n" || line == "\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !status_ok {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
format!(
|
||||
"HTTP proxy CONNECT to {} rejected: {}",
|
||||
host_header,
|
||||
status_line.trim_end()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
// A conforming proxy stays silent after the CONNECT response until the
|
||||
// client speaks. If our read buffer is non-empty, the proxy spoke
|
||||
// first — handing the raw socket to TLS would silently drop those
|
||||
// bytes and break the handshake.
|
||||
if !reader.buffer().is_empty() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"HTTP proxy sent unexpected bytes after CONNECT response",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(reader.into_inner())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! The single live test (`http_connect_tunnel_…_unwraps_stream`) drives
|
||||
//! a real `TcpListener` masquerading as a proxy and verifies both the
|
||||
//! on-the-wire CONNECT request and that the returned `TcpStream`
|
||||
//! actually carries tunneled bytes. The other proxy-URL and NO_PROXY
|
||||
//! checks are kept under `#[ignore]` for manual debugging — they cover
|
||||
//! logic that's mostly delegated to `url::Url::parse` and trivial
|
||||
//! string matching, so re-running them on every CI build is low ROI.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
#[ignore = "covered by upstream `url::Url::parse`; run manually with `--ignored` if changed"]
|
||||
fn parse_proxy_target_shapes_and_ipv6_and_basic_auth() {
|
||||
let p = parse_proxy_target("http://outbound.eps.apple.com:80").unwrap();
|
||||
assert_eq!(p.host, "outbound.eps.apple.com");
|
||||
assert_eq!(p.port, 80);
|
||||
|
||||
let p = parse_proxy_target("https://proxy.internal").unwrap();
|
||||
assert_eq!(p.port, 443);
|
||||
|
||||
let p = parse_proxy_target("proxy.internal:3128").unwrap();
|
||||
assert_eq!(p.port, 3128);
|
||||
|
||||
let p = parse_proxy_target("http://alice:s3cret@proxy.lan:8080").unwrap();
|
||||
// base64("alice:s3cret") = YWxpY2U6czNjcmV0
|
||||
assert_eq!(p.basic_auth.as_deref(), Some("YWxpY2U6czNjcmV0"));
|
||||
|
||||
let p = parse_proxy_target("http://[::1]:3128").unwrap();
|
||||
assert_eq!(p.host, "::1");
|
||||
assert_eq!(p.port, 3128);
|
||||
|
||||
assert!(parse_proxy_target("").is_none());
|
||||
assert!(parse_proxy_target("http://").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "trivial string matching; run manually with `--ignored` if rules change"]
|
||||
fn no_proxy_matching_rules() {
|
||||
assert!(matches_no_proxy("example.com", "*"));
|
||||
assert!(matches_no_proxy("example.com", "example.com"));
|
||||
assert!(matches_no_proxy("api.example.com", "example.com"));
|
||||
assert!(matches_no_proxy("api.example.com", ".example.com"));
|
||||
assert!(matches_no_proxy("example.com", ".example.com"));
|
||||
assert!(matches_no_proxy("example.com", "example.com:8080"));
|
||||
assert!(matches_no_proxy("API.Example.COM", "example.com"));
|
||||
assert!(!matches_no_proxy("notexample.com", "example.com"));
|
||||
assert!(!matches_no_proxy("slack.com", "example.com,internal.lan"));
|
||||
}
|
||||
|
||||
/// Spin up a one-shot TCP listener acting as an HTTP proxy.
|
||||
/// Reads the CONNECT request, asserts on it via `validate`, then
|
||||
/// either replies `200 Connection Established` or the supplied
|
||||
/// `respond` string. Echoes any further client bytes back so the test
|
||||
/// can confirm the returned `TcpStream` carries the tunneled session.
|
||||
async fn fake_proxy<F>(
|
||||
respond: &'static str,
|
||||
validate: F,
|
||||
) -> (std::net::SocketAddr, tokio::task::JoinHandle<Vec<u8>>)
|
||||
where
|
||||
F: FnOnce(&str) + Send + 'static,
|
||||
{
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let handle = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = String::new();
|
||||
{
|
||||
let mut reader = BufReader::new(&mut socket);
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
let n = reader.read_line(&mut line).await.unwrap();
|
||||
request.push_str(&line);
|
||||
if n == 0 || line == "\r\n" || line == "\n" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
validate(&request);
|
||||
socket.write_all(respond.as_bytes()).await.unwrap();
|
||||
socket.flush().await.unwrap();
|
||||
|
||||
let mut tunneled = Vec::new();
|
||||
socket.read_to_end(&mut tunneled).await.unwrap();
|
||||
tunneled
|
||||
});
|
||||
(addr, handle)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_connect_tunnel_sends_well_formed_request_and_unwraps_stream() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let (addr, handle) = fake_proxy(
|
||||
"HTTP/1.1 200 Connection Established\r\nProxy-Agent: test\r\n\r\n",
|
||||
|req| {
|
||||
assert!(
|
||||
req.starts_with("CONNECT slack.com:443 HTTP/1.1\r\n"),
|
||||
"got: {req:?}"
|
||||
);
|
||||
assert!(req.contains("Host: slack.com:443\r\n"));
|
||||
assert!(!req.contains("Proxy-Authorization"));
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
let proxy =
|
||||
ProxyTarget { host: addr.ip().to_string(), port: addr.port(), basic_auth: None };
|
||||
let mut stream = http_connect_tunnel(&proxy, "slack.com", 443).await.unwrap();
|
||||
stream.write_all(b"hello-tls").await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
|
||||
let tunneled = handle.await.unwrap();
|
||||
assert_eq!(tunneled, b"hello-tls");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "manual; fake-proxy edge cases (auth, error status). Run with `--ignored` if `http_connect_tunnel` changes."]
|
||||
async fn http_connect_tunnel_forwards_basic_auth_and_surfaces_non_2xx() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
// Basic-auth header is forwarded.
|
||||
let (addr, handle) = fake_proxy("HTTP/1.1 200 OK\r\n\r\n", |req| {
|
||||
assert!(req.contains("Proxy-Authorization: Basic YWxpY2U6czNjcmV0\r\n"));
|
||||
})
|
||||
.await;
|
||||
let proxy = ProxyTarget {
|
||||
host: addr.ip().to_string(),
|
||||
port: addr.port(),
|
||||
basic_auth: Some("YWxpY2U6czNjcmV0".to_string()),
|
||||
};
|
||||
let mut stream = http_connect_tunnel(&proxy, "slack.com", 443).await.unwrap();
|
||||
stream.shutdown().await.unwrap();
|
||||
let _ = handle.await.unwrap();
|
||||
|
||||
// Non-2xx status surfaces as an error.
|
||||
let (addr, handle) = fake_proxy(
|
||||
"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n",
|
||||
|_| {},
|
||||
)
|
||||
.await;
|
||||
let proxy =
|
||||
ProxyTarget { host: addr.ip().to_string(), port: addr.port(), basic_auth: None };
|
||||
let err = http_connect_tunnel(&proxy, "slack.com", 443)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("407"));
|
||||
let _ = handle.await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -586,16 +586,12 @@ pub async fn run_agent(
|
||||
tool_abort_handles: ToolAbortHandles,
|
||||
) -> error::Result<Box<RawValue>> {
|
||||
let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text);
|
||||
// Skip get_base_url for Bedrock - it uses SDK directly, not HTTP
|
||||
let base_url = if args.provider.kind == AIProvider::AWSBedrock {
|
||||
String::new()
|
||||
} else {
|
||||
args.provider.get_base_url(db).await?
|
||||
};
|
||||
let api_key = args.provider.get_api_key().unwrap_or("");
|
||||
let credentials = args.provider.to_provider_credentials(db).await?;
|
||||
let base_url = &credentials.base_url;
|
||||
let api_key = credentials.api_key.as_deref().unwrap_or("");
|
||||
|
||||
// Create the query builder for the provider
|
||||
let query_builder = create_query_builder(&args.provider);
|
||||
let query_builder = create_query_builder(&credentials);
|
||||
|
||||
// Initialize messages
|
||||
let mut messages =
|
||||
@@ -859,12 +855,12 @@ pub async fn run_agent(
|
||||
}
|
||||
|
||||
// Handle AWS Bedrock provider specially using the official SDK
|
||||
let parsed = if args.provider.kind == AIProvider::AWSBedrock {
|
||||
let parsed = if credentials.provider == AIProvider::AWSBedrock {
|
||||
#[cfg(feature = "bedrock")]
|
||||
{
|
||||
let region = args
|
||||
.provider
|
||||
.get_region()
|
||||
let region = credentials
|
||||
.region
|
||||
.as_deref()
|
||||
.unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION);
|
||||
// Use Bedrock SDK via dedicated query builder
|
||||
windmill_ai::providers::bedrock::BedrockQueryBuilder::default()
|
||||
@@ -880,9 +876,9 @@ pub async fn run_agent(
|
||||
client,
|
||||
&job.workspace_id,
|
||||
structured_output_tool_name.as_deref(),
|
||||
args.provider.get_aws_access_key_id(),
|
||||
args.provider.get_aws_secret_access_key(),
|
||||
args.provider.get_aws_session_token(),
|
||||
credentials.aws_access_key_id.as_deref(),
|
||||
credentials.aws_secret_access_key.as_deref(),
|
||||
credentials.aws_session_token.as_deref(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
@@ -913,14 +909,14 @@ pub async fn run_agent(
|
||||
.await?;
|
||||
|
||||
let endpoint =
|
||||
query_builder.get_endpoint(&base_url, args.provider.get_model(), output_type);
|
||||
let auth_headers = query_builder.get_auth_headers(api_key, &base_url, output_type);
|
||||
query_builder.get_endpoint(base_url, args.provider.get_model(), output_type);
|
||||
let auth_headers = query_builder.get_auth_headers(api_key, base_url, output_type);
|
||||
|
||||
let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout)
|
||||
.await
|
||||
.0;
|
||||
|
||||
let resource_headers = args.provider.get_headers();
|
||||
let resource_headers = &credentials.custom_headers;
|
||||
|
||||
// Helper to build HTTP request with headers
|
||||
let build_http_request = |body: String| {
|
||||
|
||||
@@ -13,6 +13,8 @@ use anyhow::anyhow;
|
||||
use futures::TryFutureExt;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::timeout;
|
||||
// Re-export proxy env-var snapshots so callers (including EE modules)
|
||||
// can keep importing them via `crate::{NO_PROXY, HTTP_PROXY, HTTPS_PROXY}`.
|
||||
use windmill_common::client::AuthedClient;
|
||||
use windmill_common::db::UserDbWithAuthed;
|
||||
use windmill_common::get_latest_deployed_hash_for_path;
|
||||
@@ -48,6 +50,7 @@ use windmill_common::{
|
||||
worker_group_job_stats::JobStatsMap,
|
||||
KillpillSender,
|
||||
};
|
||||
pub use windmill_common::{HTTPS_PROXY, HTTP_PROXY, NO_PROXY};
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::ee_oss::LICENSE_KEY_VALID;
|
||||
@@ -554,11 +557,9 @@ lazy_static::lazy_static! {
|
||||
.and_then(|x| x.parse::<bool>().ok())
|
||||
.unwrap_or(false));
|
||||
|
||||
pub static ref NO_PROXY: Option<String> = std::env::var("no_proxy").ok().or(std::env::var("NO_PROXY").ok());
|
||||
pub static ref HTTP_PROXY: Option<String> = std::env::var("http_proxy").ok().or(std::env::var("HTTP_PROXY").ok());
|
||||
pub static ref HTTPS_PROXY: Option<String> = std::env::var("https_proxy").ok().or(std::env::var("HTTPS_PROXY").ok());
|
||||
|
||||
/// Static proxy environment variables from env vars (for languages not using dynamic OTEL tracing proxy config)
|
||||
/// Static proxy environment variables from env vars (for languages not using dynamic OTEL tracing proxy config).
|
||||
/// The underlying `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` snapshots live in `windmill_common`
|
||||
/// so other crates (e.g. native triggers) can reuse the same source of truth.
|
||||
pub static ref PROXY_ENVS: Vec<(&'static str, String)> = {
|
||||
let mut proxy_env = Vec::new();
|
||||
if let Some(no_proxy) = NO_PROXY.as_ref() {
|
||||
|
||||
+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.706.1";
|
||||
export const VERSION = "v1.709.0";
|
||||
|
||||
export async function login(email: string, password: string): Promise<string> {
|
||||
return await windmill.UserService.login({
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@ export {
|
||||
token,
|
||||
};
|
||||
|
||||
export const VERSION = "1.706.1";
|
||||
export const VERSION = "1.709.0";
|
||||
|
||||
// Re-exported from constants.ts to maintain backwards compatibility
|
||||
export { WM_FORK_PREFIX } from "./core/constants.ts";
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
AI provider logic is currently split across three crates with duplicate code:
|
||||
|
||||
- **windmill-common** — base types (`ai_types`, `ai_providers`, `ai_google`, `ai_bedrock`, `ai_cache`)
|
||||
- **windmill-api** — chat proxy (`ai.rs`, `google.rs`, `bedrock.rs`) with its own request building for Google/Bedrock, plus `AIRequestConfig::prepare_request` for auth/URL handling
|
||||
- **windmill-api** — chat proxy routes (`ai.rs`), audit logging, caching, and DB-backed credential resolution into `ProviderCredentials`
|
||||
- **windmill-worker** — agent execution (`ai/` module) with `QueryBuilder` trait, SSE parsers, provider implementations
|
||||
|
||||
The goal: a single `windmill-ai` crate with all AI provider logic. Both the API proxy and worker agent use `QueryBuilder` for every provider — no more duplicate logic.
|
||||
The goal: a single `windmill-ai` crate with all AI provider logic. Worker agent execution uses `QueryBuilder`; the API proxy uses `QueryBuilder::build_proxy_request` for HTTP-forwarding providers and native proxy handlers for providers that need response conversion or SDK execution.
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
@@ -25,7 +25,7 @@ windmill-common does **NOT** re-export from windmill-ai (would be circular). All
|
||||
|
||||
## Reviewer Note: Keep API Proxy Unification Split
|
||||
|
||||
The crate boundary, shared utilities, SSE parsers, image handling, and worker provider implementations are now in `windmill-ai`. The remaining duplication is the API proxy path: `AIRequestConfig::prepare_request`, `windmill-api/src/google.rs`, and `windmill-api/src/bedrock.rs` still own API-specific request transformation.
|
||||
The crate boundary, shared utilities, SSE parsers, image handling, worker provider implementations, provider-specific API proxy transformations, and resolved runtime credential shape are now in `windmill-ai`. Raw API resources and worker agent provider payloads remain separate input/deserialization shapes and convert into `ProviderCredentials` at execution boundaries.
|
||||
|
||||
Do not jump directly from the current state to full proxy and credential unification in one PR. The API proxy combines request transformation, endpoint selection, auth headers, custom headers, OAuth user injection, Azure URL handling, Anthropic Vertex handling, Bedrock SDK calls, and SSE keepalive behavior. Split the work by risk:
|
||||
- Introduce shared proxy request and credential types first.
|
||||
@@ -62,7 +62,7 @@ Out of scope:
|
||||
|
||||
Validation:
|
||||
- `cargo test -p windmill-ai proxy`
|
||||
- `cargo test -p windmill-api maps_request_config_to_provider_credentials`
|
||||
- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace`
|
||||
- `cargo check -p windmill-ai -p windmill-api`
|
||||
- `cargo check -p windmill-ai -p windmill-api --features bedrock`
|
||||
|
||||
@@ -70,7 +70,7 @@ Follow-up status: Anthropic/Vertex proxy handling has since moved into
|
||||
`windmill-ai`, and the dead `AIRequestConfig::prepare_request` fallback has
|
||||
been removed.
|
||||
|
||||
## Current Phase PR: Proxy Execution Mode + Google AI Proxy Migration
|
||||
## Completed Phase: Proxy Execution Mode + Google AI Proxy Migration ✅
|
||||
|
||||
Goal: introduce a shared provider execution classifier before moving Google AI
|
||||
and Bedrock. `ProxyRequest` is a good contract for HTTP-forwarding providers
|
||||
@@ -98,9 +98,120 @@ Out of scope:
|
||||
Validation:
|
||||
- `cargo test -p windmill-ai google_ai`
|
||||
- `cargo test -p windmill-ai proxy`
|
||||
- `cargo test -p windmill-api maps_request_config_to_provider_credentials`
|
||||
- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace`
|
||||
- `cargo test -p windmill-ai anthropic`
|
||||
|
||||
Follow-up status: Bedrock native proxy handling has since moved into
|
||||
`windmill-ai`, and the API-local `windmill-api/src/bedrock.rs` module has been
|
||||
removed.
|
||||
|
||||
## Completed Phase: Bedrock Native Proxy Migration ✅
|
||||
|
||||
Goal: move the remaining native-provider API proxy execution out of
|
||||
`windmill-api` and into `windmill-ai`, while leaving API-owned routing,
|
||||
credential resolution, auditing, cache behavior, and Axum response conversion in
|
||||
`windmill-api`.
|
||||
|
||||
Suggested PR title: `refactor(ai): move bedrock proxy handling to windmill-ai`.
|
||||
|
||||
Scope:
|
||||
- Move Bedrock control-plane proxy calls (`foundation-models`,
|
||||
`inference-profiles`) into `windmill-ai::providers::bedrock`.
|
||||
- Move Bedrock chat proxy OpenAI request parsing, Converse request execution,
|
||||
streaming SSE conversion, non-streaming OpenAI-shaped response conversion, and
|
||||
auth selection into `windmill-ai::providers::bedrock`.
|
||||
- Add an Axum-free `BedrockProxyResponse` shape in `windmill-ai`; the API route
|
||||
converts it into an Axum body.
|
||||
- Move the optional `aws-sdk-bedrock` dependency from `windmill-api` to
|
||||
`windmill-ai`.
|
||||
- Delete the API-local `windmill-api/src/bedrock.rs` module.
|
||||
|
||||
Out of scope:
|
||||
- Do not unify `AIRequestConfig` and `ProviderWithResource`.
|
||||
- Do not change Bedrock credential resolution, audit logging, request caching,
|
||||
or non-Bedrock proxy behavior.
|
||||
|
||||
Validation:
|
||||
- `cargo test -p windmill-ai bedrock --features bedrock`
|
||||
- `cargo check -p windmill-ai -p windmill-api`
|
||||
- `cargo check -p windmill-ai -p windmill-api --features bedrock`
|
||||
|
||||
## Known Follow-Ups
|
||||
|
||||
These are not blockers for the current migration PR because they either preserve
|
||||
existing behavior or need a separate product decision, but they should stay
|
||||
visible for later hardening work.
|
||||
|
||||
- **Google AI/Gemini native proxy custom headers**: the native Google AI proxy
|
||||
path intentionally does not apply `AI_HTTP_HEADERS` or resource-level custom
|
||||
headers today. Decide whether and how env/resource custom-header injection
|
||||
should apply to Google AI once the proxy behavior is unified further.
|
||||
- **Bedrock SSE tool-call indexing**: Bedrock streaming currently increments
|
||||
the OpenAI tool-call index on every Bedrock `ContentBlockStop`, including text
|
||||
content blocks. This behavior existed before the move from `windmill-api` to
|
||||
`windmill-ai`, but a later cleanup should advance the index only when the
|
||||
stopped block was a tool-use block.
|
||||
- **Bedrock SSE keepalives**: Bedrock native SSE streams are still returned
|
||||
directly without the API proxy keepalive injection used by other SSE paths.
|
||||
This also preserves the pre-move behavior. A later cleanup can generalize the
|
||||
keepalive wrapper so it works for both `reqwest::Error` streams and Bedrock's
|
||||
SDK-backed `std::io::Error` streams.
|
||||
|
||||
## Completed Phase: Credential Unification Phase 1 ✅
|
||||
|
||||
Goal: make `ProviderCredentials` the shared resolved runtime credential shape
|
||||
without overloading it with raw resource input or model-selection state.
|
||||
|
||||
`AIRequestConfig` and `ProviderWithResource` are not equivalent concepts:
|
||||
`AIRequestConfig` is API-side resolved state after DB, variable, OAuth, and
|
||||
resource handling, while `ProviderWithResource` is worker-side raw agent input
|
||||
that also carries the selected model. Keep raw/deserialization types separate and
|
||||
convert them into `ProviderCredentials` at execution boundaries.
|
||||
|
||||
Suggested PR title: `refactor(ai): use provider credentials for worker builders`.
|
||||
|
||||
Scope:
|
||||
- Add a worker-side conversion from `ProviderWithResource` to
|
||||
`ProviderCredentials`.
|
||||
- Keep `model` outside `ProviderCredentials`; it remains agent request data.
|
||||
- Keep `ProviderWithResource` as the backward-compatible deserialization type for
|
||||
existing agent payloads.
|
||||
- Use `ProviderCredentials` for worker query-builder creation.
|
||||
- Collapse `create_query_builder` and `create_proxy_query_builder` into one
|
||||
`create_query_builder(&ProviderCredentials)` factory.
|
||||
|
||||
Out of scope:
|
||||
- Do not remove API-local `AIRequestConfig` yet.
|
||||
- Do not change API request-cache behavior.
|
||||
- Do not change worker agent payload shape or serialized field names.
|
||||
|
||||
Validation:
|
||||
- `cargo check -p windmill-ai -p windmill-api -p windmill-worker`
|
||||
- `cargo check -p windmill-ai -p windmill-api -p windmill-worker --features bedrock`
|
||||
|
||||
## Completed Phase: Credential Unification Phase 2 ✅
|
||||
|
||||
Goal: remove the API-local resolved credential wrapper after worker execution
|
||||
already uses the shared shape.
|
||||
|
||||
Suggested PR title: `refactor(ai): resolve api proxy credentials directly`.
|
||||
|
||||
Scope:
|
||||
- Change API credential resolution to return `ProviderCredentials` directly.
|
||||
- Replace `ExpiringAIRequestConfig` with an expiring `ProviderCredentials`
|
||||
cache entry.
|
||||
- Remove `AIRequestConfig::into_provider_credentials`.
|
||||
- Delete `AIRequestConfig` entirely if no API-only behavior remains.
|
||||
|
||||
Out of scope:
|
||||
- Do not merge raw worker resource input into `ProviderCredentials`.
|
||||
- Do not put model selection into `ProviderCredentials`.
|
||||
|
||||
Validation:
|
||||
- `cargo check -p windmill-ai -p windmill-api -p windmill-worker`
|
||||
- `cargo check -p windmill-ai -p windmill-api -p windmill-worker --features bedrock`
|
||||
- `cargo test -p windmill-api invalidates_all_cached_providers_for_workspace`
|
||||
|
||||
## Step-by-Step Plan
|
||||
|
||||
Each step produces a compiling, working backend.
|
||||
@@ -200,9 +311,10 @@ Move `AI_HTTP_HEADERS` lazy_static (currently duplicated in `windmill-api/src/ai
|
||||
|
||||
---
|
||||
|
||||
### Step 8: Add proxy support to QueryBuilder — API uses QueryBuilder for all providers
|
||||
### Step 8: Add API proxy execution support to windmill-ai ✅
|
||||
|
||||
This is the key unification step. Add a new method to the `QueryBuilder` trait:
|
||||
This is the key proxy unification step. HTTP-forwarding providers use
|
||||
`QueryBuilder::build_proxy_request`:
|
||||
|
||||
```rust
|
||||
/// Build a request from a raw OpenAI-format proxy request.
|
||||
@@ -237,35 +349,43 @@ pub struct ProxyRequest {
|
||||
**Provider implementations:**
|
||||
- **OpenAI-compatible** (OpenAI, Mistral, DeepSeek, Groq, TogetherAI, CustomAI, OpenRouter): Minimal transformation — pass body through, build URL and auth headers.
|
||||
- **Anthropic**: Handle standard vs Vertex AI. For Vertex: transform body (extract model, add anthropic_version). For standard: pass through with appropriate headers.
|
||||
- **Google AI**: Convert OpenAI format → Gemini format (using existing `ai_google` functions). Replaces `windmill-api/src/google.rs`.
|
||||
- **Bedrock**: Convert OpenAI format → Bedrock SDK calls. Replaces `windmill-api/src/bedrock.rs`.
|
||||
- **Google AI**: Native execution mode converts OpenAI format → Gemini format and Gemini responses → OpenAI shape. Replaces `windmill-api/src/google.rs`.
|
||||
- **Bedrock**: Native execution mode converts OpenAI format → Bedrock SDK calls and SDK responses → OpenAI shape. Replaces `windmill-api/src/bedrock.rs`.
|
||||
|
||||
**Refactor API proxy** (`windmill-api/src/ai.rs`):
|
||||
1. Parse provider from headers, resolve credentials → `ProviderCredentials`
|
||||
2. Create `QueryBuilder` via `create_query_builder`
|
||||
3. Call `query_builder.build_proxy_request(&proxy_args)` → `ProxyRequest`
|
||||
4. Send the request, return response with SSE keepalive injection
|
||||
3. Dispatch by `ProxyExecutionMode`:
|
||||
- HTTP-forwarding providers call `query_builder.build_proxy_request(&proxy_args)` → `ProxyRequest`
|
||||
- Google AI and Bedrock call native handlers in `windmill-ai`
|
||||
4. Convert the provider response to the API response body
|
||||
|
||||
**Remove** from windmill-api:
|
||||
- `AIRequestConfig::prepare_request` — replaced by `QueryBuilder::build_proxy_request`
|
||||
- `google.rs` — replaced by `GoogleAIQueryBuilder::build_proxy_request`
|
||||
- `bedrock.rs` — replaced by `BedrockQueryBuilder::build_proxy_request`
|
||||
- `google.rs` — replaced by `windmill_ai::providers::google_ai` native proxy handlers
|
||||
- `bedrock.rs` — replaced by `windmill_ai::providers::bedrock` native proxy handlers
|
||||
- `transform_anthropic_for_vertex` — moved to `AnthropicQueryBuilder`
|
||||
- `supports_native_fim`, `transform_fim_to_chat_completions` — moved to windmill-ai
|
||||
|
||||
**Keep** in API:
|
||||
- `AIRequestConfig::new` credential resolution until it is refactored to produce `ProviderCredentials`
|
||||
- credential resolution from DB, workspace settings, instance settings, variables, and OAuth into `ProviderCredentials`
|
||||
- HTTP routes, audit logging, request caching
|
||||
- `inject_keepalives`, `is_sse_response` helpers
|
||||
- `AIConfig`, `ExpiringAIRequestConfig` caching types
|
||||
- `AIConfig`, `ExpiringProviderCredentials` caching types
|
||||
|
||||
---
|
||||
|
||||
### Step 9: Unify credential resolution
|
||||
|
||||
Merge `AIRequestConfig` (API-side) and `ProviderWithResource` (worker-side) into a single credential shape in windmill-ai.
|
||||
Make `ProviderCredentials` the single resolved runtime credential shape in
|
||||
windmill-ai, while keeping raw API and worker input/deserialization types at
|
||||
their boundaries.
|
||||
|
||||
Both currently carry: api_key, base_url, region, platform, custom_headers, AWS credentials. The API's `AIRequestConfig::new` resolves credentials from DB (workspace/instance settings). The worker's `ProviderWithResource` gets credentials from the flow module definition.
|
||||
The API's `resolve_provider_credentials` resolves credentials from DB, workspace
|
||||
or instance settings, variables, and OAuth. The worker's `ProviderWithResource`
|
||||
gets raw credentials from the flow module definition and also carries the
|
||||
selected model. Convert both paths into `ProviderCredentials`; do not make
|
||||
`ProviderCredentials` carry raw resource state or the model.
|
||||
|
||||
Extend `windmill_ai::proxy::ProviderCredentials` as needed so both can produce it:
|
||||
```rust
|
||||
@@ -310,8 +430,8 @@ windmill-ai/src/
|
||||
├── mod.rs # create_query_builder factory
|
||||
├── anthropic.rs # build_request + build_proxy_request
|
||||
├── openai.rs # build_request + build_proxy_request
|
||||
├── google_ai.rs # build_request + build_proxy_request
|
||||
├── bedrock.rs # build_request + build_proxy_request (feature: bedrock)
|
||||
├── google_ai.rs # build_request + native proxy handlers
|
||||
├── bedrock.rs # build_request + native proxy handlers (feature: bedrock)
|
||||
├── other.rs # build_request + build_proxy_request
|
||||
└── openrouter.rs # build_request + build_proxy_request
|
||||
```
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Fixtures
|
||||
|
||||
Helpers for sharing a reproducible test workspace alongside a PR.
|
||||
|
||||
The workflow is:
|
||||
|
||||
1. While iterating on a PR, snapshot your local test workspace into
|
||||
`fixtures/cli-sync/` so a teammate (or CI) can replay it.
|
||||
2. Commit the snapshot on your branch so reviewers can load it locally.
|
||||
3. **Before merging**, clear `fixtures/cli-sync/` again. CI fails on `main` /
|
||||
PRs that try to merge a non-empty fixture (see `check-empty.sh`).
|
||||
|
||||
## Scripts
|
||||
|
||||
All scripts assume:
|
||||
|
||||
- A local Windmill backend running at `http://localhost:8000` (see top-level
|
||||
`AGENTS.md` for `cargo run` / `npm run dev`).
|
||||
- Default super-admin credentials `admin@windmill.dev` / `changeme`.
|
||||
- `bun` and `python3` are installed and on `PATH`. No `wmill` install
|
||||
required — the scripts invoke `cli/src/main.ts` via `bun run` directly.
|
||||
`python3` is used only to build correctly-escaped JSON request bodies.
|
||||
|
||||
The login token is passed to the CLI via `--token`, which means it appears in
|
||||
`/proc/<pid>/cmdline` for the duration of the `bun run` call. This is fine
|
||||
against a local dev instance; if you point the scripts at a real instance,
|
||||
the credentials are no more exposed than running `wmill` directly with
|
||||
explicit flags, but bear it in mind.
|
||||
|
||||
### `load.sh` — load the fixture into a fresh workspace
|
||||
|
||||
```bash
|
||||
./fixtures/load.sh
|
||||
```
|
||||
|
||||
Logs in as `admin@windmill.dev`, creates a new workspace with a random id
|
||||
(`fixture-<8 hex chars>`), and pushes `fixtures/cli-sync/` into it via
|
||||
`wmill sync push`. Prints the workspace id at the end so you can open it in
|
||||
the UI.
|
||||
|
||||
Flags:
|
||||
- `--base-url <url>` (default `http://localhost:8000`)
|
||||
- `--email <email>` (default `admin@windmill.dev`)
|
||||
- `--password <pwd>` (default `changeme`) — prefer `WMILL_PASSWORD=<pwd>` env
|
||||
var when using a real password, since `--password` ends up in `ps` /
|
||||
shell history.
|
||||
- `--workspace <id>` (default `fixture-<random>`)
|
||||
- `--dir <path>` (default `fixtures/cli-sync`)
|
||||
|
||||
### `snapshot.sh` — snapshot a workspace into the fixture folder
|
||||
|
||||
```bash
|
||||
./fixtures/snapshot.sh <workspace-id>
|
||||
```
|
||||
|
||||
Pulls the given workspace into `fixtures/cli-sync/` via `wmill sync pull`.
|
||||
The target directory is cleared first (everything except `wmill.yaml` and
|
||||
`.gitkeep`) so the snapshot reflects exactly what's in the workspace.
|
||||
|
||||
Flags: same as `load.sh` minus `--workspace` (passed as positional arg).
|
||||
|
||||
### `check-empty.sh` — fail if the fixture is non-empty
|
||||
|
||||
Used by CI to guard `main`. Run it locally before opening a PR for merge:
|
||||
|
||||
```bash
|
||||
./fixtures/check-empty.sh
|
||||
```
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fail if fixtures/cli-sync/ contains anything beyond the fixture scaffold
|
||||
# (wmill.yaml, .gitkeep). Used by CI to guard `main` against accidentally
|
||||
# merging PRs with a test workspace snapshot still committed.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DIR="${1:-$SCRIPT_DIR/cli-sync}"
|
||||
|
||||
ALLOWED_RE='^fixtures/cli-sync/(\.gitkeep|wmill\.yaml)$'
|
||||
|
||||
# Use `git ls-files` so we only check tracked files. Untracked local snapshots
|
||||
# are fine — devs may keep them locally between sessions.
|
||||
EXTRA=$(cd "$REPO_ROOT" && git ls-files fixtures/cli-sync \
|
||||
| grep -vE "$ALLOWED_RE" || true)
|
||||
|
||||
if [[ -n "$EXTRA" ]]; then
|
||||
echo "✗ fixtures/cli-sync/ contains committed snapshot files:" >&2
|
||||
echo "$EXTRA" | sed 's/^/ /' >&2
|
||||
echo >&2
|
||||
echo " Run fixtures/snapshot.sh against an empty workspace or" >&2
|
||||
echo " remove the files before merging." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ fixtures/cli-sync/ is clean"
|
||||
@@ -0,0 +1,15 @@
|
||||
defaultTs: bun
|
||||
includes:
|
||||
- f/**
|
||||
excludes: []
|
||||
skipVariables: false
|
||||
skipResources: false
|
||||
skipResourceTypes: false
|
||||
skipSecrets: true
|
||||
includeSchedules: true
|
||||
includeTriggers: true
|
||||
includeUsers: false
|
||||
includeGroups: false
|
||||
includeSettings: false
|
||||
includeKey: false
|
||||
syncBehavior: v1
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Load fixtures/cli-sync/ into a fresh workspace on a local Windmill instance.
|
||||
#
|
||||
# Requires: bun on PATH. No `wmill` install needed — we invoke
|
||||
# cli/src/main.ts directly via `bun run`.
|
||||
#
|
||||
# Assumes a Windmill backend running at http://localhost:8000 with the
|
||||
# default super-admin (admin@windmill.dev / changeme). Override via flags.
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="http://localhost:8000"
|
||||
EMAIL="admin@windmill.dev"
|
||||
# Prefer WMILL_PASSWORD env var over --password flag — flags leak into
|
||||
# /proc/<pid>/cmdline and shell history.
|
||||
PASSWORD="${WMILL_PASSWORD:-changeme}"
|
||||
WORKSPACE=""
|
||||
DIR=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--base-url) BASE_URL="$2"; shift 2 ;;
|
||||
--email) EMAIL="$2"; shift 2 ;;
|
||||
--password) PASSWORD="$2"; shift 2 ;;
|
||||
--workspace) WORKSPACE="$2"; shift 2 ;;
|
||||
--dir) DIR="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
sed -n '2,8p' "$0"; exit 0 ;;
|
||||
*) echo "Unknown flag: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DIR="${DIR:-$SCRIPT_DIR/cli-sync}"
|
||||
DIR="$(cd "$DIR" && pwd)"
|
||||
CLI_ENTRY="$REPO_ROOT/cli/src/main.ts"
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "✗ bun is required but not found on PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$CLI_ENTRY" ]]; then
|
||||
echo "✗ Cannot find CLI entry at $CLI_ENTRY" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$DIR/wmill.yaml" ]]; then
|
||||
echo "✗ No wmill.yaml in $DIR — is the fixture folder set up?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$WORKSPACE" ]]; then
|
||||
# Use $RANDOM rather than piping /dev/urandom through head -c, which
|
||||
# SIGPIPEs `tr` and aborts the script under `set -o pipefail`.
|
||||
printf -v WORKSPACE 'fixture-%04x%04x' $RANDOM $RANDOM
|
||||
fi
|
||||
|
||||
# JSON body builder — interpolation via printf '%s' is unsafe for arbitrary
|
||||
# emails / passwords / workspace ids. Python is universal enough for a dev
|
||||
# script and produces correctly escaped JSON.
|
||||
json_object() {
|
||||
python3 -c '
|
||||
import json, sys
|
||||
print(json.dumps(dict(zip(sys.argv[1::2], sys.argv[2::2]))))
|
||||
' "$@"
|
||||
}
|
||||
|
||||
echo "→ Logging in as $EMAIL on $BASE_URL"
|
||||
TOKEN="$(curl -sS -f -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(json_object email "$EMAIL" password "$PASSWORD")")"
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
echo "✗ Login failed (empty token)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "→ Creating workspace '$WORKSPACE'"
|
||||
CREATE_OUT="$(mktemp)"
|
||||
trap 'rm -f "$CREATE_OUT"' EXIT
|
||||
HTTP_CODE="$(curl -sS -o "$CREATE_OUT" -w '%{http_code}' \
|
||||
-X POST "$BASE_URL/api/workspaces/create" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(json_object id "$WORKSPACE" name "$WORKSPACE")")"
|
||||
if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then
|
||||
echo "✗ Workspace creation failed (HTTP $HTTP_CODE):" >&2
|
||||
cat "$CREATE_OUT" >&2
|
||||
echo >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "→ Pushing $DIR to workspace '$WORKSPACE'"
|
||||
(
|
||||
cd "$DIR"
|
||||
bun run "$CLI_ENTRY" sync push --yes \
|
||||
--base-url "$BASE_URL" \
|
||||
--workspace "$WORKSPACE" \
|
||||
--token "$TOKEN"
|
||||
)
|
||||
|
||||
echo
|
||||
echo "✓ Fixture loaded into workspace '$WORKSPACE'"
|
||||
echo " Open: ${BASE_URL%/}/?workspace=$WORKSPACE"
|
||||
Executable
+100
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env bash
|
||||
# Snapshot a workspace into fixtures/cli-sync/ so it can be committed
|
||||
# alongside a PR.
|
||||
#
|
||||
# Requires: bun on PATH. No `wmill` install needed.
|
||||
#
|
||||
# Assumes a Windmill backend running at http://localhost:8000 with the
|
||||
# default super-admin (admin@windmill.dev / changeme). Override via flags.
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="http://localhost:8000"
|
||||
EMAIL="admin@windmill.dev"
|
||||
# Prefer WMILL_PASSWORD env var over --password flag — flags leak into
|
||||
# /proc/<pid>/cmdline and shell history.
|
||||
PASSWORD="${WMILL_PASSWORD:-changeme}"
|
||||
DIR=""
|
||||
WORKSPACE=""
|
||||
|
||||
if [[ $# -lt 1 || "$1" == "-h" || "$1" == "--help" ]]; then
|
||||
sed -n '2,8p' "$0"
|
||||
echo
|
||||
echo "Usage: $(basename "$0") <workspace-id> [--base-url URL] [--email E] [--password P] [--dir PATH]"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
WORKSPACE="$1"; shift
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--base-url) BASE_URL="$2"; shift 2 ;;
|
||||
--email) EMAIL="$2"; shift 2 ;;
|
||||
--password) PASSWORD="$2"; shift 2 ;;
|
||||
--dir) DIR="$2"; shift 2 ;;
|
||||
*) echo "Unknown flag: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
DIR="${DIR:-$SCRIPT_DIR/cli-sync}"
|
||||
DIR="$(cd "$DIR" && pwd)"
|
||||
CLI_ENTRY="$REPO_ROOT/cli/src/main.ts"
|
||||
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
echo "✗ bun is required but not found on PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$CLI_ENTRY" ]]; then
|
||||
echo "✗ Cannot find CLI entry at $CLI_ENTRY" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$DIR/wmill.yaml" ]]; then
|
||||
echo "✗ No wmill.yaml in $DIR — is the fixture folder set up?" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# JSON body builder — interpolation via printf '%s' is unsafe for arbitrary
|
||||
# emails / passwords. Python is universal enough for a dev script and
|
||||
# produces correctly escaped JSON.
|
||||
json_object() {
|
||||
python3 -c '
|
||||
import json, sys
|
||||
print(json.dumps(dict(zip(sys.argv[1::2], sys.argv[2::2]))))
|
||||
' "$@"
|
||||
}
|
||||
|
||||
echo "→ Logging in as $EMAIL on $BASE_URL"
|
||||
TOKEN="$(curl -sS -f -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(json_object email "$EMAIL" password "$PASSWORD")")"
|
||||
if [[ -z "$TOKEN" ]]; then
|
||||
echo "✗ Login failed (empty token)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clear previous snapshot content while preserving the fixture scaffold
|
||||
# (wmill.yaml, .gitkeep). Anything else is removed so the snapshot reflects
|
||||
# exactly what is in the workspace.
|
||||
echo "→ Clearing previous snapshot in $DIR"
|
||||
(
|
||||
cd "$DIR"
|
||||
find . -mindepth 1 -maxdepth 1 \
|
||||
! -name 'wmill.yaml' \
|
||||
! -name '.gitkeep' \
|
||||
-exec rm -rf {} +
|
||||
)
|
||||
|
||||
echo "→ Pulling workspace '$WORKSPACE' into $DIR"
|
||||
(
|
||||
cd "$DIR"
|
||||
bun run "$CLI_ENTRY" sync pull --yes \
|
||||
--base-url "$BASE_URL" \
|
||||
--workspace "$WORKSPACE" \
|
||||
--token "$TOKEN"
|
||||
)
|
||||
|
||||
echo
|
||||
echo "✓ Snapshot of '$WORKSPACE' written to $DIR"
|
||||
echo " Commit the changes to share with reviewers. Run fixtures/check-empty.sh"
|
||||
echo " to verify the dir is empty again before merging."
|
||||
Generated
+6
-6
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.706.1",
|
||||
"version": "1.709.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.706.1",
|
||||
"version": "1.709.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
@@ -71,7 +71,6 @@
|
||||
"svelte-carousel": "^1.0.25",
|
||||
"svelte-exmarkdown": "^5.0.0",
|
||||
"svelte-infinite-loading": "^1.4.0",
|
||||
"tabbable": "^6.4.0",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
|
||||
@@ -12393,9 +12392,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
|
||||
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
|
||||
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/table": {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "@windmill-labs/components",
|
||||
"version": "1.706.1",
|
||||
"version": "1.709.0",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
|
||||
"build": "vite build",
|
||||
"build:utils": "vite build --config sharedUtils/vite.sharedUtils.config.js",
|
||||
"preview": "vite preview",
|
||||
@@ -125,6 +126,7 @@
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"mdast-util-find-and-replace": "^3.0.2",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0",
|
||||
"monaco-languageclient": "10.6.0",
|
||||
@@ -144,9 +146,7 @@
|
||||
"svelte-carousel": "^1.0.25",
|
||||
"svelte-exmarkdown": "^5.0.0",
|
||||
"svelte-infinite-loading": "^1.4.0",
|
||||
"tabbable": "^6.4.0",
|
||||
"tailwind-merge": "^1.13.2",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0",
|
||||
"vscode-languageclient": "~9.0.1",
|
||||
"vscode-uri": "~3.1.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
|
||||
"version": "6715153",
|
||||
"sha256": "1485930ea5f5309e4bdc09a55aae72eae8230eb74f0928715a0e6fe610703d9b"
|
||||
"version": "00c9834",
|
||||
"sha256": "5757e5b9cbf79c20d507dc4c588640368e84b873cb48f3806ca8c67fd1aa625f"
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
import { goto } from '$lib/navigation'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { UserPlus } from 'lucide-svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -36,7 +38,12 @@
|
||||
if (!username) return
|
||||
await WorkspaceService.createServiceAccount({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: { username: username! }
|
||||
requestBody: {
|
||||
username: username!,
|
||||
is_admin: serviceAccountRole === 'admin',
|
||||
operator: serviceAccountRole === 'operator',
|
||||
add_to_deployers: serviceAccountRole === 'developer' && addToDeployers
|
||||
}
|
||||
})
|
||||
sendUserToast(`Service account '${username}' created`)
|
||||
} else {
|
||||
@@ -80,7 +87,10 @@
|
||||
}
|
||||
|
||||
type UserRole = 'operator' | 'developer' | 'admin' | 'service_account'
|
||||
type ServiceAccountRole = 'operator' | 'developer' | 'admin'
|
||||
let selected: UserRole = $state('developer' as UserRole)
|
||||
let serviceAccountRole: ServiceAccountRole = $state('operator' as ServiceAccountRole)
|
||||
let addToDeployers: boolean = $state(true)
|
||||
let isServiceAccount = $derived(selected === 'service_account')
|
||||
</script>
|
||||
|
||||
@@ -144,6 +154,52 @@
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{#if isServiceAccount}
|
||||
<span class="text-xs mb-1 leading-6">Service account role</span>
|
||||
<ToggleButtonGroup bind:selected={serviceAccountRole} class="mb-4">
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
value="operator"
|
||||
label="Operator"
|
||||
tooltip="Read/run only. Counts as 0.5 seat. Cannot be used for CLI sync or to author scripts/flows/apps."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="developer"
|
||||
label="Developer"
|
||||
tooltip="Can author and edit scripts/flows/apps within its path. Counts as 1 seat. Use this for CLI sync tokens."
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="admin"
|
||||
label="Admin"
|
||||
tooltip="Full workspace admin. Counts as 1 seat. Grant only when the service account needs to manage workspace settings."
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{#if serviceAccountRole === 'developer'}
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<Toggle bind:checked={addToDeployers} size="xs" />
|
||||
<span class="text-xs leading-6">
|
||||
Add to <code>wm_deployers</code>
|
||||
<Tooltip>
|
||||
Recommended when this service account will be used as a <code>wmill sync push</code>
|
||||
/ CI deploy identity. Members of <code>wm_deployers</code> can deploy on behalf of
|
||||
other users in the target workspace.
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/core_concepts/staging_prod#run-on-behalf-of"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="underline">Learn more</a
|
||||
>.
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte'
|
||||
import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { Check, Loader2 } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte'
|
||||
import type { Item } from '$lib/utils'
|
||||
@@ -62,8 +62,17 @@
|
||||
{item.displayName}
|
||||
</p>
|
||||
{@render item.extra?.()}
|
||||
{#if item.shortcut}
|
||||
<span class="ml-auto pl-4 text-2xs text-secondary shrink-0">{item.shortcut}</span>
|
||||
{#if item.shortcut || item.selected}
|
||||
<!-- Single trailing group so `shortcut` and `selected` can coexist:
|
||||
two `ml-auto` siblings would collapse to one right-aligned item. -->
|
||||
<div class="ml-auto flex shrink-0 items-center gap-2">
|
||||
{#if item.shortcut}
|
||||
<span class="pl-4 text-2xs text-secondary">{item.shortcut}</span>
|
||||
{/if}
|
||||
{#if item.selected}
|
||||
<Check size={14} class="text-primary" />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if item.tooltip}
|
||||
<Tooltip>
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
import { isRuleActive } from '$lib/workspaceProtectionRules.svelte'
|
||||
import { buildForkEditUrl } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
import { UserDraft } from '$lib/userDraft.svelte'
|
||||
|
||||
let {
|
||||
initialPath = $bindable(''),
|
||||
@@ -123,6 +124,7 @@
|
||||
children,
|
||||
loadedFromHistoryFromUrl,
|
||||
noInitial = false,
|
||||
liveEditorDraftStoragePath = undefined,
|
||||
onSaveInitial,
|
||||
onSaveDraft,
|
||||
onDeploy,
|
||||
@@ -588,6 +590,23 @@
|
||||
const flowEditorDrawer = writable<FlowEditorDrawer | undefined>(undefined)
|
||||
const history = initHistory(untrack(() => flowStore).val)
|
||||
const pathStore = writable<string>(untrack(() => pathStoreInit) ?? initialPath)
|
||||
|
||||
$effect(() => {
|
||||
if (liveEditorDraftStoragePath === undefined || !$workspaceStore) return
|
||||
const workspace = $workspaceStore
|
||||
UserDraft.setLiveEditorDraft({
|
||||
workspace,
|
||||
itemKind: 'flow',
|
||||
storagePath: liveEditorDraftStoragePath,
|
||||
effectivePath: $pathStore
|
||||
})
|
||||
return () =>
|
||||
UserDraft.clearLiveEditorDraft('flow', {
|
||||
workspace,
|
||||
storagePath: liveEditorDraftStoragePath
|
||||
})
|
||||
})
|
||||
|
||||
const captureOn = writable<boolean>(false)
|
||||
const showCaptureHint = writable<boolean | undefined>(undefined)
|
||||
const flowInputEditorStateStore = writable<FlowInputEditorState>({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<script lang="ts">
|
||||
import { pathToMeta, type Meta } from '$lib/common'
|
||||
|
||||
import { localeConcatAnd, pluralize } from '$lib/utils'
|
||||
import { copyToClipboard, localeConcatAnd, pluralize } from '$lib/utils'
|
||||
import {
|
||||
AppService,
|
||||
FlowService,
|
||||
@@ -30,7 +30,7 @@
|
||||
import { writable } from 'svelte/store'
|
||||
import { Alert, Button } from './common'
|
||||
import { random_adj } from './random_positive_adjetive'
|
||||
import { ChevronDown, SearchCode } from 'lucide-svelte'
|
||||
import { ChevronDown, Copy, SearchCode } from 'lucide-svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { tick } from 'svelte'
|
||||
import FolderPicker from './FolderPicker.svelte'
|
||||
@@ -428,7 +428,7 @@
|
||||
inputBaseClass,
|
||||
inputBorderClass({ error: !!error }),
|
||||
inputSizeClasses[size],
|
||||
'flex gap-0 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center',
|
||||
'relative flex gap-0 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center',
|
||||
disabled && '!bg-surface-disabled cursor-not-allowed border-none'
|
||||
)}
|
||||
>
|
||||
@@ -500,7 +500,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm text-secondary">/</div>
|
||||
<label class="block grow min-w-32">
|
||||
<label class="block grow min-w-32 mr-3">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<PathNameAutocomplete
|
||||
bind:this={inputP}
|
||||
@@ -519,6 +519,15 @@
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
iconOnly
|
||||
size="xs2"
|
||||
variant="subtle"
|
||||
startIcon={{ icon: Copy }}
|
||||
title="Copy path"
|
||||
wrapperClasses="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
on:click={() => copyToClipboard(path)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -283,12 +283,14 @@
|
||||
})
|
||||
|
||||
const overlayPaddingX: Record<InputSize, string> = {
|
||||
'2xs': 'px-1',
|
||||
xs: 'px-1',
|
||||
sm: 'px-2',
|
||||
md: 'px-2',
|
||||
lg: 'px-2'
|
||||
}
|
||||
const overlayHeight: Record<InputSize, string> = {
|
||||
'2xs': 'h-5',
|
||||
xs: 'h-5',
|
||||
sm: 'h-7',
|
||||
md: 'h-8',
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { Drawer, DrawerContent } from './common'
|
||||
import QueueMetricsDrawerInner from './QueueMetricsDrawerInner.svelte'
|
||||
import QueueAlerts from './QueueAlerts.svelte'
|
||||
import WorkspaceFairnessEvents from './WorkspaceFairnessEvents.svelte'
|
||||
import { enterpriseLicense } from '$lib/stores'
|
||||
|
||||
let drawer: Drawer | undefined = $state()
|
||||
export function openDrawer() {
|
||||
@@ -21,6 +23,11 @@
|
||||
|
||||
<QueueMetricsDrawerInner />
|
||||
|
||||
{#if $enterpriseLicense}
|
||||
<div class="py-8"></div>
|
||||
<WorkspaceFairnessEvents />
|
||||
{/if}
|
||||
|
||||
<div class="py-8"></div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -405,7 +405,7 @@
|
||||
</Head>
|
||||
<tbody>
|
||||
{#if filteredUsers && users}
|
||||
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))}
|
||||
{#each filteredUsers.slice(0, nbDisplayed) as { email, super_admin, devops, login_type, name, username, operator_only, is_workspace_admin, role_source, disabled, workspace_id }, i (email + '::' + (workspace_id ?? ''))}
|
||||
{@const isServiceAccount = login_type === 'service_account'}
|
||||
<tr
|
||||
class="{i % 2 === 0 ? 'bg-surface-tertiary' : 'bg-surface'} {disabled
|
||||
@@ -467,7 +467,9 @@
|
||||
>
|
||||
{#if activeOnly}
|
||||
<Cell>
|
||||
{#if operator_only}
|
||||
{#if is_workspace_admin}
|
||||
Admin
|
||||
{:else if operator_only}
|
||||
Operator only
|
||||
{:else}
|
||||
Developer
|
||||
@@ -480,9 +482,15 @@
|
||||
<span
|
||||
class="rounded-md text-xs px-2 py-1 bg-surface shadow-md font-bold"
|
||||
>
|
||||
Operator
|
||||
{is_workspace_admin
|
||||
? 'Admin'
|
||||
: operator_only
|
||||
? 'Operator'
|
||||
: 'Developer'}
|
||||
</span>
|
||||
<Tooltip>Service accounts are always operators.</Tooltip>
|
||||
<Tooltip>
|
||||
Service-account role is managed in the workspace user settings.
|
||||
</Tooltip>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-start">
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import { WorkerService } from '$lib/gen'
|
||||
import { Section } from './common'
|
||||
import Skeleton from './common/skeleton/Skeleton.svelte'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import Button from './common/button/Button.svelte'
|
||||
import { displayDate } from '$lib/utils'
|
||||
import { RefreshCw } from 'lucide-svelte'
|
||||
|
||||
type FairnessEvent = {
|
||||
timestamp: string
|
||||
operation: string
|
||||
workspace_id?: string | null
|
||||
parameters?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
let events: FairnessEvent[] = $state([])
|
||||
let loading = $state(true)
|
||||
let error: string | undefined = $state(undefined)
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
error = undefined
|
||||
try {
|
||||
const res = await WorkerService.getWorkspaceFairnessEvents()
|
||||
events = (res as unknown as FairnessEvent[]) ?? []
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
function formatParameters(p?: Record<string, unknown> | null): string {
|
||||
if (!p) return ''
|
||||
// Audit-log params arrive as `{ hm: { ... }, args: ... }` in EE format —
|
||||
// surface the `hm` map when present, otherwise show the object as-is.
|
||||
const inner = (p as { hm?: Record<string, unknown> }).hm ?? p
|
||||
return Object.entries(inner)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(', ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<Section label="Workspace fairness events">
|
||||
{#snippet action()}
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
iconOnly
|
||||
onclick={load}
|
||||
disabled={loading}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#if loading}
|
||||
<Skeleton layout={[[8]]} />
|
||||
{:else if error}
|
||||
<Alert type="error" title="Failed to load">{error}</Alert>
|
||||
{:else if events.length === 0}
|
||||
<p class="text-secondary text-sm">
|
||||
No workspaces have been added to or removed from the fairness-restricted set yet. Entries
|
||||
appear here once the cap activates.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="text-xs text-secondary mb-2">
|
||||
Showing the last {events.length} workspace-fairness transitions (most recent first).
|
||||
</div>
|
||||
<div class="overflow-x-auto border rounded-md">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-surface-secondary text-secondary">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2 font-medium">Time</th>
|
||||
<th class="text-left px-3 py-2 font-medium">Event</th>
|
||||
<th class="text-left px-3 py-2 font-medium">Workspace</th>
|
||||
<th class="text-left px-3 py-2 font-medium">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each events as e}
|
||||
<tr class="border-t">
|
||||
<td class="px-3 py-2 whitespace-nowrap text-tertiary">
|
||||
{displayDate(e.timestamp, true)}
|
||||
</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">
|
||||
{#if e.operation === 'workspace_fairness.capped'}
|
||||
<Badge color="red">capped</Badge>
|
||||
{:else if e.operation === 'workspace_fairness.uncapped'}
|
||||
<Badge color="green">uncapped</Badge>
|
||||
{:else}
|
||||
<Badge>{e.operation}</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2 font-mono">{e.workspace_id ?? '—'}</td>
|
||||
<td class="px-3 py-2 text-tertiary text-xs">{formatParameters(e.parameters)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Section>
|
||||
@@ -17,7 +17,7 @@ export namespace ButtonType {
|
||||
* @deprecated Use `UnifiedSize` instead
|
||||
*/
|
||||
export type Size = 'xs3' | 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
export type UnifiedSize = 'xs' | 'sm' | 'md' | 'lg'
|
||||
export type UnifiedSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg'
|
||||
export type ExtendedSize = 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
/**
|
||||
* @deprecated Use `Variant` instead
|
||||
@@ -221,6 +221,7 @@ export namespace ButtonType {
|
||||
|
||||
// New unified sizing system
|
||||
export const UnifiedSizingClasses: Record<ButtonType.UnifiedSize, string> = {
|
||||
'2xs': 'px-1', // Compact horizontal padding
|
||||
xs: 'px-2',
|
||||
sm: 'px-2', // Regular horizontal padding
|
||||
md: 'px-4',
|
||||
@@ -228,6 +229,7 @@ export namespace ButtonType {
|
||||
}
|
||||
|
||||
export const UnifiedIconOnlySizingClasses: Record<ButtonType.UnifiedSize, string> = {
|
||||
'2xs': 'px-1',
|
||||
xs: 'px-1',
|
||||
sm: 'px-2', // Square padding for icon-only (same as width padding)
|
||||
md: 'px-2',
|
||||
@@ -235,6 +237,7 @@ export namespace ButtonType {
|
||||
}
|
||||
|
||||
export const UnifiedMinHeightClasses: Record<ButtonType.UnifiedSize, string> = {
|
||||
'2xs': 'min-h-5',
|
||||
xs: 'min-h-5',
|
||||
sm: 'min-h-7',
|
||||
md: 'min-h-8',
|
||||
@@ -242,6 +245,7 @@ export namespace ButtonType {
|
||||
}
|
||||
|
||||
export const UnifiedHeightClasses: Record<ButtonType.UnifiedSize, string> = {
|
||||
'2xs': 'h-5',
|
||||
xs: 'h-5',
|
||||
sm: 'h-7',
|
||||
md: 'h-8',
|
||||
@@ -249,6 +253,7 @@ export namespace ButtonType {
|
||||
}
|
||||
|
||||
export const UnifiedIconSizes: Record<ButtonType.UnifiedSize, number> = {
|
||||
'2xs': 12,
|
||||
xs: 12,
|
||||
sm: 13,
|
||||
md: 14,
|
||||
@@ -256,6 +261,7 @@ export namespace ButtonType {
|
||||
}
|
||||
|
||||
export const UnifiedFontSizes: Record<ButtonType.UnifiedSize, string> = {
|
||||
'2xs': 'font-normal',
|
||||
xs: 'font-normal',
|
||||
sm: 'font-normal',
|
||||
md: 'font-medium',
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
<script lang="ts" module>
|
||||
export type TabItem = {
|
||||
/** Stable identifier; used as the `[key]` for dnd and the activeId equality check. */
|
||||
id: string
|
||||
label: string
|
||||
/** Optional lucide-svelte (or compatible) component rendered at 12px before the label. */
|
||||
icon?: any
|
||||
/** Optional class applied to the icon (e.g. `text-accent` to tint it). */
|
||||
iconClass?: string
|
||||
/** Optional class applied to the label text (e.g. `text-accent` to tint it). */
|
||||
labelClass?: string
|
||||
/** Defaults to true. Set false to hide the × close button. */
|
||||
closable?: boolean
|
||||
/** Pinned tabs are rendered outside the drag zone — 'left' or 'right' of the draggable group. */
|
||||
pinned?: 'left' | 'right'
|
||||
}
|
||||
|
||||
// Per-instance dnd zone `type` so sibling bars (mirrored single-view) don't
|
||||
// share svelte-dnd-action's item pool — otherwise a drag in one ghosts the
|
||||
// matching tab in the other.
|
||||
let dndZoneSeq = 0
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { dndzone, type DndEvent } from '@windmill-labs/svelte-dnd-action'
|
||||
import { X } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { createScrollArea, melt } from '@melt-ui/svelte'
|
||||
import { untrack } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
tabs: TabItem[]
|
||||
activeId: string
|
||||
onSelect: (id: string) => void
|
||||
onClose?: (id: string) => void
|
||||
onReorder?: (newOrder: TabItem[]) => void
|
||||
/** Extra classes for the outer tab strip. */
|
||||
class?: string
|
||||
/** Render after the right-pinned tabs (e.g. a "Split with Preview" toggle). */
|
||||
trailing?: import('svelte').Snippet
|
||||
}
|
||||
|
||||
let { tabs, activeId, onSelect, onClose, onReorder, class: c = '', trailing }: Props = $props()
|
||||
|
||||
const pinnedLeft = $derived(tabs.filter((t) => t.pinned === 'left'))
|
||||
const middle = $derived(tabs.filter((t) => !t.pinned))
|
||||
const pinnedRight = $derived(tabs.filter((t) => t.pinned === 'right'))
|
||||
|
||||
// Unique dnd zone type for this instance (see note in the module block).
|
||||
const dndType = `draggable-tabs-${dndZoneSeq++}`
|
||||
|
||||
// Local list the dnd zone owns. `consider` updates only this (mid-drag it
|
||||
// holds svelte-dnd-action's shadow placeholder); we commit to the parent on
|
||||
// `finalize` so the placeholder never leaks into a sibling bar.
|
||||
let dndMiddle = $state<TabItem[]>(untrack(() => middle))
|
||||
let isDragging = false
|
||||
$effect(() => {
|
||||
const next = middle
|
||||
// Re-sync from props except mid-drag, where the dnd zone owns the list.
|
||||
if (!isDragging) dndMiddle = next
|
||||
})
|
||||
|
||||
// `type: 'hover'` shows the custom bar only while hovering/scrolling the strip.
|
||||
const {
|
||||
elements: { root, viewport, content, scrollbarX, thumbX }
|
||||
} = createScrollArea({ type: 'hover', hideDelay: 600, dir: 'ltr' })
|
||||
|
||||
// melt only re-measures the thumb when its *content* resizes, not the
|
||||
// viewport — so a pane resize leaves the thumb stale. Detect width changes
|
||||
// via `bind:clientWidth` and nudge melt by perturbing the 0×0 sentinel's box.
|
||||
let viewportWidth = $state(0)
|
||||
let resizeSentinel: HTMLSpanElement | undefined = $state(undefined)
|
||||
$effect(() => {
|
||||
void viewportWidth
|
||||
const el = untrack(() => resizeSentinel)
|
||||
if (!el) return
|
||||
el.style.width = '1px'
|
||||
const raf = requestAnimationFrame(() => {
|
||||
el.style.width = '0px'
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
function handleConsider(e: CustomEvent<DndEvent<TabItem>>) {
|
||||
isDragging = true
|
||||
dndMiddle = e.detail.items
|
||||
}
|
||||
function handleFinalize(e: CustomEvent<DndEvent<TabItem>>) {
|
||||
isDragging = false
|
||||
dndMiddle = e.detail.items
|
||||
onReorder?.([...pinnedLeft, ...e.detail.items, ...pinnedRight])
|
||||
}
|
||||
|
||||
function tabClasses(isActive: boolean) {
|
||||
return twMerge(
|
||||
'group inline-flex items-center gap-1.5 px-2.5 h-7 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
|
||||
isActive
|
||||
? 'bg-surface-tertiary text-emphasis'
|
||||
: 'bg-transparent text-hint hover:text-secondary'
|
||||
)
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent, tab: TabItem) {
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (tab.closable !== false) {
|
||||
e.preventDefault()
|
||||
onClose?.(tab.id)
|
||||
}
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
||||
const idx = tabs.findIndex((t) => t.id === tab.id)
|
||||
const next = e.key === 'ArrowLeft' ? idx - 1 : idx + 1
|
||||
if (next >= 0 && next < tabs.length) {
|
||||
onSelect(tabs[next].id)
|
||||
e.preventDefault()
|
||||
}
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onSelect(tab.id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAuxClick(e: MouseEvent, tab: TabItem) {
|
||||
if (e.button === 1 && tab.closable !== false) {
|
||||
e.preventDefault()
|
||||
onClose?.(tab.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet tabButton(tab: TabItem)}
|
||||
{@const isActive = tab.id === activeId}
|
||||
{@const Icon = tab.icon}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
tabindex={isActive ? 0 : -1}
|
||||
class={twMerge(tabClasses(isActive), tab.closable !== false && 'pr-1')}
|
||||
onclick={() => onSelect(tab.id)}
|
||||
onauxclick={(e) => handleAuxClick(e, tab)}
|
||||
onkeydown={(e) => handleKeydown(e, tab)}
|
||||
>
|
||||
{#if Icon}
|
||||
<Icon size={12} class={tab.iconClass} />
|
||||
{/if}
|
||||
<span class={twMerge('truncate max-w-[180px]', tab.labelClass)}>{tab.label}</span>
|
||||
{#if tab.closable !== false}
|
||||
<button
|
||||
type="button"
|
||||
class="opacity-0 group-hover:opacity-100 focus:opacity-100 rounded hover:bg-surface-hover w-4 h-4 inline-flex items-center justify-center"
|
||||
aria-label={`Close ${tab.label}`}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose?.(tab.id)
|
||||
}}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class={twMerge('flex items-center bg-surface', c)}>
|
||||
<div use:melt={$root} class="tabs-root flex-1 min-w-0 relative pt-1 pl-1 pb-1">
|
||||
<div use:melt={$viewport} bind:clientWidth={viewportWidth} class="tabs-viewport w-full">
|
||||
<!-- Inner flex wrapper — melt's content element is forced to
|
||||
`display: table` which would stack the tabs vertically. -->
|
||||
<div use:melt={$content}>
|
||||
<div class="flex items-center" role="tablist">
|
||||
{#each pinnedLeft as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
<div
|
||||
class="flex items-center"
|
||||
use:dndzone={{
|
||||
items: dndMiddle,
|
||||
flipDurationMs: 150,
|
||||
type: dndType,
|
||||
dropTargetStyle: {}
|
||||
}}
|
||||
onconsider={handleConsider}
|
||||
onfinalize={handleFinalize}
|
||||
>
|
||||
{#each dndMiddle as tab (tab.id)}
|
||||
<div>
|
||||
{@render tabButton(tab)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#each pinnedRight as tab (tab.id)}
|
||||
{@render tabButton(tab)}
|
||||
{/each}
|
||||
|
||||
<!-- resize nudge for melt (see the $effect above) -->
|
||||
<span
|
||||
bind:this={resizeSentinel}
|
||||
aria-hidden="true"
|
||||
style="display:inline-block;width:0;height:0"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div use:melt={$scrollbarX} class="tabs-scrollbar">
|
||||
<div use:melt={$thumbX} class="tabs-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if trailing}
|
||||
<div class="ml-1 pr-1 flex items-center shrink-0">
|
||||
{@render trailing()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tabs-viewport {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Custom 4px bar, absolutely positioned so toggling it never shifts layout. */
|
||||
:global([data-melt-scroll-area-scrollbar].tabs-scrollbar) {
|
||||
height: 4px;
|
||||
background: transparent;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
/* Hide the whole bar when melt marks it hidden (key off the scrollbar, not
|
||||
the thumb — the thumb's data-state doesn't track overflow). */
|
||||
:global([data-melt-scroll-area-scrollbar].tabs-scrollbar[data-state='hidden']) {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
:global([data-melt-scroll-area-thumb].tabs-thumb) {
|
||||
/* melt leaves the thumb-height var empty for a horizontal bar, collapsing
|
||||
the inline height to 0 — supply it so the thumb fills the 4px track. */
|
||||
--melt-scroll-area-thumb-height: 100%;
|
||||
height: 100%;
|
||||
width: var(--melt-scroll-area-thumb-width);
|
||||
background: rgb(var(--color-text-hint) / 0.35);
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
:global([data-melt-scroll-area-thumb].tabs-thumb:hover) {
|
||||
background: rgb(var(--color-text-secondary) / 0.6);
|
||||
}
|
||||
</style>
|
||||
@@ -6,9 +6,11 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
AtSign,
|
||||
ChevronDown,
|
||||
ChevronsRight,
|
||||
CheckIcon,
|
||||
Hand,
|
||||
HistoryIcon,
|
||||
Hourglass,
|
||||
MousePointer2,
|
||||
@@ -20,6 +22,7 @@
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import { type DisplayMessage } from './shared'
|
||||
import type { ContextElement } from './context'
|
||||
import ChatQuickActions from './ChatQuickActions.svelte'
|
||||
@@ -38,16 +41,21 @@
|
||||
|
||||
const MAX_YOLO_TOOLTIP_TOOLS = 8
|
||||
const aiChatManager = getAiChatManager()
|
||||
type AutonomyModeOption = { label: string; mode: AIAutonomyMode }
|
||||
// `label` is shown in the dropdown; `shortLabel` (when set) is shown in the
|
||||
// compact trigger pill to save horizontal space.
|
||||
type AutonomyModeOption = { label: string; shortLabel?: 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 }
|
||||
{ label: 'Ask permission', mode: AIAutonomyMode.DEFAULT },
|
||||
{ label: 'Auto-accept edits', mode: AIAutonomyMode.ACCEPT_EDIT },
|
||||
{ label: 'Yolo (bypass permissions)', shortLabel: 'Yolo', mode: AIAutonomyMode.YOLO }
|
||||
]
|
||||
const autonomyModeLabel = (
|
||||
mode: AIAutonomyMode,
|
||||
options: AutonomyModeOption[] = autonomyModeOptions
|
||||
) => options.find((option) => option.mode === mode)?.label ?? autonomyModeOptions[0].label
|
||||
const autonomyModeLabel = (mode: AIAutonomyMode) => {
|
||||
const option = autonomyModeOptions.find((o) => o.mode === mode) ?? autonomyModeOptions[0]
|
||||
return option.shortLabel ?? option.label
|
||||
}
|
||||
// "Auto-accept edits" only applies where script/flow edits can be accepted,
|
||||
// "Bypass permissions" only where tool confirmations exist; filter the picker
|
||||
// to the levels that actually do something in the current mode.
|
||||
const isAutonomyModeAvailable = (
|
||||
mode: AIAutonomyMode,
|
||||
autoAcceptEditsAvailable: boolean,
|
||||
@@ -63,6 +71,16 @@
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Ask-permission holds (raised hand); auto-accept/bypass fast-forward. Color
|
||||
// ramps from muted (ask) to accent (auto-accept) to red (bypass).
|
||||
const autonomyModeIcon = (mode: AIAutonomyMode) =>
|
||||
mode === AIAutonomyMode.DEFAULT ? Hand : ChevronsRight
|
||||
const autonomyModeIconColor = (mode: AIAutonomyMode) =>
|
||||
mode === AIAutonomyMode.YOLO
|
||||
? 'text-red-500'
|
||||
: mode === AIAutonomyMode.DEFAULT
|
||||
? 'text-secondary'
|
||||
: 'text-accent'
|
||||
|
||||
let {
|
||||
messages,
|
||||
@@ -218,6 +236,8 @@
|
||||
)
|
||||
)
|
||||
)
|
||||
// Fall back to ask-permission when the persisted mode isn't applicable in the
|
||||
// current AI mode (e.g. auto-accept edits while in a mode without edits).
|
||||
const effectiveAutonomyMode = $derived(
|
||||
availableAutonomyModeOptions.some((option) => option.mode === aiChatManager.autonomyMode)
|
||||
? aiChatManager.autonomyMode
|
||||
@@ -405,7 +425,7 @@
|
||||
{#if showTypingIndicator}
|
||||
<div
|
||||
class={twMerge(
|
||||
'sticky z-10 mt-2 ml-2 self-start pointer-events-none',
|
||||
'sticky z-10 mt-0.5 ml-2 self-start pointer-events-none',
|
||||
showFlowPendingActionControls ? 'bottom-14' : 'bottom-2'
|
||||
)}
|
||||
>
|
||||
@@ -494,7 +514,7 @@
|
||||
isFirstMessage={messages.length === 0}
|
||||
/>
|
||||
<div
|
||||
class="flex flex-row items-center gap-x-1.5"
|
||||
class="mt-1 flex flex-row flex-wrap items-center gap-x-1.5 gap-y-1"
|
||||
class:justify-between={showFooterLeftControls}
|
||||
class:justify-end={!showFooterLeftControls}
|
||||
>
|
||||
@@ -503,12 +523,14 @@
|
||||
{#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"
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="default"
|
||||
title="Add context"
|
||||
>
|
||||
@
|
||||
</div>
|
||||
iconOnly
|
||||
startIcon={{ icon: AtSign }}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
{#if aiChatManager.mode === AIMode.APP}
|
||||
@@ -538,53 +560,33 @@
|
||||
</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>
|
||||
<DropdownV2
|
||||
items={() =>
|
||||
availableAutonomyModeOptions.map((option) => ({
|
||||
displayName: option.label,
|
||||
selected: effectiveAutonomyMode === option.mode,
|
||||
action: () => aiChatManager.setAutonomyMode(option.mode)
|
||||
}))}
|
||||
placement="bottom-start"
|
||||
fixedHeight={false}
|
||||
customWidth={240}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="default"
|
||||
title={autonomyModeTooltip}
|
||||
startIcon={{
|
||||
icon: autonomyModeIcon(effectiveAutonomyMode),
|
||||
classes: autonomyModeIconColor(effectiveAutonomyMode)
|
||||
}}
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
>
|
||||
{autonomyModeLabel(effectiveAutonomyMode)}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
{#if effectiveAutonomyMode === AIAutonomyMode.YOLO && aiChatManager.autoAcceptToolConfirmationsAvailable}
|
||||
<Tooltip small placement="top">
|
||||
@@ -593,8 +595,8 @@
|
||||
<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.'}
|
||||
? 'Bypass permissions auto-accepts edits and tool usage.'
|
||||
: 'Bypass permissions auto-accepts tool usage.'}
|
||||
</p>
|
||||
<p class="mt-1">
|
||||
{aiChatManager.autoAcceptEditsAvailable
|
||||
|
||||
@@ -126,15 +126,18 @@ function isWorkspacePath(path: string | undefined): path is string {
|
||||
|
||||
function getPersistedAutonomyMode(): AIAutonomyMode {
|
||||
if (!BROWSER || typeof localStorage === 'undefined') {
|
||||
return AIAutonomyMode.DEFAULT
|
||||
return AIAutonomyMode.ACCEPT_EDIT
|
||||
}
|
||||
const persistedMode = localStorage.getItem(AI_AUTONOMY_MODE_STORAGE_KEY)
|
||||
if (isAIAutonomyMode(persistedMode)) {
|
||||
return persistedMode
|
||||
}
|
||||
// No stored preference: default to auto-accepting edits (tool calls still
|
||||
// require confirmation; only YOLO bypasses those). Note this means users who
|
||||
// never opened the autonomy picker now start with edit auto-accept on.
|
||||
return localStorage.getItem(LEGACY_AUTO_ACCEPT_TOOL_CONFIRMATIONS_STORAGE_KEY) === 'true'
|
||||
? AIAutonomyMode.YOLO
|
||||
: AIAutonomyMode.DEFAULT
|
||||
: AIAutonomyMode.ACCEPT_EDIT
|
||||
}
|
||||
|
||||
function persistAutonomyMode(mode: AIAutonomyMode) {
|
||||
|
||||
@@ -46,6 +46,13 @@ vi.mock('./global/gate', () => ({
|
||||
isGlobalAiEnabled: () => true
|
||||
}))
|
||||
|
||||
// Force BROWSER=true so localStorage-backed autonomy persistence is exercised
|
||||
// (the vitest "server" env reports BROWSER=false, which would short-circuit it).
|
||||
vi.mock('esm-env', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('esm-env')>()),
|
||||
BROWSER: true
|
||||
}))
|
||||
|
||||
function createFlowHelpers({
|
||||
hasPendingChanges,
|
||||
acceptAllModuleActions
|
||||
@@ -75,6 +82,9 @@ function createFlowHelpers({
|
||||
describe('AIChatManager autonomy mode', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
// These tests exercise the transition into auto-accept, so start from the
|
||||
// ask-permission baseline rather than the new auto-accept-edits default.
|
||||
localStorage.setItem('ai-chat-autonomy-mode', AIAutonomyMode.DEFAULT)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
@@ -153,3 +163,28 @@ describe('AIChatManager autonomy mode', () => {
|
||||
expect(applied).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AIChatManager persisted autonomy default', () => {
|
||||
// Mirrors the private storage keys in AIChatManager.svelte.ts.
|
||||
const AUTONOMY_KEY = 'ai-chat-autonomy-mode'
|
||||
const LEGACY_YOLO_KEY = 'ai-chat-yolo-mode'
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('defaults to auto-accept edits when no preference is stored', () => {
|
||||
expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.ACCEPT_EDIT)
|
||||
})
|
||||
|
||||
it('maps the legacy auto-accept-tool-confirmations flag to YOLO', () => {
|
||||
localStorage.setItem(LEGACY_YOLO_KEY, 'true')
|
||||
expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.YOLO)
|
||||
})
|
||||
|
||||
it('restores an explicitly persisted autonomy mode', () => {
|
||||
localStorage.setItem(AUTONOMY_KEY, AIAutonomyMode.DEFAULT)
|
||||
expect(new AIChatManager().autonomyMode).toBe(AIAutonomyMode.DEFAULT)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMount, tick } from 'svelte'
|
||||
import { CircleHelp } from 'lucide-svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
||||
import { aiChatManager } from './AIChatManager.svelte'
|
||||
import type { UserQuestionDisplay } from './shared'
|
||||
|
||||
@@ -13,6 +14,8 @@
|
||||
let { toolCallId, userQuestion }: Props = $props()
|
||||
|
||||
let choiceButtons = $state<(HTMLButtonElement | undefined)[]>([])
|
||||
let customAnswer = $state('')
|
||||
let canSubmitCustomAnswer = $derived(customAnswer.trim().length > 0)
|
||||
|
||||
onMount(() => {
|
||||
if (userQuestion.choices.length === 0) {
|
||||
@@ -32,6 +35,15 @@
|
||||
aiChatManager.handleUserQuestionAnswer(toolCallId, choice)
|
||||
}
|
||||
|
||||
function submitCustomAnswer() {
|
||||
const answer = customAnswer.trim()
|
||||
if (!answer) {
|
||||
return
|
||||
}
|
||||
|
||||
aiChatManager.handleUserQuestionAnswer(toolCallId, answer)
|
||||
}
|
||||
|
||||
function handleChoiceKeydown(event: KeyboardEvent, choice: string, index: number) {
|
||||
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
@@ -53,6 +65,16 @@
|
||||
selectChoice(choice)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCustomAnswerKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== 'Enter') {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
submitCustomAnswer()
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -81,5 +103,27 @@
|
||||
</span>
|
||||
</Button>
|
||||
{/each}
|
||||
|
||||
<div class="flex min-w-0 gap-2 pt-1">
|
||||
<TextInput
|
||||
bind:value={customAnswer}
|
||||
class="min-w-0 flex-1"
|
||||
size="sm"
|
||||
inputProps={{
|
||||
'aria-label': 'Custom answer',
|
||||
placeholder: 'Custom answer',
|
||||
onkeydown: handleCustomAnswerKeydown
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="default"
|
||||
unifiedSize="sm"
|
||||
disabled={!canSubmitCustomAnswer}
|
||||
onClick={submitCustomAnswer}
|
||||
btnClasses="shrink-0"
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,54 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { aiChatManager, AIMode } from './AIChatManager.svelte'
|
||||
|
||||
const modeLabel = (mode: AIMode) => mode.charAt(0).toUpperCase() + mode.slice(1) + ' mode'
|
||||
|
||||
let allowedModeList = $derived(
|
||||
Object.values(AIMode).filter((mode) => aiChatManager.allowedModes[mode])
|
||||
)
|
||||
let hasMultiple = $derived(allowedModeList.length > 1)
|
||||
</script>
|
||||
|
||||
<div class="min-w-0">
|
||||
<Popover
|
||||
disablePopup={Object.keys(aiChatManager.allowedModes).filter(
|
||||
(k) => aiChatManager.allowedModes[k]
|
||||
).length < 2}
|
||||
class="max-w-full"
|
||||
{#if hasMultiple}
|
||||
<DropdownV2
|
||||
items={() =>
|
||||
allowedModeList.map((mode) => ({
|
||||
displayName: modeLabel(mode),
|
||||
selected: aiChatManager.mode === mode,
|
||||
action: () => aiChatManager.changeMode(mode)
|
||||
}))}
|
||||
placement="bottom-start"
|
||||
fixedHeight={false}
|
||||
customWidth={170}
|
||||
>
|
||||
{#snippet trigger()}
|
||||
|
||||
<div
|
||||
class="text-primary text-xs flex flex-row items-center font-normal gap-0.5 border px-1 rounded-lg"
|
||||
>
|
||||
<span class={`truncate`}>
|
||||
{aiChatManager.mode.charAt(0).toUpperCase() + aiChatManager.mode.slice(1)} mode
|
||||
</span>
|
||||
{#if Object.keys(aiChatManager.allowedModes).filter((k) => aiChatManager.allowedModes[k]).length > 1}
|
||||
<div class="shrink-0">
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
|
||||
<div class="flex flex-col gap-1 p-1 min-w-24">
|
||||
{#each Object.values(AIMode) as possibleMode}
|
||||
{#if aiChatManager.allowedModes[possibleMode]}
|
||||
<button
|
||||
class={twMerge(
|
||||
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
|
||||
aiChatManager.mode === possibleMode && 'bg-surface-hover'
|
||||
)}
|
||||
onclick={() => {
|
||||
aiChatManager.changeMode(possibleMode)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{possibleMode.charAt(0).toUpperCase() + possibleMode.slice(1)} mode
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button nonCaptureEvent unifiedSize="2xs" variant="subtle" endIcon={{ icon: ChevronDown }}>
|
||||
{modeLabel(aiChatManager.mode)}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{:else}
|
||||
<Button unifiedSize="2xs" variant="subtle">
|
||||
{modeLabel(aiChatManager.mode)}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte'
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import {
|
||||
COPILOT_SESSION_MODEL_SETTING_NAME,
|
||||
COPILOT_SESSION_PROVIDER_SETTING_NAME
|
||||
} from '$lib/stores'
|
||||
import { storeLocalSetting } from '$lib/utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
|
||||
|
||||
let providerModel = $derived(
|
||||
@@ -21,37 +21,35 @@
|
||||
let multipleModels = $derived($copilotInfo.aiModels.length > 1)
|
||||
</script>
|
||||
|
||||
<div class="min-w-0">
|
||||
<Popover disablePopup={!multipleModels} class="max-w-full">
|
||||
{#snippet trigger()}
|
||||
<div class="text-secondary text-2xs flex flex-row items-center font-normal gap-0.5">
|
||||
<span class={`truncate ${multipleModels ? '' : 'pr-2'}`}>{providerModel.model}</span>
|
||||
{#if multipleModels}
|
||||
<div class="shrink-0">
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if multipleModels}
|
||||
<DropdownV2
|
||||
items={() =>
|
||||
$copilotInfo.aiModels.map((m) => ({
|
||||
displayName: m.model,
|
||||
selected: m.model === providerModel.model,
|
||||
action: () => {
|
||||
$copilotSessionModel = m
|
||||
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model)
|
||||
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider)
|
||||
}
|
||||
}))}
|
||||
placement="bottom-end"
|
||||
fixedHeight={false}
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<Button
|
||||
nonCaptureEvent
|
||||
unifiedSize="2xs"
|
||||
variant="subtle"
|
||||
endIcon={{ icon: ChevronDown }}
|
||||
btnClasses="max-w-[160px] text-secondary font-normal"
|
||||
>
|
||||
<span class="truncate">{providerModel.model}</span>
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet content({ close })}
|
||||
<div class="flex flex-col gap-1 p-1 min-w-24">
|
||||
{#each $copilotInfo.aiModels as providerModel}
|
||||
<button
|
||||
class={twMerge(
|
||||
'text-left text-xs hover:bg-surface-hover rounded-md p-1 font-normal',
|
||||
providerModel.model === $copilotSessionModel?.model && 'bg-surface-hover'
|
||||
)}
|
||||
onclick={() => {
|
||||
$copilotSessionModel = providerModel
|
||||
storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, providerModel.model)
|
||||
storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, providerModel.provider)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{providerModel.model}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
</DropdownV2>
|
||||
{:else}
|
||||
<Button unifiedSize="2xs" variant="subtle" btnClasses="max-w-[160px] text-secondary font-normal">
|
||||
<span class="truncate">{providerModel.model}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Flow, NewScript, Script } from '$lib/gen/types.gen'
|
||||
import { buildFlowDeployRequestBody, buildScriptDeployRequestBody } from './deployRequests'
|
||||
import type { WorkspaceItem } from './draftStore.svelte'
|
||||
import type { WorkspaceItem } from './workspaceItems'
|
||||
|
||||
describe('global AI deploy request builders', () => {
|
||||
it('preserves existing script metadata while replacing draft-controlled fields', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Flow, NewScript, OpenFlowWPath, Script } from '$lib/gen/types.gen'
|
||||
import type { FlowDraftValue, WorkspaceItem } from './draftStore.svelte'
|
||||
import type { FlowDraftValue, WorkspaceItem } from './workspaceItems'
|
||||
|
||||
type ScriptWithDeployMetadata = Script & Partial<Pick<NewScript, 'assets' | 'cache_ignore_s3_path'>>
|
||||
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import type {
|
||||
AzureTriggerData,
|
||||
CreateResource,
|
||||
CreateVariable,
|
||||
FlowValue,
|
||||
GcpTriggerData,
|
||||
NewHttpTrigger,
|
||||
NewKafkaTrigger,
|
||||
NewMqttTrigger,
|
||||
NewNatsTrigger,
|
||||
NewPostgresTrigger,
|
||||
NewSchedule,
|
||||
NewSqsTrigger,
|
||||
NewWebsocketTrigger,
|
||||
Policy,
|
||||
ScriptLang
|
||||
} from '$lib/gen/types.gen'
|
||||
|
||||
/**
|
||||
* Flow draft value. Mirrors what the backend's create/update flow API expects
|
||||
* — the OpenFlow value, plus the inputs schema and (optional) groups.
|
||||
*
|
||||
* Schema and groups are split out from FlowValue intentionally so that
|
||||
* deploy_workspace_item can preserve them through the draft → workspace
|
||||
* round-trip; an earlier version dropped them on every deploy.
|
||||
*/
|
||||
export type FlowDraftValue = {
|
||||
value: FlowValue
|
||||
schema?: Record<string, any> | null
|
||||
groups?: NonNullable<FlowValue['groups']> | null
|
||||
}
|
||||
|
||||
export const TRIGGER_KINDS = [
|
||||
'http',
|
||||
'websocket',
|
||||
'kafka',
|
||||
'nats',
|
||||
'postgres',
|
||||
'mqtt',
|
||||
'sqs',
|
||||
'gcp',
|
||||
'azure'
|
||||
] as const
|
||||
|
||||
export type TriggerKind = (typeof TRIGGER_KINDS)[number]
|
||||
|
||||
export type TriggerRequestBody =
|
||||
| NewHttpTrigger
|
||||
| NewWebsocketTrigger
|
||||
| NewKafkaTrigger
|
||||
| NewNatsTrigger
|
||||
| NewPostgresTrigger
|
||||
| NewMqttTrigger
|
||||
| NewSqsTrigger
|
||||
| GcpTriggerData
|
||||
| AzureTriggerData
|
||||
|
||||
export type WorkspaceItemType =
|
||||
| 'script'
|
||||
| 'flow'
|
||||
| 'schedule'
|
||||
| 'trigger'
|
||||
| 'resource'
|
||||
| 'variable'
|
||||
| 'app'
|
||||
|
||||
export type AppDraftValue = {
|
||||
summary?: string
|
||||
files: Record<string, string>
|
||||
runnables: Record<string, any>
|
||||
data?: any
|
||||
policy?: Policy
|
||||
custom_path?: string
|
||||
}
|
||||
|
||||
export type WorkspaceItem = {
|
||||
type: WorkspaceItemType
|
||||
path: string
|
||||
summary?: string
|
||||
language?: ScriptLang
|
||||
triggerKind?: TriggerKind
|
||||
value?:
|
||||
| string
|
||||
| FlowDraftValue
|
||||
| NewSchedule
|
||||
| TriggerRequestBody
|
||||
| CreateResource
|
||||
| CreateVariable
|
||||
| AppDraftValue
|
||||
isDraft: boolean
|
||||
}
|
||||
|
||||
export function getWorkspaceItemKey(
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): string {
|
||||
if (type === 'trigger') {
|
||||
return `trigger:${triggerKind ?? ''}:${path}`
|
||||
}
|
||||
return `${type}:${path}`
|
||||
}
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone($state.snapshot(value)) as T
|
||||
}
|
||||
|
||||
class GlobalDraftStore {
|
||||
private drafts = $state<Record<string, Record<string, WorkspaceItem>>>({})
|
||||
|
||||
private getWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
|
||||
return this.drafts[workspace] ?? {}
|
||||
}
|
||||
|
||||
private ensureWorkspaceDrafts(workspace: string): Record<string, WorkspaceItem> {
|
||||
if (!this.drafts[workspace]) {
|
||||
this.drafts[workspace] = {}
|
||||
}
|
||||
return this.drafts[workspace]
|
||||
}
|
||||
|
||||
listDrafts(workspace: string): WorkspaceItem[] {
|
||||
return Object.values(this.getWorkspaceDrafts(workspace)).map(clone)
|
||||
}
|
||||
|
||||
getDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
const draft = this.getWorkspaceDrafts(workspace)[getWorkspaceItemKey(type, path, triggerKind)]
|
||||
return draft ? clone(draft) : undefined
|
||||
}
|
||||
|
||||
setDraft(workspace: string, item: WorkspaceItem): WorkspaceItem {
|
||||
const stored: WorkspaceItem = { ...clone(item), isDraft: true }
|
||||
this.ensureWorkspaceDrafts(workspace)[
|
||||
getWorkspaceItemKey(item.type, item.path, item.triggerKind)
|
||||
] = stored
|
||||
return clone(stored)
|
||||
}
|
||||
|
||||
deleteDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): void {
|
||||
const drafts = this.drafts[workspace]
|
||||
if (!drafts) return
|
||||
|
||||
delete drafts[getWorkspaceItemKey(type, path, triggerKind)]
|
||||
if (Object.keys(drafts).length === 0) {
|
||||
delete this.drafts[workspace]
|
||||
}
|
||||
}
|
||||
|
||||
clearDrafts(workspace: string): void {
|
||||
delete this.drafts[workspace]
|
||||
}
|
||||
|
||||
getScriptDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'script', path)
|
||||
}
|
||||
|
||||
getFlowDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'flow', path)
|
||||
}
|
||||
|
||||
getScheduleDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'schedule', path)
|
||||
}
|
||||
|
||||
getTriggerDraft(workspace: string, kind: TriggerKind, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'trigger', path, kind)
|
||||
}
|
||||
|
||||
getResourceDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'resource', path)
|
||||
}
|
||||
|
||||
getVariableDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'variable', path)
|
||||
}
|
||||
|
||||
getAppDraft(workspace: string, path: string): WorkspaceItem | undefined {
|
||||
return this.getDraft(workspace, 'app', path)
|
||||
}
|
||||
}
|
||||
|
||||
export const globalDraftStore = new GlobalDraftStore()
|
||||
@@ -1,56 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { globalDraftStore } from './draftStore.svelte'
|
||||
|
||||
const WORKSPACE_A = 'draft-store-test-a'
|
||||
const WORKSPACE_B = 'draft-store-test-b'
|
||||
|
||||
function clearTestDrafts() {
|
||||
globalDraftStore.clearDrafts(WORKSPACE_A)
|
||||
globalDraftStore.clearDrafts(WORKSPACE_B)
|
||||
}
|
||||
|
||||
describe('globalDraftStore', () => {
|
||||
beforeEach(clearTestDrafts)
|
||||
|
||||
it('lists and reads drafts only from the requested workspace', () => {
|
||||
globalDraftStore.setDraft(WORKSPACE_A, {
|
||||
type: 'script',
|
||||
path: 'f/shared/path',
|
||||
language: 'bun',
|
||||
value: 'export async function main() {}',
|
||||
isDraft: true
|
||||
})
|
||||
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_A, 'script', 'f/shared/path')?.value).toBe(
|
||||
'export async function main() {}'
|
||||
)
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_B, 'script', 'f/shared/path')).toBeUndefined()
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(1)
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deletes and clears drafts only from the requested workspace', () => {
|
||||
globalDraftStore.setDraft(WORKSPACE_A, {
|
||||
type: 'flow',
|
||||
path: 'f/shared/path',
|
||||
value: { value: { modules: [] }, schema: null, groups: null },
|
||||
isDraft: true
|
||||
})
|
||||
globalDraftStore.setDraft(WORKSPACE_B, {
|
||||
type: 'flow',
|
||||
path: 'f/shared/path',
|
||||
value: { value: { modules: [] }, schema: { workspace: WORKSPACE_B }, groups: null },
|
||||
isDraft: true
|
||||
})
|
||||
|
||||
globalDraftStore.deleteDraft(WORKSPACE_A, 'flow', 'f/shared/path')
|
||||
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_A, 'flow', 'f/shared/path')).toBeUndefined()
|
||||
expect(globalDraftStore.getDraft(WORKSPACE_B, 'flow', 'f/shared/path')).toBeDefined()
|
||||
|
||||
globalDraftStore.clearDrafts(WORKSPACE_B)
|
||||
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_A)).toHaveLength(0)
|
||||
expect(globalDraftStore.listDrafts(WORKSPACE_B)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,395 @@
|
||||
import type { Flow, NewSchedule, NewScript } from '$lib/gen/types.gen'
|
||||
import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA } from '$lib/components/raw_apps/dataTableRefUtils'
|
||||
import {
|
||||
UserDraft,
|
||||
type UserDraftEntry,
|
||||
type UserDraftItemKind,
|
||||
type UserDraftMeta
|
||||
} from '$lib/userDraft.svelte'
|
||||
import {
|
||||
getWorkspaceItemKey,
|
||||
type AppDraftValue,
|
||||
type ResourceDraftState,
|
||||
type TriggerKind,
|
||||
type TriggerRequestBody,
|
||||
type VariableDraftState,
|
||||
type WorkspaceItem,
|
||||
type WorkspaceItemType
|
||||
} from './workspaceItems'
|
||||
|
||||
const TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND = {
|
||||
http: 'trigger_http',
|
||||
websocket: 'trigger_websocket',
|
||||
kafka: 'trigger_kafka',
|
||||
nats: 'trigger_nats',
|
||||
postgres: 'trigger_postgres',
|
||||
mqtt: 'trigger_mqtt',
|
||||
sqs: 'trigger_sqs',
|
||||
gcp: 'trigger_gcp',
|
||||
azure: 'trigger_azure'
|
||||
} as const satisfies Record<TriggerKind, UserDraftItemKind>
|
||||
|
||||
const TRIGGER_KIND_BY_DRAFT_KIND = Object.fromEntries(
|
||||
Object.entries(TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND).map(([triggerKind, draftKind]) => [
|
||||
draftKind,
|
||||
triggerKind
|
||||
])
|
||||
) as Partial<Record<UserDraftItemKind, TriggerKind>>
|
||||
|
||||
const GLOBAL_DRAFT_KINDS = [
|
||||
'script',
|
||||
'flow',
|
||||
'raw_app',
|
||||
'trigger_schedule',
|
||||
'trigger_http',
|
||||
'trigger_websocket',
|
||||
'trigger_kafka',
|
||||
'trigger_nats',
|
||||
'trigger_postgres',
|
||||
'trigger_mqtt',
|
||||
'trigger_sqs',
|
||||
'trigger_gcp',
|
||||
'trigger_azure',
|
||||
'resource',
|
||||
'variable'
|
||||
] as const satisfies UserDraftItemKind[]
|
||||
|
||||
const secretVariableDraftValues = new Map<string, Map<string, string>>()
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return structuredClone(value) as T
|
||||
}
|
||||
|
||||
function normalizeAppDraftValue(value: AppDraftValue): AppDraftValue {
|
||||
return {
|
||||
summary: value.summary,
|
||||
files: { ...(value.files ?? {}) },
|
||||
runnables: { ...(value.runnables ?? {}) },
|
||||
data: value.data ?? { ...DEFAULT_RAW_APP_DATA },
|
||||
policy: value.policy === undefined ? undefined : clone(value.policy),
|
||||
custom_path: value.custom_path
|
||||
}
|
||||
}
|
||||
|
||||
function getItemSummary(value: unknown): string | undefined {
|
||||
return ((value as { summary?: string | null } | undefined)?.summary ?? undefined) || undefined
|
||||
}
|
||||
|
||||
export function setEphemeralSecretVariableDraftValue(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: string
|
||||
): void {
|
||||
let workspaceValues = secretVariableDraftValues.get(workspace)
|
||||
if (!workspaceValues) {
|
||||
workspaceValues = new Map()
|
||||
secretVariableDraftValues.set(workspace, workspaceValues)
|
||||
}
|
||||
workspaceValues.set(path, value)
|
||||
}
|
||||
|
||||
export function getEphemeralSecretVariableDraftValue(
|
||||
workspace: string,
|
||||
path: string
|
||||
): string | undefined {
|
||||
return secretVariableDraftValues.get(workspace)?.get(path)
|
||||
}
|
||||
|
||||
export function clearEphemeralSecretVariableDraftValue(workspace: string, path: string): void {
|
||||
const workspaceValues = secretVariableDraftValues.get(workspace)
|
||||
if (!workspaceValues) return
|
||||
workspaceValues.delete(path)
|
||||
if (workspaceValues.size === 0) secretVariableDraftValues.delete(workspace)
|
||||
}
|
||||
|
||||
function clearEphemeralSecretVariableDraftValues(workspace: string): void {
|
||||
secretVariableDraftValues.delete(workspace)
|
||||
}
|
||||
|
||||
function itemKindFor(
|
||||
type: WorkspaceItemType,
|
||||
triggerKind?: TriggerKind
|
||||
): UserDraftItemKind | undefined {
|
||||
switch (type) {
|
||||
case 'script':
|
||||
case 'flow':
|
||||
case 'resource':
|
||||
case 'variable':
|
||||
return type
|
||||
case 'app':
|
||||
return 'raw_app'
|
||||
case 'schedule':
|
||||
return 'trigger_schedule'
|
||||
case 'trigger':
|
||||
return triggerKind ? TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[triggerKind] : undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerKindToUserDraftKind(kind: TriggerKind): UserDraftItemKind {
|
||||
return TRIGGER_DRAFT_KIND_BY_TRIGGER_KIND[kind]
|
||||
}
|
||||
|
||||
function scriptDraftToWorkspaceItem(path: string, draft: NewScript): WorkspaceItem {
|
||||
return {
|
||||
type: 'script',
|
||||
path,
|
||||
summary: draft.summary,
|
||||
language: draft.language,
|
||||
value: draft.content,
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function flowDraftToWorkspaceItem(path: string, draft: Flow): WorkspaceItem {
|
||||
return {
|
||||
type: 'flow',
|
||||
path,
|
||||
summary: draft.summary,
|
||||
value: {
|
||||
value: draft.value,
|
||||
schema: draft.schema ?? null,
|
||||
groups: draft.value.groups ?? null
|
||||
},
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function appDraftToWorkspaceItem(path: string, draft: AppDraftValue): WorkspaceItem {
|
||||
const value = normalizeAppDraftValue(draft)
|
||||
return {
|
||||
type: 'app',
|
||||
path,
|
||||
summary: value.summary,
|
||||
value,
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleDraftToWorkspaceItem(path: string, draft: NewSchedule): WorkspaceItem {
|
||||
return {
|
||||
type: 'schedule',
|
||||
path,
|
||||
summary: draft.summary ?? undefined,
|
||||
value: clone(draft),
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDraftToWorkspaceItem(
|
||||
kind: TriggerKind,
|
||||
path: string,
|
||||
draft: TriggerRequestBody
|
||||
): WorkspaceItem {
|
||||
return {
|
||||
type: 'trigger',
|
||||
triggerKind: kind,
|
||||
path,
|
||||
summary: getItemSummary(draft),
|
||||
value: clone(draft),
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function resourceDraftToWorkspaceItem(path: string, draft: ResourceDraftState): WorkspaceItem {
|
||||
return {
|
||||
type: 'resource',
|
||||
path,
|
||||
summary: draft.description || undefined,
|
||||
value: {
|
||||
path,
|
||||
value: clone(draft.args),
|
||||
description: draft.description,
|
||||
resource_type: draft.resource_type ?? '',
|
||||
labels: draft.labels,
|
||||
ws_specific: draft.wsSpecific
|
||||
},
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState): WorkspaceItem {
|
||||
return {
|
||||
type: 'variable',
|
||||
path,
|
||||
summary: draft.variable.description || undefined,
|
||||
value: {
|
||||
path,
|
||||
value: draft.variable.value,
|
||||
is_secret: draft.variable.is_secret,
|
||||
description: draft.variable.description,
|
||||
account: draft.account,
|
||||
is_oauth: draft.is_oauth,
|
||||
expires_at: draft.expires_at,
|
||||
labels: draft.labels,
|
||||
ws_specific: draft.wsSpecific
|
||||
},
|
||||
isDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function userDraftEntryToWorkspaceItem(
|
||||
entry: UserDraftEntry,
|
||||
path = entry.path,
|
||||
isLiveDraft = false
|
||||
): WorkspaceItem | undefined {
|
||||
let item: WorkspaceItem | undefined
|
||||
switch (entry.itemKind) {
|
||||
case 'script':
|
||||
item = scriptDraftToWorkspaceItem(path, entry.value as NewScript)
|
||||
break
|
||||
case 'flow':
|
||||
item = flowDraftToWorkspaceItem(path, entry.value as Flow)
|
||||
break
|
||||
case 'raw_app':
|
||||
item = appDraftToWorkspaceItem(path, entry.value as AppDraftValue)
|
||||
break
|
||||
case 'trigger_schedule':
|
||||
item = scheduleDraftToWorkspaceItem(path, entry.value as NewSchedule)
|
||||
break
|
||||
case 'resource':
|
||||
item = resourceDraftToWorkspaceItem(path, entry.value as ResourceDraftState)
|
||||
break
|
||||
case 'variable':
|
||||
item = variableDraftToWorkspaceItem(path, entry.value as VariableDraftState)
|
||||
break
|
||||
default: {
|
||||
const triggerKind = TRIGGER_KIND_BY_DRAFT_KIND[entry.itemKind]
|
||||
item = triggerKind
|
||||
? triggerDraftToWorkspaceItem(triggerKind, path, entry.value as TriggerRequestBody)
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
return item && isLiveDraft ? { ...item, isLiveDraft: true } : item
|
||||
}
|
||||
|
||||
function liveDisplayPath(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
storagePath: string
|
||||
): { displayPath: string; isLiveDraft: boolean } {
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (liveDraft?.storagePath !== storagePath) {
|
||||
return { displayPath: storagePath, isLiveDraft: false }
|
||||
}
|
||||
return {
|
||||
displayPath: liveDraft.effectivePath || storagePath,
|
||||
isLiveDraft: true
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDraftStoragePath(
|
||||
workspace: string,
|
||||
itemKind: UserDraftItemKind,
|
||||
path: string
|
||||
): string {
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (!liveDraft) return path
|
||||
if (path === liveDraft.storagePath || path === liveDraft.effectivePath)
|
||||
return liveDraft.storagePath
|
||||
return path
|
||||
}
|
||||
|
||||
export function getGlobalDraftStoragePath(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): string {
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
return itemKind ? resolveDraftStoragePath(workspace, itemKind, path) : path
|
||||
}
|
||||
|
||||
function getGlobalDraftSlot(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
) {
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
if (!itemKind) return undefined
|
||||
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
|
||||
const draft = UserDraft.get(itemKind, storagePath, { workspace })
|
||||
if (draft === undefined) return undefined
|
||||
|
||||
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, itemKind, storagePath)
|
||||
const entry = {
|
||||
workspace,
|
||||
itemKind,
|
||||
path: storagePath,
|
||||
value: draft,
|
||||
meta: {},
|
||||
persisted: false,
|
||||
live: false
|
||||
}
|
||||
const item = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
|
||||
if (!item) return undefined
|
||||
return { itemKind, storagePath, displayPath, item }
|
||||
}
|
||||
|
||||
export function getGlobalDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind
|
||||
): WorkspaceItem | undefined {
|
||||
return getGlobalDraftSlot(workspace, type, path, triggerKind)?.item
|
||||
}
|
||||
|
||||
export function listGlobalDrafts(workspace: string): WorkspaceItem[] {
|
||||
const drafts = new Map<string, WorkspaceItem>()
|
||||
for (const entry of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
|
||||
const { displayPath, isLiveDraft } = liveDisplayPath(workspace, entry.itemKind, entry.path)
|
||||
const draft = userDraftEntryToWorkspaceItem(entry, displayPath, isLiveDraft)
|
||||
if (!draft) continue
|
||||
drafts.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), draft)
|
||||
}
|
||||
return Array.from(drafts.values())
|
||||
}
|
||||
|
||||
export function saveGlobalAppDraft(
|
||||
workspace: string,
|
||||
path: string,
|
||||
value: AppDraftValue,
|
||||
meta?: UserDraftMeta
|
||||
): WorkspaceItem {
|
||||
const storagePath = resolveDraftStoragePath(workspace, 'raw_app', path)
|
||||
const normalized = normalizeAppDraftValue(value)
|
||||
if (meta) {
|
||||
UserDraft.setDraftAndMeta('raw_app', storagePath, normalized, meta, { workspace })
|
||||
} else {
|
||||
UserDraft.save('raw_app', storagePath, normalized, { workspace })
|
||||
}
|
||||
const stored = getGlobalDraft(workspace, 'app', path)
|
||||
if (!stored) throw new Error(`Could not read written app draft "${path}".`)
|
||||
return stored
|
||||
}
|
||||
|
||||
type DeleteGlobalDraftOptions = {
|
||||
preserveLiveDraft?: boolean
|
||||
}
|
||||
|
||||
export function deleteGlobalDraft(
|
||||
workspace: string,
|
||||
type: WorkspaceItemType,
|
||||
path: string,
|
||||
triggerKind?: TriggerKind,
|
||||
options: DeleteGlobalDraftOptions = {}
|
||||
): void {
|
||||
const itemKind = itemKindFor(type, triggerKind)
|
||||
if (!itemKind) return
|
||||
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
|
||||
const liveDraft = UserDraft.getLiveEditorDraft(itemKind, { workspace })
|
||||
if (options.preserveLiveDraft && liveDraft?.storagePath === storagePath) {
|
||||
UserDraft.remove(itemKind, storagePath, { workspace })
|
||||
} else {
|
||||
UserDraft.clear(itemKind, storagePath, { workspace })
|
||||
}
|
||||
if (type === 'variable') clearEphemeralSecretVariableDraftValue(workspace, storagePath)
|
||||
}
|
||||
|
||||
export function clearGlobalDrafts(workspace: string): void {
|
||||
for (const draft of UserDraft.list({ workspace, itemKinds: [...GLOBAL_DRAFT_KINDS] })) {
|
||||
UserDraft.clear(draft.itemKind, draft.path, { workspace })
|
||||
}
|
||||
clearEphemeralSecretVariableDraftValues(workspace)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user