Merge remote-tracking branch 'origin/main' into di/data-tables

This commit is contained in:
Diego Imbert
2025-11-21 12:39:06 +01:00
251 changed files with 10006 additions and 1998 deletions
+18 -8
View File
@@ -67,13 +67,23 @@ jobs:
- name: Substitute EE code (EE logic is behind feature flag)
run: |
./substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Cache DuckDB FFI module build
uses: actions/cache@v3
with:
path: ./backend/windmill-duckdb-ffi-internal/target
key: ${{ runner.os }}-duckdb-ffi-${{ hashFiles('./backend/windmill-duckdb-ffi-internal/src/**/*.rs', './backend/windmill-duckdb-ffi-internal/Cargo.toml', './backend/windmill-duckdb-ffi-internal/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-duckdb-ffi-
- name: cargo test
timeout-minutes: 16
run: deno --version && bun -v && go version && python3 --version &&
SQLX_OFFLINE=true
DATABASE_URL=postgres://postgres:changeme@localhost:5432/windmill
DISABLE_EMBEDDING=true RUST_LOG=info RUST_LOG_STYLE=never
DENO_PATH=$(which deno) BUN_PATH=$(which bun) GO_PATH=$(which go)
UV_PATH=$(which uv) cargo test --features
enterprise,deno_core,license,python,rust,scoped_cache,private --all --
--nocapture
env:
SQLX_OFFLINE: true
DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill
DISABLE_EMBEDDING: true
RUST_LOG: info
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
run: |
deno --version && bun -v && go version && python3 --version
cd windmill-duckdb-ffi-internal && ./build_dev.sh && cd ..
DENO_PATH=$(which deno) BUN_PATH=$(which bun) GO_PATH=$(which go) UV_PATH=$(which uv) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,private --all -- --nocapture
@@ -0,0 +1,140 @@
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
name: Build and publish windmill for RHEL8
on: workflow_dispatch
permissions: write-all
jobs:
build_ee:
runs-on: ubicloud
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Read EE repo commit hash
run: |
echo "ee_repo_ref=$(cat ./backend/ee-repo-ref.txt)" >> "$GITHUB_ENV"
- uses: actions/checkout@v4
with:
repository: windmill-labs/windmill-ee-private
path: ./windmill-ee-private
ref: ${{ env.ee_repo_ref }}
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
fetch-depth: 0
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v2
- uses: depot/setup-action@v1
- name: Docker meta
id: meta-ee-public
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee-rhel8
flavor: |
latest=false
tags: |
type=sha
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Substitute EE code
run: |
./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Copy RHEL8 Dockerfile
run: |
cp ./docker/RHEL8/Dockerfile ./Dockerfile
- name: Build and push publicly ee amd64
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/amd64
push: true
build-args: |
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages,deno_core,mcp,private
secrets: |
rh_username=${{ secrets.RH_USERNAME }}
rh_password=${{ secrets.RH_PASSWORD }}
tags: |
${{ steps.meta-ee-public.outputs.tags }}-amd64
labels: |
${{ steps.meta-ee-public.outputs.labels }}-amd64
org.opencontainers.image.licenses=Windmill-Enterprise-License
- name: Build and push publicly ee arm64
uses: depot/build-push-action@v1
with:
context: .
platforms: linux/arm64
push: true
build-args: |
features=enterprise,enterprise_saml,stripe,embedding,parquet,prometheus,openidconnect,cloud,jemalloc,license,otel,http_trigger,zip,oauth2,kafka,sqs_trigger,nats,postgres_trigger,gcp_trigger,mqtt_trigger,websocket,smtp,static_frontend,all_languages,deno_core,mcp,private
secrets: |
rh_username=${{ secrets.RH_USERNAME }}
rh_password=${{ secrets.RH_PASSWORD }}
tags: |
${{ steps.meta-ee-public.outputs.tags }}-arm64
labels: |
${{ steps.meta-ee-public.outputs.labels }}-arm64
org.opencontainers.image.licenses=Windmill-Enterprise-License
- uses: shrink/actions-docker-extract@v3
id: extract-ee-amd64
with:
image: ${{ steps.meta-ee-public.outputs.tags}}-amd64
path: "/windmill/target/release/windmill"
- uses: shrink/actions-docker-extract@v3
id: extract-duckdb-ffi-internal
with:
image: ${{ steps.meta-ee-public.outputs.tags}}-amd64
path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so"
# - uses: shrink/actions-docker-extract@v3
# id: extract-ee-arm64
# with:
# image: ${{ steps.meta-ee-public.outputs.tags}}-arm64
# path: "/windmill/target/release/windmill"
- name: Rename binary with corresponding architecture
run: |
mv "${{ steps.extract-ee-amd64.outputs.destination }}/windmill" "${{ steps.extract-ee-amd64.outputs.destination }}/windmill-ee-amd64-rhel8"
# mv "${{ steps.extract-ee-arm64.outputs.destination }}/windmill" "${{ steps.extract-ee-arm64.outputs.destination }}/windmill-ee-arm64-rhel8"
- uses: actions/upload-artifact@v4
with:
name: RHEL8-amd64 build
path: ${{ steps.extract-ee-amd64.outputs.destination }}/windmill-ee-amd64-rhel8
- uses: actions/upload-artifact@v4
with:
name: RHEL8-amd64 dynamic libraries build
path: ${{ steps.extract-duckdb-ffi-internal.outputs.destination }}/libwindmill_duckdb_ffi_internal.so
# - uses: actions/upload-artifact@v4
# with:
# name: RHEL8-arm64 build
# path:
# ${{ steps.extract-ee-arm64.outputs.destination
# }}/windmill-ee-arm64-rhel8
# - name: Attach binary to release
# uses: softprops/action-gh-release@v2
# if: startsWith(github.ref, 'refs/tags/')
# with:
# files: |
# ${{ steps.extract-ee-arm64.outputs.destination }}/windmill-ee-arm64-rhel8
# ${{ steps.extract-ee-amd64.outputs.destination }}/windmill-ee-amd64-rhel8
+144
View File
@@ -1,5 +1,149 @@
# Changelog
## [1.582.2](https://github.com/windmill-labs/windmill/compare/v1.582.1...v1.582.2) (2025-11-21)
### Bug Fixes
* fix aws oidc refresh ([98bdb68](https://github.com/windmill-labs/windmill/commit/98bdb6825a1b85c973ddec3e6b933e4a3d6d6972))
## [1.582.1](https://github.com/windmill-labs/windmill/compare/v1.582.0...v1.582.1) (2025-11-21)
### Bug Fixes
* fix aws oidc refresh ([a3b4cfc](https://github.com/windmill-labs/windmill/commit/a3b4cfcb8f11db326b0ebf1777ad7e6479425125))
## [1.582.0](https://github.com/windmill-labs/windmill/compare/v1.581.1...v1.582.0) (2025-11-20)
### Features
* **aichat:** handle duckdb scripts ([#7187](https://github.com/windmill-labs/windmill/issues/7187)) ([ce5a318](https://github.com/windmill-labs/windmill/commit/ce5a31865cf6965ec28c449c2a832b93572a8eb6))
* **ee:** support iamrds ([e9691c9](https://github.com/windmill-labs/windmill/commit/e9691c9eb080236849850a1ea6f3237ae39a2c4c))
### Bug Fixes
* **aichat:** fallback to completion if responses fails ([#7190](https://github.com/windmill-labs/windmill/issues/7190)) ([b56e611](https://github.com/windmill-labs/windmill/commit/b56e611700f06844dda4f30d02a1119e714d73a4))
* **frontend:** show code/lock in flow steps on runs page ([#7191](https://github.com/windmill-labs/windmill/issues/7191)) ([338fd8a](https://github.com/windmill-labs/windmill/commit/338fd8a38cb035de298006ed1b96b6513eab9769))
## [1.581.1](https://github.com/windmill-labs/windmill/compare/v1.581.0...v1.581.1) (2025-11-20)
### Bug Fixes
* **frontend:** missing node Result id migration ([#7182](https://github.com/windmill-labs/windmill/issues/7182)) ([054aeb3](https://github.com/windmill-labs/windmill/commit/054aeb33271288dc9458b012881164c3c4597280))
## [1.581.0](https://github.com/windmill-labs/windmill/compare/v1.580.0...v1.581.0) (2025-11-19)
### Features
* **frontend:** add notes to flow ([#6628](https://github.com/windmill-labs/windmill/issues/6628)) ([cfeb294](https://github.com/windmill-labs/windmill/commit/cfeb294308ba85763025f3628cbb85144d7f0778))
## [1.580.0](https://github.com/windmill-labs/windmill/compare/v1.579.2...v1.580.0) (2025-11-18)
### Features
* **aichat:** use responses api for openai models ([#7163](https://github.com/windmill-labs/windmill/issues/7163)) ([5c79a35](https://github.com/windmill-labs/windmill/commit/5c79a35306855143428d0725519578aea0a746fd))
* disabling/enabling email triggers ([#7171](https://github.com/windmill-labs/windmill/issues/7171)) ([8ae266b](https://github.com/windmill-labs/windmill/commit/8ae266b6a9ced16e1b7416cfc8bea5fe7a7af042))
* **security:** unshare pid of worker job process ([#7106](https://github.com/windmill-labs/windmill/issues/7106)) ([5aa251a](https://github.com/windmill-labs/windmill/commit/5aa251a2d276cc9d27bf104f8e4f724ea6a28231))
* support secondary promotion repos in git sync settings ([#7173](https://github.com/windmill-labs/windmill/issues/7173)) ([5548221](https://github.com/windmill-labs/windmill/commit/55482210921fe2eb0fd158abd4f7369495f2dfd7))
### Bug Fixes
* change uv tool dir from /root to /usr/local/uv ([c3e59fe](https://github.com/windmill-labs/windmill/commit/c3e59fe064fc3b9d4c05958eea54601ff3410899))
* improve delete to handle ai chat ([f371fbe](https://github.com/windmill-labs/windmill/commit/f371fbeb9bb0946bd29a6413ee7ede75dedda5d9))
* support IRSA for duckdb s3 proxy ([2058f27](https://github.com/windmill-labs/windmill/commit/2058f27e03468d45813f340b8563f935ca2142f4))
## [1.579.2](https://github.com/windmill-labs/windmill/compare/v1.579.1...v1.579.2) (2025-11-18)
### Bug Fixes
* ducklake manager table explorer issue ([d08c091](https://github.com/windmill-labs/windmill/commit/d08c0916f72a67f01e0c4475f03f9d1d33c10905))
## [1.579.1](https://github.com/windmill-labs/windmill/compare/v1.579.0...v1.579.1) (2025-11-18)
### Bug Fixes
* fix s3 object download frontend freezes ([09a6e1f](https://github.com/windmill-labs/windmill/commit/09a6e1feaa79ce3f8548f8090fddbf46abb08b18))
## [1.579.0](https://github.com/windmill-labs/windmill/compare/v1.578.0...v1.579.0) (2025-11-17)
### Features
* **ai:** handle aws bedrock as provider ([#7155](https://github.com/windmill-labs/windmill/issues/7155)) ([79ac631](https://github.com/windmill-labs/windmill/commit/79ac6312e87afa3646bddc0f7e66fc4367dbff7c))
* **mcp:** granular token scopes for scripts, flows, and endpoints ([#7130](https://github.com/windmill-labs/windmill/issues/7130)) ([88d04b9](https://github.com/windmill-labs/windmill/commit/88d04b9cbeee98f3256b78e9d34beb930cd729ec))
* rhel8 + fix rhel9 ([#7165](https://github.com/windmill-labs/windmill/issues/7165)) ([499d7d4](https://github.com/windmill-labs/windmill/commit/499d7d4098758726a8cb2bf3e4837927b8fd70a4))
### Bug Fixes
* **backend:** worker count in latest worker usage ([#7160](https://github.com/windmill-labs/windmill/issues/7160)) ([b87d2cc](https://github.com/windmill-labs/windmill/commit/b87d2cc64cb54b602ee599fcde7f0fd3c8931550))
* fix custom email triggers enabled ([#7164](https://github.com/windmill-labs/windmill/issues/7164)) ([90b5569](https://github.com/windmill-labs/windmill/commit/90b5569c911f9025b0e6b5318f57705efbd9bd17))
## [1.578.0](https://github.com/windmill-labs/windmill/compare/v1.577.0...v1.578.0) (2025-11-17)
### Features
* support to run windows binary as service ([#7153](https://github.com/windmill-labs/windmill/issues/7153)) ([ceeff5f](https://github.com/windmill-labs/windmill/commit/ceeff5f76c69d98319bb3fb7f7779b6046478d6b))
## [1.577.0](https://github.com/windmill-labs/windmill/compare/v1.576.3...v1.577.0) (2025-11-17)
### Features
* add support for validateset in pwsh ([#7158](https://github.com/windmill-labs/windmill/issues/7158)) ([b66e038](https://github.com/windmill-labs/windmill/commit/b66e038a0f8b6bffe157a83671c8e692c1441f23))
* allow http trigger to be disabled ([#6976](https://github.com/windmill-labs/windmill/issues/6976)) ([09082de](https://github.com/windmill-labs/windmill/commit/09082de53971d0d2f2a6308bc8ee573458a3b913))
### Bug Fixes
* create app_themes/groups/components only when needed ([cf5d58e](https://github.com/windmill-labs/windmill/commit/cf5d58ea43cef6add3da2aa1e24efc83be6df3b9))
* fix parse_postgres_uri not decoding password ([#7157](https://github.com/windmill-labs/windmill/issues/7157)) ([2cae72c](https://github.com/windmill-labs/windmill/commit/2cae72c9db6bd08689e1672be6dda32f6af831fb))
## [1.576.3](https://github.com/windmill-labs/windmill/compare/v1.576.2...v1.576.3) (2025-11-15)
### Bug Fixes
* handle better alias types in duckdb ([2c04e04](https://github.com/windmill-labs/windmill/commit/2c04e04bf0e3272c89f321392158888d02a1191b))
## [1.576.2](https://github.com/windmill-labs/windmill/compare/v1.576.1...v1.576.2) (2025-11-15)
### Bug Fixes
* temporary fix for duckdb type_aliases causing issues ([#7148](https://github.com/windmill-labs/windmill/issues/7148)) ([6426ebf](https://github.com/windmill-labs/windmill/commit/6426ebf8cb713443904065064b6a07eb1db0761a))
## [1.576.1](https://github.com/windmill-labs/windmill/compare/v1.576.0...v1.576.1) (2025-11-14)
### Bug Fixes
* DuckDB FFI crash fix ([#7145](https://github.com/windmill-labs/windmill/issues/7145)) ([d3fc459](https://github.com/windmill-labs/windmill/commit/d3fc459b407682bf588236236916363d94f3e1ff))
## [1.576.0](https://github.com/windmill-labs/windmill/compare/v1.575.4...v1.576.0) (2025-11-14)
### Features
* add support for switch and attributes in pwsh params ([#7143](https://github.com/windmill-labs/windmill/issues/7143)) ([c16bef8](https://github.com/windmill-labs/windmill/commit/c16bef8f296645ff873f9d8d28e3dcb50a65e304))
* **ai:** handle aws bedrock as provider ([#7131](https://github.com/windmill-labs/windmill/issues/7131)) ([30eb9aa](https://github.com/windmill-labs/windmill/commit/30eb9aae25eeb563ad119ef93f3ff1ab17c66d75))
* webhook by flow version ([#7062](https://github.com/windmill-labs/windmill/issues/7062)) ([09cdfb4](https://github.com/windmill-labs/windmill/commit/09cdfb4556748903dc5bbf53ef3356ac97c57d90))
### Bug Fixes
* use proper TLS connector for DuckLake instance catalog setup ([#7138](https://github.com/windmill-labs/windmill/issues/7138)) ([cf36fe3](https://github.com/windmill-labs/windmill/commit/cf36fe3bb1beec80fa84dc342a8a38cc7369bc4d))
## [1.575.4](https://github.com/windmill-labs/windmill/compare/v1.575.3...v1.575.4) (2025-11-13)
+3
View File
@@ -114,7 +114,10 @@ ARG WITH_GIT=true
ARG LATEST_STABLE_PY=3.11.10
ENV UV_PYTHON_INSTALL_DIR=/tmp/windmill/cache/py_runtime
ENV UV_PYTHON_PREFERENCE=only-managed
RUN mkdir -p /usr/local/uv
ENV UV_TOOL_BIN_DIR=/usr/local/bin
ENV UV_TOOL_DIR=/usr/local/uv
ENV PATH /usr/local/bin:/root/.local/bin:$PATH
+4 -1
View File
@@ -13,4 +13,7 @@ rustflags = [
"-C", "link-arg=-undefined",
"-C", "link-arg=dynamic_lookup",
"-C", "link-args=-Wl,-rpath,$ORIGIN/"
]
]
[net]
git-fetch-with-cli = true
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usage (id, is_workspace, month_, usage)\n VALUES ($1, FALSE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1)\n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar"
]
},
"nullable": []
},
"hash": "045b26db0cefe6eaac0e572661d984ff5ce7086ac511e8647e2024d9dbe0af56"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM flow_conversation WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "08574e8e5dc165041750880fb02e7ffea83ae94a670b598b6dada0b3d0914629"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE\n flow\n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now()\n WHERE\n path = $11 AND workspace_id = $12",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text",
"Varchar",
"Bool",
"Bool",
"Text",
"Jsonb",
"Text",
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "207a0721b6f0b8b6ddd4120343eba524a2bc1e9047bdde5f568af4d993dbb74c"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usage (id, is_workspace, month_, usage)\n VALUES ($1, TRUE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1)\n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar"
]
},
"nullable": []
},
"hash": "42baae3c69367bbb21771a9711c311afe67d339ca022ba61c2767c004b038ef0"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow \n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at) \n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "544a00afb5c72d2aa24ab4ccfe68d1e383aeda9a5a71183b7fbaa41deca4e333"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_agent_memory WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "56d3dccce81c652d6ab3d4ba69de89c4a5cb1fbc9a8b73e6b8ba4d6e74088352"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
true
null
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n script_path, \n is_flow, \n workspace_id, \n edited_by, \n email, \n path, \n error_handler_path as \"error_handler_path: _\", \n error_handler_args as \"error_handler_args: _\", \n retry as \"retry: _\" \n FROM email_trigger \n WHERE local_part = $1 \n AND workspaced_local_part = FALSE\n ",
"query": "\n SELECT \n script_path, \n is_flow, \n workspace_id, \n edited_by, \n email, \n path, \n error_handler_path as \"error_handler_path: _\", \n error_handler_args as \"error_handler_args: _\", \n retry as \"retry: _\" \n FROM email_trigger \n WHERE local_part = $1 \n AND workspaced_local_part = FALSE\n AND enabled IS TRUE\n ",
"describe": {
"columns": [
{
@@ -66,5 +66,5 @@
true
]
},
"hash": "668edc2f84eccf5db6b8daa3ed97e25e46a37c50a9d1a6eca5a277fb58a2e7b4"
"hash": "628d303956d55d22e5ac64496b66010d4476d5c222184f09e5a189975a37b941"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usage (id, is_workspace, month_, usage)\n VALUES ($1, FALSE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1)\n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar"
]
},
"nullable": []
},
"hash": "6525e65ffe66643fed4db83aa81b525fc3ec54fdbca5d12647c1da88c9983b10"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow\n (workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)\n SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at\n FROM flow\n WHERE path = $2 AND workspace_id = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "676c758d9c4492dada50edd3ad06dc628e2ba56eab5e1a50c99481da9793759e"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO email_trigger (\n workspace_id,\n path,\n script_path,\n is_flow,\n local_part,\n workspaced_local_part,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, now(), $9, $10, $11\n )\n ",
"query": "\n INSERT INTO email_trigger (\n workspace_id,\n path,\n script_path,\n is_flow,\n local_part,\n workspaced_local_part,\n edited_by,\n email,\n edited_at,\n error_handler_path,\n error_handler_args,\n retry,\n enabled\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, now(), $9, $10, $11, $12\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -15,10 +15,11 @@
"Varchar",
"Varchar",
"Jsonb",
"Jsonb"
"Jsonb",
"Bool"
]
},
"nullable": []
},
"hash": "d328d00e5393b0e8d2c4b3674221fabbae580fc404e0d4481fd8d7fb51272c94"
"hash": "6c568509908c1833d9e6f58f739b8a186fc91f94d4fcc40eeb9914084e4ef60c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE worker_ping SET ping_at = now(), current_job_id = $1, current_job_workspace_id = $2, memory_usage = $3, wm_memory_usage = $4,\n occupancy_rate = $6, occupancy_rate_15s = $7, occupancy_rate_5m = $8, occupancy_rate_30m = $9 WHERE worker = $5",
"query": "UPDATE worker_ping SET ping_at = now(), current_job_id = $1, current_job_workspace_id = $2, memory_usage = $3, wm_memory_usage = $4,\n occupancy_rate = $6, occupancy_rate_15s = $7, occupancy_rate_5m = $8, occupancy_rate_30m = $9, job_isolation = $10 WHERE worker = $5",
"describe": {
"columns": [],
"parameters": {
@@ -13,10 +13,11 @@
"Float4",
"Float4",
"Float4",
"Float4"
"Float4",
"Text"
]
},
"nullable": []
},
"hash": "506066203c49424e9944eb3948dc1657d3d796e6233e9f0ec925879c705d4773"
"hash": "76a7ad0588afcb4e9f0b876c50e203c36be56b2b2b08ca787417a9822ef56f64"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id, \n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3",
"query": "SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,\n CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,\n custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation\n FROM worker_ping\n WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)\n ORDER BY ping_at desc LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
@@ -97,6 +97,11 @@
"ordinal": 18,
"name": "wm_memory_usage",
"type_info": "Int8"
},
{
"ordinal": 19,
"name": "job_isolation",
"type_info": "Text"
}
],
"parameters": {
@@ -126,8 +131,9 @@
true,
true,
true,
true,
true
]
},
"hash": "6a497334c98bfaf70be44fced572a1cc0dde4141aa4c5002765a95432d0101ab"
"hash": "771a858a4b7ca41b6787e61f5a4a5c9c4d48fd213852e2f997cd4b2420580d30"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO unique_ext_jwt_token (jwt_hash, last_used_at)\n VALUES ($1, NOW())\n ON CONFLICT (jwt_hash)\n DO UPDATE SET last_used_at = NOW()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "778ab8ceb2a84978919ceb07f399468e01c4bee4cd755322eb2a83353a279a2b"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usage (id, is_workspace, month_, usage)\n VALUES ($1, TRUE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1)\n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar"
]
},
"nullable": []
},
"hash": "88d529fd26d6ecba7640c74daccb5cb0c7146c11adae8c287c0c9e71ac68f7a7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO http_trigger (\n workspace_id,\n path,\n route_path,\n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n script_path,\n summary,\n description,\n is_flow,\n request_type,\n authentication_method,\n http_method,\n static_asset_config,\n edited_by,\n email,\n edited_at,\n is_static_website,\n error_handler_path,\n error_handler_args,\n retry\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, now(), $19, $20, $21, $22\n )\n ",
"query": "\n INSERT INTO http_trigger (\n workspace_id,\n path,\n route_path,\n route_path_key,\n workspaced_route,\n authentication_resource_path,\n wrap_body,\n raw_string,\n script_path,\n summary,\n description,\n is_flow,\n enabled,\n request_type,\n authentication_method,\n http_method,\n static_asset_config,\n edited_by,\n email,\n edited_at,\n is_static_website,\n error_handler_path,\n error_handler_args,\n retry\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, now(), $20, $21, $22, $23\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -17,6 +17,7 @@
"Varchar",
"Text",
"Bool",
"Bool",
{
"Custom": {
"name": "request_type",
@@ -69,5 +70,5 @@
},
"nullable": []
},
"hash": "1f6b773ce34fe51d03d6f9a2345481629c62453eebbb08f82dd2da23389bc117"
"hash": "94e2e899fc0a5134c29217feda1f0530d03548cc5c884c83d7b85d068e738b8e"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE email_trigger \n SET \n script_path = $1,\n path = $2,\n is_flow = $3,\n edited_by = $4,\n email = $5,\n edited_at = now(),\n error_handler_path = $6,\n error_handler_args = $7,\n retry = $8\n WHERE \n workspace_id = $9 AND path = $10\n ",
"query": "\n UPDATE email_trigger \n SET \n script_path = $1,\n path = $2,\n is_flow = $3,\n edited_by = $4,\n email = $5,\n edited_at = now(),\n error_handler_path = $6,\n error_handler_args = $7,\n retry = $8,\n enabled = $9\n WHERE \n workspace_id = $10 AND path = $11\n ",
"describe": {
"columns": [],
"parameters": {
@@ -13,11 +13,12 @@
"Varchar",
"Jsonb",
"Jsonb",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "acb094aef60bba9083087264d65034fce38417099f15e8312be72a386f10bc1f"
"hash": "a704283ff62ac1cd6db489ca3f84d6994d75c6b9e5f61daf54d298a2fdcfd9af"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE\n http_trigger\n SET\n wrap_body = $1,\n raw_string = $2,\n authentication_resource_path = $3,\n script_path = $4,\n path = $5,\n is_flow = $6,\n http_method = $7,\n static_asset_config = $8,\n edited_by = $9,\n email = $10,\n request_type = $11,\n authentication_method = $12,\n summary = $13,\n description = $14,\n edited_at = now(),\n is_static_website = $15,\n error_handler_path = $16,\n error_handler_args = $17,\n retry = $18\n WHERE\n workspace_id = $19 AND\n path = $20\n ",
"query": "\n UPDATE\n http_trigger\n SET\n wrap_body = $1,\n raw_string = $2,\n authentication_resource_path = $3,\n script_path = $4,\n path = $5,\n is_flow = $6,\n enabled = $7,\n http_method = $8,\n static_asset_config = $9,\n edited_by = $10,\n email = $11,\n request_type = $12,\n authentication_method = $13,\n summary = $14,\n description = $15,\n edited_at = now(),\n is_static_website = $16,\n error_handler_path = $17,\n error_handler_args = $18,\n retry = $19\n WHERE\n workspace_id = $20 AND\n path = $21\n ",
"describe": {
"columns": [],
"parameters": {
@@ -11,6 +11,7 @@
"Varchar",
"Varchar",
"Bool",
"Bool",
{
"Custom": {
"name": "http_method",
@@ -67,5 +68,5 @@
},
"nullable": []
},
"hash": "4c64bd0e364f536597db83161b5a27ff58b5ec7148bc94807423593bcfa27b90"
"hash": "ada1a14b4f25e41742df153b07e8bdf5b5feac9c6fa9de38bd24104bac8539d2"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE\n http_trigger\n SET\n route_path = $1,\n route_path_key = $2,\n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n authentication_resource_path = $6,\n script_path = $7,\n path = $8,\n is_flow = $9,\n http_method = $10,\n static_asset_config = $11,\n edited_by = $12,\n email = $13,\n request_type = $14,\n authentication_method = $15,\n summary = $16,\n description = $17,\n edited_at = now(),\n is_static_website = $18,\n error_handler_path = $19,\n error_handler_args = $20,\n retry = $21\n WHERE\n workspace_id = $22 AND\n path = $23\n ",
"query": "\n UPDATE\n http_trigger\n SET\n route_path = $1,\n route_path_key = $2,\n workspaced_route = $3,\n wrap_body = $4,\n raw_string = $5,\n authentication_resource_path = $6,\n script_path = $7,\n path = $8,\n is_flow = $9,\n enabled = $10,\n http_method = $11,\n static_asset_config = $12,\n edited_by = $13,\n email = $14,\n request_type = $15,\n authentication_method = $16,\n summary = $17,\n description = $18,\n edited_at = now(),\n is_static_website = $19,\n error_handler_path = $20,\n error_handler_args = $21,\n retry = $22\n WHERE\n workspace_id = $23 AND\n path = $24\n ",
"describe": {
"columns": [],
"parameters": {
@@ -14,6 +14,7 @@
"Varchar",
"Varchar",
"Bool",
"Bool",
{
"Custom": {
"name": "http_method",
@@ -70,5 +71,5 @@
},
"nullable": []
},
"hash": "465144ea7e2930203618d9814a3e20c77b4363cf9e7c655d395f3fe40c247f61"
"hash": "b8bcf1fef244395dad802174dfc9bff45ff6f9cd1298eae5de40218cbab2eb96"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n path,\n script_path,\n is_flow,\n route_path,\n authentication_resource_path,\n workspace_id,\n request_type AS \"request_type: _\",\n authentication_method AS \"authentication_method: _\",\n edited_by,\n email,\n static_asset_config AS \"static_asset_config: _\",\n wrap_body,\n raw_string,\n workspaced_route,\n is_static_website,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM\n http_trigger\n WHERE\n http_method = $1\n ",
"query": "\n SELECT\n path,\n script_path,\n is_flow,\n route_path,\n authentication_resource_path,\n workspace_id,\n request_type AS \"request_type: _\",\n authentication_method AS \"authentication_method: _\",\n edited_by,\n email,\n static_asset_config AS \"static_asset_config: _\",\n wrap_body,\n raw_string,\n workspaced_route,\n is_static_website,\n error_handler_path,\n error_handler_args as \"error_handler_args: _\",\n retry as \"retry: _\"\n FROM\n http_trigger\n WHERE\n http_method = $1 AND\n enabled is TRUE\n ",
"describe": {
"columns": [
{
@@ -158,5 +158,5 @@
true
]
},
"hash": "1301f873a829db137573b8b39449f6160f2adf44f864f26a99b8eab5818fbd50"
"hash": "bbc96ae911d4ca0330582340e65c10e008e507610f095ff3936865101c9ba346"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usage (id, is_workspace, month_, usage)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Bool",
"Int4",
"Int4"
]
},
"nullable": []
},
"hash": "cabc79429825b5f4861e4ba2001a5f235f39558dee88cbb486d7ac988d0a44d1"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO resource (workspace_id, path, value, description, resource_type, created_by, edited_at) VALUES ($1, 'f/app_themes/theme_0', '{\"name\": \"Default Theme\", \"value\": \"\"}', 'The default app theme', 'app_theme', $2, now()) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "cccf9e216d84dfbb801b92d697496f44d93b344190a26c8393867b3a268b9aba"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM unique_ext_jwt_token WHERE last_used_at > NOW() - INTERVAL '30 days'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null
]
},
"hash": "d2732640f09ec029025ebdd4de502c8adee995eac05b5051e480f0a20fa6b7bb"
}
@@ -1,25 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n flow \n SET\n path = $1,\n summary = $2,\n description = $3,\n dependency_job = NULL,\n lock_error_logs = '',\n draft_only = NULL,\n tag = $4,\n dedicated_worker = $5,\n visible_to_runner_only = $6,\n on_behalf_of_email = $7,\n value = $8,\n schema = $9::text::json,\n edited_by = $10,\n edited_at = now()\n WHERE \n path = $11 AND workspace_id = $12",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Text",
"Text",
"Varchar",
"Bool",
"Bool",
"Text",
"Jsonb",
"Text",
"Varchar",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "d2acc380c45df31f741bbd420c2f4a5b962f7803def9ecd6e33d2aec6abce772"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n script_path, \n is_flow, \n workspace_id, \n edited_by, \n email, \n path, \n error_handler_path as \"error_handler_path: _\", \n error_handler_args as \"error_handler_args: _\", \n retry as \"retry: _\" \n FROM email_trigger \n WHERE workspace_id = $1 \n AND local_part = $2 \n AND (workspaced_local_part = TRUE OR $3 IS TRUE)\n ",
"query": "\n SELECT \n script_path, \n is_flow, \n workspace_id, \n edited_by, \n email, \n path, \n error_handler_path as \"error_handler_path: _\", \n error_handler_args as \"error_handler_args: _\", \n retry as \"retry: _\" \n FROM email_trigger \n WHERE workspace_id = $1 \n AND local_part = $2 \n AND (workspaced_local_part = TRUE OR $3 IS TRUE)\n AND enabled IS TRUE\n ",
"describe": {
"columns": [
{
@@ -68,5 +68,5 @@
true
]
},
"hash": "bbd51f4f0a8bb2db5d6b634f2d32f5f7f7f57390a0e4b6e7cad45c9147fd6356"
"hash": "df6972fb16a2364f10379fe37e125ae228697602e3769c479582470dc0b9488b"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, wm_version, vcpus, memory) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (worker) \n DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group",
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, wm_version, vcpus, memory, job_isolation) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (worker)\n DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group",
"describe": {
"columns": [],
"parameters": {
@@ -13,10 +13,11 @@
"Varchar",
"Varchar",
"Int8",
"Int8"
"Int8",
"Text"
]
},
"nullable": []
},
"hash": "e5f8830450e90f678494ae2b0f86b0692a02dec4c8415fd7d364b0cc088905c2"
"hash": "ed90b9fcc57f530bab2d2d7426795bb358c91ebdd9dab50d7e3d2fdce63b947c"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE email_trigger \n SET \n script_path = $1,\n path = $2,\n is_flow = $3,\n local_part = $4,\n workspaced_local_part = $5,\n edited_by = $6,\n email = $7,\n edited_at = now(),\n error_handler_path = $8,\n error_handler_args = $9,\n retry = $10\n WHERE \n workspace_id = $11 AND path = $12\n ",
"query": "\n UPDATE email_trigger \n SET \n script_path = $1,\n path = $2,\n is_flow = $3,\n local_part = $4,\n workspaced_local_part = $5,\n edited_by = $6,\n email = $7,\n edited_at = now(),\n error_handler_path = $8,\n error_handler_args = $9,\n retry = $10,\n enabled = $11\n WHERE \n workspace_id = $12 AND path = $13\n ",
"describe": {
"columns": [],
"parameters": {
@@ -15,11 +15,12 @@
"Varchar",
"Jsonb",
"Jsonb",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "6fafc23924eded970689040bd4a94d4d23ebee4f2b7d37bb54c47edb7720be00"
"hash": "fe12006498b9e7aece6b104fc5d9a80eeac12c6ca07a0e8950941ba796b0ba79"
}
+13
View File
@@ -10,3 +10,16 @@
1. Update database schema with migration if necessary
2. Update backend/windmill-api/openapi.yaml after modifying API endpoints
## Querying the Database
To query the database directly, use psql with the following connection string:
```bash
psql postgres://postgres:changeme@localhost:5432/windmill
```
This can be helpful for:
- Inspecting database state during development
- Testing queries before implementing them in Rust
- Debugging data-related issues
+190 -102
View File
@@ -199,22 +199,22 @@ dependencies = [
[[package]]
name = "anstyle-query"
version = "1.1.4"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.10"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -788,9 +788,9 @@ dependencies = [
[[package]]
name = "aws-credential-types"
version = "1.2.9"
version = "1.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86590e57ea40121d47d3f2e131bfd873dea15d78dc2f4604f4734537ad9e56c4"
checksum = "b01c9521fa01558f750d183c8c68c81b0155b9d193a4ba7f84c36bd1b6d04a06"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -823,13 +823,14 @@ dependencies = [
[[package]]
name = "aws-runtime"
version = "1.5.10"
version = "1.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c034a1bc1d70e16e7f4e4caf7e9f7693e4c9c24cd91cf17c2a0b21abaebc7c8b"
checksum = "7ce527fb7e53ba9626fc47824f25e256250556c40d8f81d27dd92aa38239d632"
dependencies = [
"aws-credential-types",
"aws-sigv4",
"aws-smithy-async",
"aws-smithy-eventstream",
"aws-smithy-http",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
@@ -845,6 +846,31 @@ dependencies = [
"uuid",
]
[[package]]
name = "aws-sdk-bedrockruntime"
version = "1.113.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5d2b8f081b9e8ff455b8dd7387b6b02263c3dac73172d188d2b523ff1e775e9"
dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-sigv4",
"aws-smithy-async",
"aws-smithy-eventstream",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
"aws-types",
"bytes",
"fastrand",
"http 0.2.12",
"hyper 0.14.32",
"regex-lite",
"tracing",
]
[[package]]
name = "aws-sdk-config"
version = "1.68.0"
@@ -868,6 +894,31 @@ dependencies = [
"tracing",
]
[[package]]
name = "aws-sdk-rds"
version = "1.117.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56b116d0229c41d5b72050dda07aa2cc7d1c571de8f7870c8b87fa297a724982"
dependencies = [
"aws-credential-types",
"aws-runtime",
"aws-sigv4",
"aws-smithy-async",
"aws-smithy-http",
"aws-smithy-json",
"aws-smithy-query",
"aws-smithy-runtime",
"aws-smithy-runtime-api",
"aws-smithy-types",
"aws-smithy-xml",
"aws-types",
"fastrand",
"http 0.2.12",
"regex-lite",
"tracing",
"url",
]
[[package]]
name = "aws-sdk-sqs"
version = "1.77.0"
@@ -964,6 +1015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c35452ec3f001e1f2f6db107b6373f1f48f05ec63ba2c5c9fa91f07dad32af11"
dependencies = [
"aws-credential-types",
"aws-smithy-eventstream",
"aws-smithy-http",
"aws-smithy-runtime-api",
"aws-smithy-types",
@@ -990,12 +1042,24 @@ dependencies = [
"tokio",
]
[[package]]
name = "aws-smithy-eventstream"
version = "0.60.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e29a304f8319781a39808847efb39561351b1bb76e933da7aa90232673638658"
dependencies = [
"aws-smithy-types",
"bytes",
"crc32fast",
]
[[package]]
name = "aws-smithy-http"
version = "0.62.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "445d5d720c99eed0b4aa674ed00d835d9b1427dd73e04adaf2f94c6b2d6f9fca"
dependencies = [
"aws-smithy-eventstream",
"aws-smithy-runtime-api",
"aws-smithy-types",
"bytes",
@@ -1013,9 +1077,9 @@ dependencies = [
[[package]]
name = "aws-smithy-http-client"
version = "1.0.6"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f108f1ca850f3feef3009bdcc977be201bca9a91058864d9de0684e64514bee0"
checksum = "623254723e8dfd535f566ee7b2381645f8981da086b5c4aa26c0c41582bb1d2c"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -1026,16 +1090,17 @@ dependencies = [
"http 1.3.1",
"http-body 0.4.6",
"hyper 0.14.32",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-rustls 0.24.2",
"hyper-rustls 0.27.7",
"hyper-util",
"pin-project-lite",
"rustls 0.21.12",
"rustls 0.23.29",
"rustls 0.23.35",
"rustls-native-certs 0.8.2",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.4",
"tower 0.5.2",
"tracing",
]
@@ -1070,9 +1135,9 @@ dependencies = [
[[package]]
name = "aws-smithy-runtime"
version = "1.8.6"
version = "1.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e107ce0783019dbff59b3a244aa0c114e4a8c9d93498af9162608cd5474e796"
checksum = "0bbe9d018d646b96c7be063dd07987849862b0e6d07c778aad7d93d1be6c1ef0"
dependencies = [
"aws-smithy-async",
"aws-smithy-http",
@@ -1182,7 +1247,7 @@ dependencies = [
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"itoa",
"matchit",
@@ -1569,7 +1634,7 @@ dependencies = [
"hex",
"http 1.3.1",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-named-pipe",
"hyper-util",
"hyperlocal",
@@ -1799,9 +1864,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.10.1"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
dependencies = [
"serde",
]
@@ -1945,9 +2010,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.45"
version = "1.2.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe"
checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2054,9 +2119,9 @@ dependencies = [
[[package]]
name = "clap"
version = "4.5.51"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5"
checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8"
dependencies = [
"clap_builder",
"clap_derive",
@@ -2064,9 +2129,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.51"
version = "4.5.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a"
checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00"
dependencies = [
"anstream",
"anstyle",
@@ -3625,7 +3690,7 @@ dependencies = [
"hickory-resolver",
"http 1.3.1",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-rustls 0.27.7",
"hyper-util",
"ipnet",
@@ -3713,7 +3778,7 @@ dependencies = [
"http 1.3.1",
"httparse",
"hyper 0.14.32",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"itertools 0.10.5",
"memmem",
@@ -3906,7 +3971,7 @@ dependencies = [
"hkdf",
"http 1.3.1",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"idna",
"indexmap 2.11.1",
@@ -4177,7 +4242,7 @@ dependencies = [
"http 1.3.1",
"http-body-util",
"hyper 0.14.32",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"libc",
"log",
@@ -4231,7 +4296,7 @@ dependencies = [
"deno_error",
"deno_tls",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-rustls 0.27.7",
"hyper-util",
"log",
@@ -4266,7 +4331,7 @@ dependencies = [
"deno_core",
"deno_error",
"deno_native_certs",
"rustls 0.23.29",
"rustls 0.23.35",
"rustls-pemfile 2.2.0",
"rustls-tokio-stream",
"rustls-webpki 0.102.8",
@@ -4361,7 +4426,7 @@ dependencies = [
"h2 0.4.12",
"http 1.3.1",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"once_cell",
"rustls-tokio-stream",
@@ -5225,7 +5290,7 @@ dependencies = [
"base64 0.21.7",
"bytes",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"pin-project",
"rand 0.8.5",
@@ -5292,9 +5357,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.4"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127"
checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
[[package]]
name = "fixedbitset"
@@ -6262,9 +6327,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.16.0"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashify"
@@ -6604,9 +6669,9 @@ dependencies = [
[[package]]
name = "hyper"
version = "1.8.0"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1744436df46f0bde35af3eda22aeaba453aada65d8f1c171cd8a5f59030bd69f"
checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
dependencies = [
"atomic-waker",
"bytes",
@@ -6635,7 +6700,7 @@ dependencies = [
"futures-util",
"headers",
"http 1.3.1",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-rustls 0.27.7",
"hyper-util",
"pin-project-lite",
@@ -6652,7 +6717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278"
dependencies = [
"hex",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"pin-project-lite",
"tokio",
@@ -6683,10 +6748,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http 1.3.1",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"log",
"rustls 0.23.29",
"rustls 0.23.35",
"rustls-native-certs 0.8.2",
"rustls-pki-types",
"tokio",
@@ -6701,7 +6766,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0"
dependencies = [
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"pin-project-lite",
"tokio",
@@ -6729,7 +6794,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"native-tls",
"tokio",
@@ -6750,7 +6815,7 @@ dependencies = [
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
"hyper 1.8.0",
"hyper 1.8.1",
"ipnet",
"libc",
"percent-encoding",
@@ -6771,7 +6836,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7"
dependencies = [
"hex",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-util",
"pin-project-lite",
"tokio",
@@ -7419,7 +7484,7 @@ dependencies = [
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-http-proxy",
"hyper-rustls 0.27.7",
"hyper-timeout",
@@ -7428,7 +7493,7 @@ dependencies = [
"k8s-openapi",
"kube-core",
"pem 3.0.5",
"rustls 0.23.29",
"rustls 0.23.35",
"secrecy",
"serde",
"serde_json",
@@ -7913,7 +7978,7 @@ dependencies = [
"base64 0.22.1",
"gethostname",
"mail-builder",
"rustls 0.23.29",
"rustls 0.23.35",
"rustls-pki-types",
"smtp-proto",
"tokio",
@@ -8946,7 +9011,7 @@ dependencies = [
"http-body-util",
"httparse",
"humantime",
"hyper 1.8.0",
"hyper 1.8.1",
"itertools 0.14.0",
"md-5 0.10.6",
"parking_lot 0.12.5",
@@ -10246,7 +10311,7 @@ checksum = "7ada44a88ef953a3294f6eb55d2007ba44646015e18613d2f213016379203ef3"
dependencies = [
"ahash 0.8.12",
"equivalent",
"hashbrown 0.16.0",
"hashbrown 0.16.1",
"parking_lot 0.12.5",
]
@@ -10262,7 +10327,7 @@ dependencies = [
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.1",
"rustls 0.23.29",
"rustls 0.23.35",
"socket2 0.6.1",
"thiserror 2.0.17",
"tokio",
@@ -10282,7 +10347,7 @@ dependencies = [
"rand 0.9.0",
"ring 0.17.14",
"rustc-hash 2.1.1",
"rustls 0.23.29",
"rustls 0.23.35",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
@@ -10712,7 +10777,7 @@ dependencies = [
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-rustls 0.27.7",
"hyper-tls 0.6.0",
"hyper-util",
@@ -10724,7 +10789,7 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls 0.23.29",
"rustls 0.23.35",
"rustls-native-certs 0.8.2",
"rustls-pki-types",
"serde",
@@ -10772,7 +10837,7 @@ dependencies = [
"futures",
"getrandom 0.2.16",
"http 1.3.1",
"hyper 1.8.0",
"hyper 1.8.1",
"parking_lot 0.11.2",
"reqwest 0.12.24",
"reqwest-middleware",
@@ -10785,9 +10850,9 @@ dependencies = [
[[package]]
name = "resolv-conf"
version = "0.7.5"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "retry-policies"
@@ -11146,9 +11211,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.29"
version = "0.23.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
dependencies = [
"aws-lc-rs",
"log",
@@ -11232,7 +11297,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22557157d7395bc30727745b365d923f1ecc230c4c80b176545f3f4f08c46e33"
dependencies = [
"futures",
"rustls 0.23.29",
"rustls 0.23.35",
"socket2 0.5.10",
"tokio",
]
@@ -12297,7 +12362,7 @@ dependencies = [
"memchr",
"once_cell",
"percent-encoding",
"rustls 0.23.29",
"rustls 0.23.35",
"serde",
"serde_json",
"sha2 0.10.9",
@@ -13771,7 +13836,7 @@ version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls 0.23.29",
"rustls 0.23.35",
"tokio",
]
@@ -13945,7 +14010,7 @@ dependencies = [
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.8.0",
"hyper 1.8.1",
"hyper-timeout",
"hyper-util",
"percent-encoding",
@@ -14603,7 +14668,7 @@ dependencies = [
"log",
"native-tls",
"once_cell",
"rustls 0.23.29",
"rustls 0.23.35",
"rustls-pki-types",
"serde",
"serde_json",
@@ -15148,7 +15213,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"aws-sdk-config",
@@ -15177,7 +15242,7 @@ dependencies = [
"quote",
"rand 0.9.0",
"reqwest 0.12.24",
"rustls 0.23.29",
"rustls 0.23.35",
"serde",
"serde_json",
"serde_yml",
@@ -15204,11 +15269,13 @@ dependencies = [
"windmill-indexer",
"windmill-queue",
"windmill-worker",
"windows-service",
"windows-sys 0.52.0",
]
[[package]]
name = "windmill-api"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"argon2",
@@ -15251,7 +15318,7 @@ dependencies = [
"hf-hub",
"hmac",
"http 1.3.1",
"hyper 1.8.0",
"hyper 1.8.1",
"indexmap 2.11.1",
"itertools 0.14.0",
"jsonwebtoken 8.3.0",
@@ -15281,7 +15348,7 @@ dependencies = [
"rumqttc",
"rust-embed",
"rust_decimal",
"rustls 0.23.29",
"rustls 0.23.35",
"samael",
"serde",
"serde_json",
@@ -15329,7 +15396,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -15344,7 +15411,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"chrono",
"lazy_static",
@@ -15358,7 +15425,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"axum",
@@ -15377,14 +15444,17 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"async-recursion",
"async-stream",
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sdk-rds",
"aws-sdk-sts",
"aws-smithy-types",
"aws-smithy-types-convert",
"axum",
"backon",
@@ -15405,7 +15475,7 @@ dependencies = [
"globset",
"hex",
"hmac",
"hyper 1.8.0",
"hyper 1.8.1",
"indexmap 2.11.1",
"itertools 0.14.0",
"jsonwebtoken 8.3.0",
@@ -15452,6 +15522,7 @@ dependencies = [
"tracing-opentelemetry",
"tracing-subscriber",
"url",
"urlencoding",
"uuid",
"windmill-macros",
"windmill-parser",
@@ -15462,7 +15533,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"regex",
"serde",
@@ -15477,7 +15548,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"bytes",
@@ -15501,7 +15572,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15513,7 +15584,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15522,7 +15593,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15534,7 +15605,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"serde_json",
@@ -15546,7 +15617,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"gosyn",
@@ -15558,7 +15629,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15570,7 +15641,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"serde_json",
@@ -15582,7 +15653,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"nu-parser",
@@ -15593,7 +15664,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15604,7 +15675,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15616,7 +15687,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -15639,7 +15710,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15653,7 +15724,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15670,7 +15741,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15684,7 +15755,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"lazy_static",
@@ -15702,7 +15773,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"serde",
@@ -15713,7 +15784,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"async-recursion",
@@ -15722,12 +15793,14 @@ dependencies = [
"chrono",
"chrono-tz",
"cron",
"dashmap 6.1.0",
"futures",
"futures-core",
"hex",
"hmac",
"itertools 0.14.0",
"lazy_static",
"once_cell",
"prometheus",
"quick_cache",
"regex",
@@ -15748,7 +15821,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -15758,13 +15831,17 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.575.4"
version = "1.582.2"
dependencies = [
"anyhow",
"async-once-cell",
"async-recursion",
"async-stream",
"async-trait",
"aws-config",
"aws-credential-types",
"aws-sdk-bedrockruntime",
"aws-smithy-types",
"backon",
"base64 0.22.1",
"bit-vec 0.6.3",
@@ -16088,6 +16165,17 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-service"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a"
dependencies = [
"bitflags 2.9.4",
"widestring",
"windows-sys 0.52.0",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
@@ -16540,18 +16628,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.27"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
checksum = "43fa6694ed34d6e57407afbccdeecfa268c470a7d2a5b0cf49ce9fcc345afb90"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.27"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
checksum = "c640b22cd9817fae95be82f0d2f90b11f7605f6c319d16705c459b27ac2cbc26"
dependencies = [
"proc-macro2",
"quote",
+12 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.575.4"
version = "1.582.2"
authors.workspace = true
edition.workspace = true
@@ -33,7 +33,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.575.4"
version = "1.582.2"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -161,6 +161,10 @@ nom.workspace = true
globset.workspace = true
[target.'cfg(windows)'.dependencies]
windows-service = "0.7"
windows-sys = { version = "0.52", features = ["Win32_System_Services", "Win32_System_Console", "Win32_Foundation"] }
[target.'cfg(not(target_env = "msvc"))'.dependencies]
tikv-jemallocator = { optional = true, workspace = true }
tikv-jemalloc-sys = { optional = true, workspace = true }
@@ -264,6 +268,7 @@ regex = "^1"
semver = "^1"
aws-sigv4 = "^1.3.4"
aws-sdk-config = "=1.68.0"
aws-sdk-rds = "^1"
async-trait = "0.1.88"
@@ -328,6 +333,7 @@ dyn-iter = "0.2.0"
rsa = "^0"
async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] }
once_cell = "1.17.1"
dashmap = "6.1.0"
gosyn = "0.2.6"
bytes = "1.4.0"
gethostname = "0.4.3"
@@ -380,11 +386,14 @@ datafusion = "47.0.0"
object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7", features = ["aws", "azure", "gcp"] }
openidconnect = { version = "4.0.0-rc.1" }
aws-config = "^1"
aws-sdk-bedrockruntime = "=1.113.0"
aws-credential-types = "^1"
aws-smithy-types = "^1"
aws-sdk-sqs = "=1.77.0"
aws-sdk-sts = "=1.79.0"
aws-sdk-sso = "=1.77.0"
aws-sdk-ssooidc = "=1.78.0"
rustls = "=0.23.29"
rustls = "=0.23.35"
async-once-cell = "0.5.4"
+1 -1
View File
@@ -1 +1 @@
31ee9d3449f05cd0328c0fcf43e2b161dee767b9
c90bfc11c7c643b2c8c5111f092e7ae142318e9d
@@ -0,0 +1,2 @@
-- Add down migration script here
ALTER TABLE http_trigger DROP COLUMN enabled;
@@ -0,0 +1,2 @@
-- Add up migration script here
ALTER TABLE http_trigger ADD COLUMN enabled BOOLEAN DEFAULT TRUE NOT NULL;
@@ -0,0 +1,2 @@
-- Rollback: Remove job_isolation column from worker_ping table
ALTER TABLE worker_ping DROP COLUMN IF EXISTS job_isolation;
@@ -0,0 +1,4 @@
-- Add job_isolation column to worker_ping table
-- This tracks which job isolation method the worker is using: 'nsjail', 'unshare', or 'none'
-- Nullable for backwards compatibility - old workers will report NULL
ALTER TABLE worker_ping ADD COLUMN job_isolation TEXT;
@@ -0,0 +1 @@
-- Add down migration script here
@@ -0,0 +1,3 @@
-- Add up migration script here
ALTER TABLE email_trigger ADD COLUMN enabled BOOLEAN DEFAULT TRUE NOT NULL;
@@ -0,0 +1,3 @@
-- Add down migration script here
DROP TABLE IF EXISTS unique_ext_jwt_token;
@@ -0,0 +1,9 @@
-- Add up migration script here
CREATE TABLE IF NOT EXISTS unique_ext_jwt_token (
jwt_hash BIGINT PRIMARY KEY NOT NULL,
last_used_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_unique_ext_jwt_token_last_used_at ON unique_ext_jwt_token(last_used_at);
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# Run the command repeatedly until it fails (exits with non-zero code)
cd windmill-duckdb-ffi-internal && ./build_dev.sh && cd ..
while true; do
DISABLE_EMBEDDING=true \
RUST_LOG=info \
@@ -9,7 +10,7 @@ while true; do
GO_PATH=$(which go) \
UV_PATH=$(which uv) \
CARGO_PATH=$(which cargo) \
cargo test --features enterprise,deno_core,license,python,rust,scoped_cache \
cargo test --features enterprise,deno_core,license,python,duckdb,rust,scoped_cache \
-- --nocapture --test-threads=8 | tee /tmp/test.log
# Capture the exit code of the cargo test command (not tee)
+28 -5
View File
@@ -113,6 +113,10 @@ pub mod ee;
mod ee_oss;
mod monitor;
// Windows service support - EE feature
#[cfg(all(windows, feature = "enterprise", feature = "private"))]
mod windows_service_ee;
pub fn setup_deno_runtime() -> anyhow::Result<()> {
// https://github.com/denoland/deno/blob/main/cli/main.rs#L477
#[cfg(feature = "deno_core")]
@@ -207,6 +211,17 @@ lazy_static::lazy_static! {
}
pub fn main() -> anyhow::Result<()> {
// On Windows with enterprise feature, check if running as a service
#[cfg(all(windows, feature = "enterprise", feature = "private"))]
{
if windows_service_ee::is_running_as_service() {
// Run as Windows service with SCM handlers
return windows_service_ee::run_as_windows_service()
.map_err(|e| anyhow::anyhow!("Failed to run as Windows service: {}", e));
}
}
// Normal execution (console/foreground mode)
setup_deno_runtime()?;
create_and_run_current_thread_inner(windmill_main())
}
@@ -496,7 +511,14 @@ async fn windmill_main() -> anyhow::Result<()> {
conn
} else {
// This time we use a pool of connections
let db = windmill_common::connect_db(server_mode, indexer_mode, worker_mode).await?;
let db = windmill_common::connect_db(
server_mode,
indexer_mode,
worker_mode,
#[cfg(feature = "private")]
killpill_rx.resubscribe(),
)
.await?;
// NOTE: Variable/resource cache initialization moved to API server in windmill-api
@@ -817,10 +839,10 @@ Windmill Community Edition {GIT_VERSION}
match conn {
Connection::Sql(ref db) => {
let base_internal_url = base_internal_url.to_string();
let db_url: String = get_database_url().await?;
let db_url = get_database_url().await?;
let db = db.clone();
let h = tokio::spawn(async move {
let mut listener = retry_listen_pg(&db_url).await;
let mut listener = retry_listen_pg(&db_url.as_str().await).await;
let mut last_listener_refresh = Instant::now();
let mut monitor_iteration: u64 = 0;
let rd_shift: u8 = rand::rng().random_range(0..200);
@@ -1154,13 +1176,14 @@ Windmill Community Edition {GIT_VERSION}
},
Err(e) => {
tracing::error!(error = %e, "Could not receive notification, attempting to reconnect listener");
let db_url = db_url.clone();
tokio::select! {
biased;
_ = monitor_killpill_rx.recv() => {
tracing::info!("received killpill for monitor job");
break;
},
new_listener = retry_listen_pg(&db_url) => {
new_listener = async move { retry_listen_pg(&db_url.as_str().await).await } => {
listener = new_listener;
continue;
}
@@ -1174,7 +1197,7 @@ Windmill Community Edition {GIT_VERSION}
if let Err(e) = listener.unlisten_all().await {
tracing::error!(error = %e, "Could not unlisten to database");
}
listener = retry_listen_pg(&db_url).await;
listener = retry_listen_pg(&db_url.as_str().await).await;
initial_load(
&conn,
tx.clone(),
+30
View File
@@ -2944,3 +2944,33 @@ async fn test_workflow_as_code(db: Pool<Postgres>) -> anyhow::Result<()> {
.await;
Ok(())
}
#[cfg(feature = "duckdb")]
#[sqlx::test(fixtures("base"))]
async fn test_duckdb_ffi(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let content = "-- result_collection=last_statement_first_row_scalar\nSELECT 'Hello world!';";
let flow: FlowValue = serde_json::from_value(serde_json::json!({
"modules": [{
"value": {
"type": "rawscript",
"language": "duckdb",
"content": content,
},
}],
}))
.unwrap();
let result =
RunJob::from(JobPayload::RawFlow { value: flow.clone(), path: None, restarted_from: None })
.run_until_complete(&db, false, server.addr.port())
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("Hello world!"));
Ok(())
}
+97 -55
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.575.4
version: 1.582.2
title: Windmill API
contact:
@@ -572,12 +572,12 @@ paths:
use_case:
type: string
responses:
'200':
"200":
description: Onboarding data submitted successfully
content:
application/json:
schema:
type: string
type: string
/w/{workspace}/users/delete/{username}:
delete:
@@ -10163,6 +10163,34 @@ paths:
schema:
type: boolean
/w/{workspace}/http_triggers/setenabled/{path}:
post:
summary: enable/disable http trigger
operationId: setHttpTriggerEnabled
tags:
- http_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
required:
- enabled
responses:
"200":
description: http trigger enable/disable
content:
text/plain:
schema:
type: string
/w/{workspace}/websocket_triggers/create:
post:
summary: create websocket trigger
@@ -11970,6 +11998,33 @@ paths:
application/json:
schema:
type: boolean
/w/{workspace}/email_triggers/setenabled/{path}:
post:
summary: enable/disable email trigger
operationId: setEmailTriggerEnabled
tags:
- email_trigger
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/Path"
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
required:
- enabled
responses:
"200":
description: email trigger enable/disable
content:
text/plain:
schema:
type: string
/groups/list:
get:
@@ -15085,7 +15140,7 @@ components:
schema:
type: integer
JobTriggerKind:
name: trigger_kind
name: trigger_kind
description: trigger kind (schedule, http, websocket...)
in: query
schema:
@@ -15266,8 +15321,7 @@ components:
CreatedAfterQueue:
name: created_after_queue
description:
filter on jobs created after X for jobs in the queue only
description: filter on jobs created after X for jobs in the queue only
in: query
schema:
type: string
@@ -15275,8 +15329,7 @@ components:
CreatedBeforeQueue:
name: created_before_queue
description:
filter on jobs created before X for jobs in the queue only
description: filter on jobs created before X for jobs in the queue only
in: query
schema:
type: string
@@ -15457,6 +15510,8 @@ components:
$ref: "../../openflow.openapi.yaml#/components/schemas/FlowStatus"
FlowStatusModule:
$ref: "../../openflow.openapi.yaml#/components/schemas/FlowStatusModule"
FlowNote:
$ref: "../../openflow.openapi.yaml#/components/schemas/FlowNote"
# -- INLINE END --
# Do not change line above
@@ -15571,6 +15626,7 @@ components:
groq,
openrouter,
togetherai,
aws_bedrock,
customai,
]
@@ -16722,30 +16778,30 @@ components:
ScriptLang:
type: string
enum: [
python3,
deno,
go,
bash,
powershell,
postgresql,
mysql,
bigquery,
snowflake,
mssql,
oracledb,
graphql,
nativets,
bun,
php,
rust,
ansible,
csharp,
nu,
java,
ruby,
duckdb,
# for related places search: ADD_NEW_LANG
]
python3,
deno,
go,
bash,
powershell,
postgresql,
mysql,
bigquery,
snowflake,
mssql,
oracledb,
graphql,
nativets,
bun,
php,
rust,
ansible,
csharp,
nu,
java,
ruby,
duckdb,
# for related places search: ADD_NEW_LANG
]
Preview:
type: object
@@ -17222,6 +17278,8 @@ components:
format: date-time
is_flow:
type: boolean
enabled:
type: boolean
required:
- path
- script_path
@@ -17231,6 +17289,7 @@ components:
- edited_by
- edited_at
- is_flow
- enabled
AuthenticationMethod:
type: string
@@ -17456,6 +17515,8 @@ components:
type: boolean
wrap_body:
type: boolean
enabled:
type: boolean
raw_string:
type: boolean
error_handler_path:
@@ -17581,8 +17642,6 @@ components:
format: date-time
error:
type: string
enabled:
type: boolean
filters:
type: array
items:
@@ -17613,7 +17672,6 @@ components:
required:
- url
- enabled
- filters
- can_return_message
- can_return_error_result
@@ -17802,17 +17860,13 @@ components:
format: date-time
error:
type: string
enabled:
type: boolean
error_handler_path:
type: string
error_handler_args:
$ref: "#/components/schemas/ScriptArgs"
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
required:
- enabled
- subscribe_topics
- mqtt_resource_path
@@ -17934,8 +17988,6 @@ components:
format: date-time
error:
type: string
enabled:
type: boolean
error_handler_path:
type: string
error_handler_args:
@@ -17946,7 +17998,6 @@ components:
- gcp_resource_path
- topic_id
- subscription_id
- enabled
- delivery_type
- subscription_mode
@@ -18048,8 +18099,6 @@ components:
format: date-time
error:
type: string
enabled:
type: boolean
error_handler_path:
type: string
error_handler_args:
@@ -18060,7 +18109,6 @@ components:
required:
- queue_url
- aws_resource_path
- enabled
- aws_auth_resource_type
LoggedWizardStatus:
@@ -18257,8 +18305,6 @@ components:
- $ref: "#/components/schemas/TriggerExtraProperty"
type: object
properties:
enabled:
type: boolean
postgres_resource_path:
type: string
publication_name:
@@ -18279,7 +18325,6 @@ components:
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
required:
- enabled
- postgres_resource_path
- replication_slot_name
- publication_name
@@ -18370,8 +18415,6 @@ components:
format: date-time
error:
type: string
enabled:
type: boolean
error_handler_path:
type: string
error_handler_args:
@@ -18383,7 +18426,6 @@ components:
- kafka_resource_path
- group_id
- topics
- enabled
NewKafkaTrigger:
type: object
@@ -18475,8 +18517,6 @@ components:
format: date-time
error:
type: string
enabled:
type: boolean
error_handler_path:
type: string
error_handler_args:
@@ -18488,7 +18528,6 @@ components:
- nats_resource_path
- use_jetstream
- subjects
- enabled
NewNatsTrigger:
type: object
@@ -18602,7 +18641,8 @@ components:
$ref: "#/components/schemas/ScriptArgs"
retry:
$ref: "../../openflow.openapi.yaml#/components/schemas/Retry"
enabled:
type: boolean
required:
- path
- script_path
@@ -18764,6 +18804,8 @@ components:
type: number
wm_memory_usage:
type: number
job_isolation:
type: string
required:
- worker
- worker_instance
+89 -18
View File
@@ -1,3 +1,4 @@
use crate::bedrock;
use crate::db::{ApiAuthed, DB};
use axum::{body::Bytes, extract::Path, response::IntoResponse, routing::post, Extension, Router};
@@ -6,12 +7,12 @@ use quick_cache::sync::Cache;
use reqwest::{Client, RequestBuilder};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use windmill_common::variables::get_variable_or_self;
use std::collections::HashMap;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::ai_providers::{AIProvider, ProviderConfig, ProviderModel, AZURE_API_VERSION};
use windmill_common::ai_providers::{AIProvider, ProviderConfig, ProviderModel};
use windmill_common::error::{to_anyhow, Error, Result};
use windmill_common::utils::configure_client;
use windmill_common::variables::get_variable_or_self;
lazy_static::lazy_static! {
static ref HTTP_CLIENT: Client = configure_client(reqwest::ClientBuilder::new()
@@ -66,6 +67,7 @@ struct AIStandardResource {
#[serde(alias = "apiKey")]
api_key: Option<String>,
organization_id: Option<String>,
region: Option<String>,
}
#[derive(Deserialize, Debug)]
@@ -98,7 +100,9 @@ impl AIRequestConfig {
) -> Result<Self> {
let (api_key, access_token, organization_id, base_url, user) = match resource {
AIResource::Standard(resource) => {
let base_url = provider.get_base_url(resource.base_url, db).await?;
let base_url = provider
.get_base_url(resource.base_url, resource.region, db)
.await?;
let api_key = if let Some(api_key) = resource.api_key {
Some(get_variable_or_self(api_key, db, w_id).await?)
} else {
@@ -119,7 +123,7 @@ impl AIRequestConfig {
None
};
let token = Self::get_token_using_oauth(resource, db, w_id).await?;
let base_url = provider.get_base_url(None, db).await?;
let base_url = provider.get_base_url(None, None, db).await?;
(None, Some(token), None, base_url, user)
}
@@ -180,15 +184,34 @@ impl AIRequestConfig {
let is_azure = provider.is_azure_openai(base_url);
let is_anthropic = matches!(provider, AIProvider::Anthropic);
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
let is_bedrock = matches!(provider, AIProvider::AWSBedrock);
let url = if is_azure && method != Method::GET {
let model = AIProvider::extract_model_from_body(&body)?;
AIProvider::build_azure_openai_url(base_url, &model, path)
// Handle AWS Bedrock transformation
let (url, body) = if is_bedrock && method != Method::GET {
let (model, transformed_body, is_streaming) =
bedrock::transform_openai_to_bedrock(&body)?;
let endpoint = if is_streaming {
"converse-stream"
} else {
"converse"
};
let bedrock_url = format!("{}/model/{}/{}", base_url, model, endpoint);
(bedrock_url, transformed_body)
} else if is_bedrock && (path == "foundation-models" || path == "inference-profiles") {
// AWS Bedrock foundation-models and inference-profiles endpoints use different base URL (without -runtime)
let bedrock_base_url = base_url.replace("bedrock-runtime.", "bedrock.");
let bedrock_url = format!("{}/{}", bedrock_base_url, path);
(bedrock_url, body)
} else if is_azure {
let azure_url = AIProvider::build_azure_openai_url(base_url, path);
(azure_url, body)
} else if is_anthropic_sdk {
let truncated_base_url = base_url.trim_end_matches("/v1");
format!("{}/{}", truncated_base_url, path)
let anthropic_url = format!("{}/{}", truncated_base_url, path);
(anthropic_url, body)
} else {
format!("{}/{}", base_url, path)
let default_url = format!("{}/{}", base_url, path);
(default_url, body)
};
tracing::debug!("AI request URL: {}", url);
@@ -205,10 +228,6 @@ impl AIRequestConfig {
request = request.body(body);
if is_azure {
request = request.query(&[("api-version", AZURE_API_VERSION)])
}
if let Some(api_key) = self.api_key {
if is_azure {
request = request.header("api-key", api_key.clone())
@@ -316,7 +335,7 @@ async fn global_proxy(
return Err(Error::BadRequest("API key is required".to_string()));
};
let base_url = provider.get_base_url(None, &db).await?;
let base_url = provider.get_base_url(None, None, &db).await?;
let url = format!("{}/{}", base_url, ai_path);
@@ -446,6 +465,21 @@ async fn proxy(
}
};
// Extract model and streaming flag for Bedrock transformation (only for POST requests)
let (model, is_streaming) =
if matches!(provider, AIProvider::AWSBedrock) && method == Method::POST {
#[derive(Deserialize, Debug)]
struct BedrockRequest {
model: String,
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)
};
let request = request_config.prepare_request(&provider, &ai_path, method, headers, body)?;
let response = request.send().await.map_err(to_anyhow)?;
@@ -469,8 +503,45 @@ async fn proxy(
return Err(Error::AIError(err_msg));
}
let status_code = response.status();
let headers = response.headers().clone();
let stream = response.bytes_stream();
Ok((status_code, headers, axum::body::Body::from_stream(stream)))
// Transform Bedrock responses back to OpenAI format
if matches!(provider, AIProvider::AWSBedrock) && model.is_some() {
if is_streaming {
// Transform streaming response
use http::StatusCode;
let mut response_headers = 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());
let stream = response.bytes_stream();
let transformed_stream =
bedrock::transform_bedrock_stream_to_openai(stream, model.unwrap());
Ok((
StatusCode::OK,
response_headers,
axum::body::Body::from_stream(transformed_stream),
))
} else {
// Transform non-streaming response
let transformed_body =
bedrock::transform_bedrock_to_openai(response, model.unwrap()).await?;
let mut response_headers = HeaderMap::new();
response_headers.insert("content-type", "application/json".parse().unwrap());
Ok((
http::StatusCode::OK,
response_headers,
axum::body::Body::from(transformed_body),
))
}
} else {
// Pass through for other providers
let status_code = response.status();
let headers = response.headers().clone();
let stream = response.bytes_stream();
Ok((status_code, headers, axum::body::Body::from_stream(stream)))
}
}
+1
View File
@@ -90,6 +90,7 @@ impl AuthCache {
w_id.as_ref(),
token.trim_start_matches("jwt_ext_"),
self.ext_jwks.clone(),
&self.db,
)
.await
{
+602
View File
@@ -0,0 +1,602 @@
use axum::body::Bytes;
use bytes;
use futures;
use uuid;
use windmill_common::error::{Error, Result};
/// Transform OpenAI format request to AWS Bedrock Converse format
/// Returns: (model_id, transformed_body, is_streaming)
pub fn transform_openai_to_bedrock(body: &[u8]) -> Result<(String, Bytes, bool)> {
use serde_json::Value;
// Parse the OpenAI request
let openai_req: Value = serde_json::from_slice(body)
.map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?;
// Extract model and streaming flag
let model = openai_req["model"]
.as_str()
.ok_or_else(|| Error::BadRequest("Missing 'model' field in request".to_string()))?
.to_string();
let is_streaming = openai_req["stream"].as_bool().unwrap_or(false);
// Build Bedrock request
let mut bedrock_req = serde_json::json!({});
// Transform messages
if let Some(messages) = openai_req["messages"].as_array() {
let mut system_messages = Vec::new();
let mut conversation_messages = Vec::new();
for msg in messages {
let role = msg["role"].as_str().unwrap_or("");
match role {
"system" => {
// Extract system messages to separate array
if let Some(content) = msg["content"].as_str() {
system_messages.push(serde_json::json!({"text": content}));
}
}
"user" | "assistant" => {
// Normalize content to array format
let mut content = if let Some(text) = msg["content"].as_str() {
// Simple string → array of content blocks
vec![serde_json::json!({"text": text})]
} else if let Some(content_array) = msg["content"].as_array() {
// Already an array - transform each item
content_array
.iter()
.filter_map(|item| {
if let Some(text) = item["text"].as_str() {
Some(serde_json::json!({"text": text}))
} else if item["type"].as_str() == Some("text") {
Some(serde_json::json!({"text": item["text"]}))
} else if item["type"].as_str() == Some("image_url") {
// Transform image_url format if needed
// For now, pass through - may need more sophisticated handling
Some(item.clone())
} else {
None
}
})
.collect()
} else {
vec![]
};
// Handle tool_calls for assistant messages (OpenAI → Bedrock toolUse)
if role == "assistant" {
if let Some(tool_calls) = msg["tool_calls"].as_array() {
for tool_call in tool_calls {
if tool_call["type"].as_str() == Some("function") {
let tool_use_id = tool_call["id"].as_str().unwrap_or("");
let function_name =
tool_call["function"]["name"].as_str().unwrap_or("");
let arguments_str =
tool_call["function"]["arguments"].as_str().unwrap_or("{}");
// Parse arguments JSON string to object
let input = serde_json::from_str::<Value>(arguments_str)
.map_err(|e| {
Error::internal_err(format!(
"Failed to parse tool call arguments: {}",
e
))
})?;
content.push(serde_json::json!({
"toolUse": {
"toolUseId": tool_use_id,
"name": function_name,
"input": input
}
}));
}
}
}
}
// Only add message if it has content
if !content.is_empty() {
conversation_messages.push(serde_json::json!({
"role": role,
"content": content
}));
}
}
"tool" => {
// Transform tool response to Bedrock format
let tool_call_id = msg["tool_call_id"].as_str().unwrap_or("");
let content = msg["content"].as_str().unwrap_or("");
// Try to parse content as JSON
// Bedrock requires json field to be an object, not a primitive or array
let tool_result_content =
if let Ok(json_content) = serde_json::from_str::<Value>(content) {
if json_content.is_object() {
vec![serde_json::json!({"json": json_content})]
} else {
// Wrap primitives and arrays in an object
vec![serde_json::json!({"json": {"result": json_content}})]
}
} else {
vec![serde_json::json!({"text": content})]
};
conversation_messages.push(serde_json::json!({
"role": "user",
"content": [{
"toolResult": {
"toolUseId": tool_call_id,
"content": tool_result_content
}
}]
}));
}
_ => {}
}
}
if !system_messages.is_empty() {
bedrock_req["system"] = Value::Array(system_messages);
}
bedrock_req["messages"] = Value::Array(conversation_messages);
}
// Transform inference parameters
let mut inference_config = serde_json::json!({});
if let Some(max_tokens) = openai_req["max_tokens"].as_i64() {
inference_config["maxTokens"] = Value::Number(max_tokens.into());
}
if let Some(temperature) = openai_req["temperature"].as_f64() {
inference_config["temperature"] = serde_json::json!(temperature);
}
if let Some(top_p) = openai_req["top_p"].as_f64() {
inference_config["topP"] = serde_json::json!(top_p);
}
if let Some(stop) = openai_req["stop"].as_array() {
let stop_sequences: Vec<String> = stop
.iter()
.filter_map(|s| s.as_str().map(|s| s.to_string()))
.collect();
if !stop_sequences.is_empty() {
inference_config["stopSequences"] =
Value::Array(stop_sequences.into_iter().map(Value::String).collect());
}
}
if !inference_config.as_object().unwrap().is_empty() {
bedrock_req["inferenceConfig"] = inference_config;
}
// Transform tools if present
if let Some(tools) = openai_req["tools"].as_array() {
let mut bedrock_tools = Vec::new();
for tool in tools {
if tool["type"].as_str() == Some("function") {
if let Some(function) = tool["function"].as_object() {
bedrock_tools.push(serde_json::json!({
"toolSpec": {
"name": function.get("name"),
"description": function.get("description")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("Tool function"),
"inputSchema": {
"json": function.get("parameters")
}
}
}));
}
}
}
if !bedrock_tools.is_empty() {
let mut tool_config = serde_json::json!({
"tools": bedrock_tools
});
// Transform tool_choice
if let Some(tool_choice) = openai_req.get("tool_choice") {
if tool_choice == "auto" {
tool_config["toolChoice"] = serde_json::json!({"auto": {}});
} else if tool_choice == "required" {
tool_config["toolChoice"] = serde_json::json!({"any": {}});
} else if let Some(obj) = tool_choice.as_object() {
if obj.get("type").and_then(|v| v.as_str()) == Some("function") {
if let Some(function) = obj.get("function").and_then(|v| v.as_object()) {
if let Some(name) = function.get("name").and_then(|v| v.as_str()) {
tool_config["toolChoice"] = serde_json::json!({
"tool": {"name": name}
});
}
}
}
}
}
bedrock_req["toolConfig"] = tool_config;
}
}
let transformed_body = serde_json::to_vec(&bedrock_req)
.map_err(|e| Error::internal_err(format!("Failed to serialize Bedrock request: {}", e)))?
.into();
Ok((model, transformed_body, is_streaming))
}
/// Transform AWS Bedrock Converse response to OpenAI format
pub async fn transform_bedrock_to_openai(
response: reqwest::Response,
model: String,
) -> Result<Bytes> {
use serde_json::Value;
let bedrock_resp: Value = response
.json()
.await
.map_err(|e| Error::internal_err(format!("Failed to parse Bedrock response: {}", e)))?;
// Generate unique ID and timestamp
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 and map to finish_reason
let stop_reason = bedrock_resp["stopReason"].as_str().unwrap_or("end_turn");
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 message_content = &bedrock_resp["output"]["message"]["content"];
let mut text_content = String::new();
let mut tool_calls = Vec::new();
if let Some(content_array) = message_content.as_array() {
for (_index, block) in content_array.iter().enumerate() {
if let Some(text) = block["text"].as_str() {
text_content.push_str(text);
} else if let Some(tool_use) = block.get("toolUse") {
// Transform tool use to OpenAI tool_calls format
let tool_call_id = tool_use["toolUseId"].as_str().unwrap_or("");
let name = tool_use["name"].as_str().unwrap_or("");
let input = &tool_use["input"];
tool_calls.push(serde_json::json!({
"id": tool_call_id,
"type": "function",
"function": {
"name": name,
"arguments": serde_json::to_string(input).unwrap_or_default()
}
}));
}
}
}
// Build the message
let message = if !tool_calls.is_empty() {
serde_json::json!({
"role": "assistant",
"content": if text_content.is_empty() { Value::Null } else { 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) = bedrock_resp.get("usage") {
serde_json::json!({
"prompt_tokens": usage_data["inputTokens"].as_i64().unwrap_or(0),
"completion_tokens": usage_data["outputTokens"].as_i64().unwrap_or(0),
"total_tokens": usage_data["totalTokens"].as_i64().unwrap_or(0)
})
} 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)))?
.into();
Ok(response_body)
}
/// Transform AWS Bedrock streaming response to OpenAI SSE format
/// Bedrock uses AWS event stream binary format, not SSE
pub fn transform_bedrock_stream_to_openai(
stream: impl futures::Stream<Item = std::result::Result<bytes::Bytes, reqwest::Error>>
+ Send
+ 'static,
model: String,
) -> impl futures::Stream<Item = std::result::Result<bytes::Bytes, std::io::Error>> + Send {
use futures::stream::StreamExt;
use serde_json::Value;
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 and binary buffer
struct StreamState {
id: String,
model: String,
created: u64,
tool_calls: HashMap<usize, (String, String, String)>, // index -> (id, name, args)
buffer: Vec<u8>, // Binary buffer for AWS event stream
}
let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState {
id: id.clone(),
model: model.clone(),
created,
tool_calls: HashMap::new(),
buffer: Vec::new(),
}));
stream
.then(move |chunk_result| {
let state = state.clone();
async move {
match chunk_result {
Ok(chunk) => {
let mut state = state.lock().await;
state.buffer.extend_from_slice(&chunk);
let mut events = Vec::new();
// Parse AWS event stream messages from buffer
loop {
// Need at least 12 bytes for prelude (8) + prelude CRC (4)
if state.buffer.len() < 12 {
break;
}
// Read prelude: total_length (4 bytes) + headers_length (4 bytes)
let total_length = u32::from_be_bytes([
state.buffer[0],
state.buffer[1],
state.buffer[2],
state.buffer[3],
]) as usize;
// Check if we have the complete message
if state.buffer.len() < total_length {
break;
}
let headers_length = u32::from_be_bytes([
state.buffer[4],
state.buffer[5],
state.buffer[6],
state.buffer[7],
]) as usize;
// Skip prelude CRC (4 bytes after prelude)
let headers_start = 12;
let payload_start = headers_start + headers_length;
let payload_end = total_length - 4; // Exclude message CRC
// Parse headers to extract event type
let mut event_type = None;
let mut pos = headers_start;
while pos < payload_start {
if pos + 1 > state.buffer.len() {
break;
}
let name_len = state.buffer[pos] as usize;
pos += 1;
if pos + name_len > state.buffer.len() {
break;
}
let name = String::from_utf8_lossy(&state.buffer[pos..pos + name_len]).to_string();
pos += name_len;
if pos + 3 > state.buffer.len() {
break;
}
let value_type = state.buffer[pos];
pos += 1;
let value_len = u16::from_be_bytes([state.buffer[pos], state.buffer[pos + 1]]) as usize;
pos += 2;
if pos + value_len > state.buffer.len() {
break;
}
if value_type == 7 && name == ":event-type" {
event_type = Some(String::from_utf8_lossy(&state.buffer[pos..pos + value_len]).to_string());
}
pos += value_len;
}
// Extract JSON payload (copy to avoid borrow issues)
let payload = state.buffer[payload_start..payload_end].to_vec();
// Remove processed message from buffer
state.buffer.drain(0..total_length);
// Process the event
if let Some(evt_type) = event_type {
if let Ok(payload_str) = std::str::from_utf8(&payload) {
if let Ok(parsed_data) = serde_json::from_str::<Value>(payload_str) {
// Transform based on event type
match evt_type.as_str() {
"messageStart" => {
// No output for messageStart
}
"contentBlockStart" => {
let index = parsed_data["contentBlockIndex"].as_u64().unwrap_or(0) as usize;
if let Some(tool_use) = parsed_data["start"].get("toolUse") {
let tool_id = tool_use["toolUseId"].as_str().unwrap_or("").to_string();
let name = tool_use["name"].as_str().unwrap_or("").to_string();
state.tool_calls.insert(index, (tool_id.clone(), 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_id,
"type": "function",
"function": {
"name": name,
"arguments": ""
}
}]
},
"finish_reason": Value::Null
}]
});
events.push(Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))));
}
}
"contentBlockDelta" => {
let index = parsed_data["contentBlockIndex"].as_u64().unwrap_or(0) as usize;
if let Some(text) = parsed_data["delta"]["text"].as_str() {
// Text content delta
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": Value::Null
}]
});
events.push(Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))));
} else if let Some(tool_use_input) = parsed_data["delta"]["toolUse"]["input"].as_str() {
// Tool use arguments delta
if let Some((_tool_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) {
args.push_str(tool_use_input);
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": tool_use_input
}
}]
},
"finish_reason": Value::Null
}]
});
events.push(Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))));
}
}
}
"contentBlockStop" => {
// No output needed
}
"messageStop" => {
let stop_reason = parsed_data["stopReason"].as_str().unwrap_or("end_turn");
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
}]
});
events.push(Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))));
}
"metadata" => {
// Could include usage info here if needed
}
_ => {}
}
}
}
}
} // end loop
events
}
Err(e) => {
vec![Err(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
))]
}
}
}
})
.flat_map(|events| futures::stream::iter(events))
.chain(futures::stream::iter(vec![
// Send [DONE] at the end
Ok(bytes::Bytes::from("data: [DONE]\n\n"))
]))
}
+4
View File
@@ -53,6 +53,9 @@ lazy_static::lazy_static! {
).to_string()),
(20221105003256, "DELETE FROM workspace_invite WHERE workspace_id = 'demo' AND email = 'ruben@windmill.dev';".to_string()),
(20221123151919, "".to_string()),
(20251105100125, include_str!(
"../../migrations/20251105100125_legacy_sql_result_flag.up.sql"
).replace("", "")),
].into_iter().collect();
}
@@ -166,6 +169,7 @@ impl Migrate for CustomMigrator {
if let Some(migration_sql) = OVERRIDDEN_MIGRATIONS.get(&migration.version) {
tracing::info!("Using custom migration for version {}", migration.version);
// tracing::info!("Migration SQL: {}", migration_sql);
self.inner
.execute(&**migration_sql)
+1
View File
@@ -26,6 +26,7 @@ pub async fn jwt_ext_auth(
_w_id: Option<&String>,
_token: &str,
_external_jwks: Option<Arc<RwLock<ExternalJwks>>>,
_db: &crate::db::DB,
) -> anyhow::Result<(crate::db::ApiAuthed, usize)> {
// Implementation is not open source
+12 -10
View File
@@ -783,8 +783,8 @@ async fn update_flow(
sqlx::query!(
"
UPDATE
flow
UPDATE
flow
SET
path = $1,
summary = $2,
@@ -800,7 +800,7 @@ async fn update_flow(
schema = $9::text::json,
edited_by = $10,
edited_at = now()
WHERE
WHERE
path = $11 AND workspace_id = $12",
if is_new_path { flow_path } else { &nf.path },
nf.summary,
@@ -824,8 +824,8 @@ async fn update_flow(
if is_new_path {
// if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint
sqlx::query!(
"INSERT INTO flow
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
"INSERT INTO flow
(workspace_id, path, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at)
SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, draft_only, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at
FROM flow
WHERE path = $2 AND workspace_id = $3",
@@ -893,6 +893,8 @@ async fn update_flow(
.warn_after_seconds(10)
.await??;
// tracing::error!("Updating flow: {:?}", nf.value.get());
// This will lock anyone who is trying to iterate on flow_versions with given path and parameters.
let version = sqlx::query_scalar!(
"INSERT INTO flow_version (workspace_id, path, value, schema, created_by) VALUES ($1, $2, $3, $4::text::json, $5) RETURNING id",
@@ -1143,11 +1145,11 @@ async fn get_flow_by_path(
favorite.path IS NOT NULL AS starred
FROM flow
LEFT JOIN favorite
ON favorite.favorite_kind = 'flow'
AND favorite.workspace_id = flow.workspace_id
AND favorite.path = flow.path
ON favorite.favorite_kind = 'flow'
AND favorite.workspace_id = flow.workspace_id
AND favorite.path = flow.path
AND favorite.usr = $3
LEFT JOIN flow_version
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2
"#,
@@ -1182,7 +1184,7 @@ async fn get_flow_by_path(
flow_version.created_by AS edited_by,
NULL AS starred
FROM flow
LEFT JOIN flow_version
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2
"#,
+7 -1
View File
@@ -80,6 +80,7 @@ pub mod args;
mod assets;
mod audit;
pub mod auth;
mod bedrock;
mod capture;
mod concurrency_groups;
mod configs;
@@ -154,6 +155,7 @@ mod smtp_server_oss;
pub mod teams_approvals_ee;
mod teams_approvals_oss;
mod public_app_layer;
mod static_assets;
#[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))]
pub mod stripe_ee;
@@ -183,7 +185,6 @@ pub mod workspaces_ee;
mod workspaces_export;
mod workspaces_extra;
mod workspaces_oss;
mod public_app_layer;
#[cfg(feature = "mcp")]
mod mcp;
@@ -324,6 +325,11 @@ pub async fn run_server(
#[cfg(feature = "embedding")]
load_embeddings_db(&db);
#[cfg(feature = "cloud")]
if *CLOUD_HOSTED {
windmill_queue::init_usage_buffer(db.clone());
}
let mut start_smtp_server = false;
if let Some(smtp_settings) =
load_value_from_global_settings(&db, EMAIL_DOMAIN_SETTING).await?
+76 -48
View File
@@ -39,6 +39,7 @@ use super::utils::{
FlowInfo, ResourceInfo, ResourceType, SchemaType, ScriptInfo, ToolableItem, WorkspaceId,
},
schema::transform_schema_for_resources,
scope_matcher::{is_resource_allowed, parse_mcp_scopes},
transform::{reverse_transform, reverse_transform_key},
};
@@ -151,6 +152,10 @@ impl ServerHandler for Runner {
check_scopes(authed)?;
// Parse MCP scopes for authorization
let scopes = authed.scopes.as_ref().map(|s| s.as_slice()).unwrap_or(&[]);
let scope_config = parse_mcp_scopes(scopes)?;
if request.name.ends_with("_TRUNC") {
return Ok(CallToolResult::error(
vec![
@@ -197,6 +202,19 @@ impl ServerHandler for Runner {
let endpoint_tools = all_endpoint_tools();
for endpoint_tool in endpoint_tools {
if endpoint_tool.name.as_ref() == request.name {
// Validate endpoint scope
if scope_config.granular
&& !is_resource_allowed(&endpoint_tool.name, &scope_config.endpoints)
{
return Err(ErrorData::internal_error(
format!(
"Access denied: endpoint '{}' not in token scope",
endpoint_tool.name
),
None,
));
}
// This is an endpoint tool, forward to the actual HTTP endpoint
let result =
call_endpoint_tool(&endpoint_tool, args.clone(), &workspace_id, &authed)
@@ -212,6 +230,21 @@ impl ServerHandler for Runner {
ErrorData::internal_error(format!("Failed to reverse transform path: {}", e), None)
})?;
// Validate script/flow scope
if !is_hub && scope_config.granular {
if tool_type == "script" && !is_resource_allowed(&path, &scope_config.scripts) {
return Err(ErrorData::internal_error(
format!("Access denied: script '{}' not in token scope", path),
None,
));
} else if tool_type == "flow" && !is_resource_allowed(&path, &scope_config.flows) {
return Err(ErrorData::internal_error(
format!("Access denied: flow '{}' not in token scope", path),
None,
));
}
}
let item_schema = if is_hub {
get_hub_script_schema(&format!("hub/{}", path), db).await?
} else {
@@ -337,53 +370,23 @@ impl ServerHandler for Runner {
})
.map(|w_id| w_id.0.clone())?;
let scopes = authed.scopes.as_ref();
let owned_scope = scopes.and_then(|scopes| {
scopes
.iter()
.find(|scope| scope.starts_with("mcp:") && !scope.contains("hub"))
});
let hub_scope =
scopes.and_then(|scopes| scopes.iter().find(|scope| scope.starts_with("mcp:hub")));
let (scope_type, scope_path) = owned_scope.map_or(("all", None), |scope| {
let parts = scope.split(":").collect::<Vec<&str>>();
(
parts[1],
if parts.len() == 3 {
Some(parts[2])
} else {
None
},
)
});
let scope_integrations = hub_scope.and_then(|scope| {
let parts = scope.split(":").collect::<Vec<&str>>();
if parts.len() == 3 {
Some(parts[2])
} else {
None
}
});
// Parse MCP scopes to determine what to expose
let scopes = authed.scopes.as_ref().map(|s| s.as_slice()).unwrap_or(&[]);
let scope_config = parse_mcp_scopes(scopes)?;
let scripts_fn = get_items::<ScriptInfo>(
user_db,
authed,
&workspace_id,
scope_type,
"script",
scope_path.as_deref(),
);
let flows_fn = get_items::<FlowInfo>(
user_db,
authed,
&workspace_id,
scope_type,
"flow",
scope_path.as_deref(),
);
let scope_type = if scope_config.favorites {
"favorites"
} else {
// Fetch all items if either all or granular scope set (we filter later for granular scopes)
"all"
};
let scripts_fn =
get_items::<ScriptInfo>(user_db, authed, &workspace_id, scope_type, "script");
let flows_fn = get_items::<FlowInfo>(user_db, authed, &workspace_id, scope_type, "flow");
let resources_types_fn = get_resources_types(user_db, authed, &workspace_id);
let hub_scripts_fn = get_scripts_from_hub(db, scope_integrations.as_deref());
let (scripts, flows, resources_types, hub_scripts) = if scope_integrations.is_some() {
let hub_scripts_fn = get_scripts_from_hub(db, scope_config.hub_apps.as_deref());
let (scripts, flows, resources_types, hub_scripts) = if scope_config.hub_apps.is_some() {
let (scripts, flows, resources_types, hub_scripts) =
try_join!(scripts_fn, flows_fn, resources_types_fn, hub_scripts_fn)?;
(scripts, flows, resources_types, hub_scripts)
@@ -396,7 +399,13 @@ impl ServerHandler for Runner {
let mut resources_cache: HashMap<String, Vec<ResourceInfo>> = HashMap::new();
let mut tools: Vec<Tool> = Vec::new();
// Filter and add scripts based on scope
for script in scripts {
// For granular scopes, filter by path
if scope_config.granular && !is_resource_allowed(&script.path, &scope_config.scripts) {
continue;
}
tools.push(
Runner::create_tool_from_item(
&script,
@@ -410,7 +419,13 @@ impl ServerHandler for Runner {
);
}
// Filter and add flows based on scope
for flow in flows {
// For granular scopes, filter by path
if scope_config.granular && !is_resource_allowed(&flow.path, &scope_config.flows) {
continue;
}
tools.push(
Runner::create_tool_from_item(
&flow,
@@ -438,10 +453,23 @@ impl ServerHandler for Runner {
);
}
// Add endpoint tools from the generated MCP tools
// Add endpoint tools from the generated MCP tools, filtered by scope
let endpoint_tools = all_endpoint_tools();
let mcp_tools_converted = endpoint_tools_to_mcp_tools(endpoint_tools);
tools.extend(mcp_tools_converted);
for endpoint_tool in endpoint_tools {
// For granular scopes, filter by endpoint name
if scope_config.granular
&& !is_resource_allowed(&endpoint_tool.name, &scope_config.endpoints)
{
continue;
}
tools.push(
endpoint_tools_to_mcp_tools(vec![endpoint_tool])
.into_iter()
.next()
.unwrap(),
);
}
Ok(ListToolsResult { tools, next_cursor: None })
}
+4 -23
View File
@@ -18,11 +18,10 @@ use crate::HTTP_CLIENT;
pub fn check_scopes(authed: &ApiAuthed) -> Result<(), ErrorData> {
let scopes = authed.scopes.as_ref();
if scopes.is_none()
|| scopes.unwrap().iter().all(|scope| {
!scope.starts_with("mcp:all")
&& !scope.starts_with("mcp:favorites")
&& !scope.starts_with("mcp:hub:")
})
|| scopes
.unwrap()
.iter()
.all(|scope| !scope.starts_with("mcp:"))
{
tracing::error!("Unauthorized: missing mcp scope");
return Err(ErrorData::internal_error(
@@ -141,7 +140,6 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
workspace_id: &str,
scope_type: &str,
item_type: &str,
scope_path: Option<&str>,
) -> Result<Vec<T>, ErrorData> {
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
let fields = vec!["o.path", "o.summary", "o.description", "o.schema"];
@@ -159,23 +157,6 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)");
}
// scope path is always a folder path, format is f/my_folder/*
if let Some(scope_path) = scope_path {
if scope_path.split("/").count() != 3
|| !scope_path.starts_with("f/")
|| !scope_path.ends_with("/*")
{
return Err(ErrorData::internal_error(
format!(
"Invalid folder format: {}, expected format is f/my_folder/*",
scope_path
),
None,
));
}
sqlb.and_where_like_left("o.path", &scope_path[..scope_path.len() - 2]);
}
sqlb.order_by(
if item_type == "flow" {
"o.edited_at"
+2 -1
View File
@@ -6,4 +6,5 @@
pub mod models;
pub mod database;
pub mod schema;
pub mod transform;
pub mod transform;
pub mod scope_matcher;
@@ -0,0 +1,229 @@
//! MCP Scope matching utilities
//!
//! Contains utilities for parsing and matching MCP token scopes to determine
//! which scripts, flows, and endpoints a token has access to.
use rmcp::ErrorData;
/// Configuration for MCP scopes parsed from token scopes
#[derive(Debug, Clone, Default)]
pub struct McpScopeConfig {
/// Script paths/patterns allowed by this token
pub scripts: Vec<String>,
/// Flow paths/patterns allowed by this token
pub flows: Vec<String>,
/// Endpoint names/patterns allowed by this token
pub endpoints: Vec<String>,
/// Whether this is a legacy "all" scope
pub all: bool,
/// Whether this is a "favorites" scope
pub favorites: bool,
/// Whether a granular scope is detected
pub granular: bool,
/// Hub app filter (if any)
pub hub_apps: Option<String>,
}
/// Parse MCP scopes from token scope strings
pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, ErrorData> {
let mut config = McpScopeConfig::default();
for scope in scopes {
if !scope.starts_with("mcp:") {
continue;
}
if scope == "mcp:all" {
// Legacy scope: grant access to everything
config.all = true;
config.scripts.push("*".to_string());
config.flows.push("*".to_string());
config.endpoints.push("*".to_string());
continue;
}
if scope == "mcp:favorites" {
// Legacy favorites scope
config.favorites = true;
continue;
}
// Legacy folder scope: mcp:all:f/folder/*
if scope.starts_with("mcp:all:") {
if let Some(folder_pattern) = scope.strip_prefix("mcp:all:") {
// Parse as folder pattern - add to both scripts and flows. Also add all endpoints.
config.scripts.push(folder_pattern.to_string());
config.flows.push(folder_pattern.to_string());
config.endpoints.push("*".to_string());
}
continue;
}
if scope.starts_with("mcp:hub:") {
// Legacy hub scope
if let Some(apps) = scope.strip_prefix("mcp:hub:") {
config.hub_apps = Some(apps.to_string());
}
continue;
}
if let Some(resources) = scope.strip_prefix("mcp:scripts:") {
// New granular script scope: mcp:scripts:path1,path2,f/folder/*
config.scripts.extend(parse_resource_list(resources)?);
continue;
}
if let Some(resources) = scope.strip_prefix("mcp:flows:") {
// New granular flow scope: mcp:flows:path1,path2,f/folder/*
config.flows.extend(parse_resource_list(resources)?);
continue;
}
if let Some(resources) = scope.strip_prefix("mcp:endpoints:") {
// New granular endpoint scope: mcp:endpoints:name1,name2
config.endpoints.extend(parse_resource_list(resources)?);
continue;
}
tracing::warn!("Unrecognized MCP scope format: {}", scope);
}
config.granular = !config.all && !config.favorites;
Ok(config)
}
/// Parse comma-separated resource list
fn parse_resource_list(resources: &str) -> Result<Vec<String>, ErrorData> {
if resources.is_empty() {
return Ok(vec![]);
}
Ok(resources
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
}
/// Check if a resource path matches any pattern in the allowed list
pub fn is_resource_allowed(resource_path: &str, allowed_patterns: &[String]) -> bool {
if allowed_patterns.is_empty() {
return false;
}
// Wildcard grants all access
if allowed_patterns.contains(&"*".to_string()) {
return true;
}
// Check against each pattern
for pattern in allowed_patterns {
if resource_matches_pattern(resource_path, pattern) {
return true;
}
}
false
}
/// Check if a resource path matches a pattern (supports wildcards like f/folder/*)
fn resource_matches_pattern(resource_path: &str, pattern: &str) -> bool {
// Exact match
if pattern == resource_path {
return true;
}
// Wildcard pattern matching
if pattern.ends_with("/*") {
let prefix = &pattern[..pattern.len() - 2];
if !resource_path.starts_with(prefix) {
return false;
}
// If the resource is exactly the prefix, it matches
if resource_path.len() == prefix.len() {
return true;
}
// If the resource is longer, the next character must be '/' for a valid match
// This prevents "u/user" from matching "u/use/*"
return resource_path.chars().nth(prefix.len()) == Some('/');
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_legacy_scopes() {
let scopes = vec!["mcp:all".to_string()];
let config = parse_mcp_scopes(&scopes).unwrap();
assert!(config.all);
assert_eq!(config.scripts, vec!["*"]);
assert_eq!(config.flows, vec!["*"]);
assert_eq!(config.endpoints, vec!["*"]);
let scopes = vec!["mcp:favorites".to_string()];
let config = parse_mcp_scopes(&scopes).unwrap();
assert!(config.favorites);
let scopes = vec!["mcp:hub:slack".to_string()];
let config = parse_mcp_scopes(&scopes).unwrap();
assert_eq!(config.hub_apps, Some("slack".to_string()));
}
#[test]
fn test_parse_granular_scopes() {
let scopes = vec![
"mcp:scripts:u/admin/script1,u/admin/script2".to_string(),
"mcp:flows:f/automation/*".to_string(),
"mcp:endpoints:list_jobs,get_job".to_string(),
];
let config = parse_mcp_scopes(&scopes).unwrap();
assert_eq!(config.scripts, vec!["u/admin/script1", "u/admin/script2"]);
assert_eq!(config.flows, vec!["f/automation/*"]);
assert_eq!(config.endpoints, vec!["list_jobs", "get_job"]);
}
#[test]
fn test_resource_matching() {
// Exact match
assert!(resource_matches_pattern("u/admin/script", "u/admin/script"));
// Wildcard folder match
assert!(resource_matches_pattern("f/folder/script", "f/folder/*"));
assert!(resource_matches_pattern(
"f/folder/sub/script",
"f/folder/*"
));
// Should NOT match - prefix is not complete
assert!(!resource_matches_pattern("u/username", "u/user/*"));
// Should match - exact prefix
assert!(resource_matches_pattern("u/user/script", "u/user/*"));
}
#[test]
fn test_is_resource_allowed() {
let patterns = vec!["u/admin/script1".to_string(), "f/folder/*".to_string()];
assert!(is_resource_allowed("u/admin/script1", &patterns));
assert!(is_resource_allowed("f/folder/anything", &patterns));
assert!(!is_resource_allowed("u/other/script", &patterns));
// Test wildcard
let wildcard = vec!["*".to_string()];
assert!(is_resource_allowed("any/path", &wildcard));
// Test empty patterns
let empty: Vec<String> = vec![];
assert!(!is_resource_allowed("any/path", &empty));
}
}
+26 -1
View File
@@ -472,7 +472,7 @@ pub async fn get_resource_value_interpolated_internal(
// This is a special syntax to help debugging ducklake catalogs stored in the instance
if let Some(dbname) = path.strip_prefix("INSTANCE_DUCKLAKE_CATALOG/") {
require_super_admin(db, &authed.email).await?;
let pg_creds = parse_postgres_url(&get_database_url().await?)?;
let pg_creds = parse_postgres_url(&get_database_url().await?.as_str().await)?;
return Ok(Some(serde_json::json!({
"dbname": dbname,
"host": pg_creds.host,
@@ -738,6 +738,31 @@ async fn create_resource(
let res_value = resource.value.unwrap_or_default();
let raw_json = sqlx::types::Json(res_value.as_ref());
if resource.path.starts_with("f/app_themes/") {
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by, edited_at) VALUES ($1, 'app_themes', 'App Themes', ARRAY[]::TEXT[], '{\"g/all\": false}', $2, now()) ON CONFLICT DO NOTHING",
w_id,
authed.username,
)
.execute(&db)
.await?;
} else if resource.path.starts_with("f/app_custom/") {
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by, edited_at) VALUES ($1, 'app_custom', 'App Custom Components', ARRAY[]::TEXT[], '{\"g/all\": false}', $2, now()) ON CONFLICT DO NOTHING",
w_id,
authed.username,
)
.execute(&db)
.await?;
} else if resource.path.starts_with("f/app_groups/") {
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by, edited_at) VALUES ($1, 'app_groups', 'App Groups', ARRAY[]::TEXT[], '{\"g/all\": false}', $2, now()) ON CONFLICT DO NOTHING",
w_id,
authed.username,
)
.execute(&db)
.await?;
}
sqlx::query!(
"INSERT INTO resource
(workspace_id, path, value, description, resource_type, created_by, edited_at)
+2 -3
View File
@@ -647,8 +647,7 @@ async fn setup_ducklake_catalog_db_inner(
) -> Result<()> {
require_super_admin(db, &authed.email).await?;
logs.super_admin = "OK".to_string();
let pg_creds = &get_database_url().await?;
let pg_creds = parse_postgres_url(pg_creds)?;
let pg_creds = parse_postgres_url(&get_database_url().await?.as_str().await)?;
logs.database_credentials = "OK".to_string();
// Validate name to ensure it only contains alphanumeric characters
@@ -695,7 +694,7 @@ async fn setup_ducklake_catalog_db_inner(
"postgres://{user}:{password}@{host}:{port}/{dbname}?sslmode={sslmode}",
user = urlencoding::encode(&pg_creds.username.unwrap_or_else(|| "postgres".to_string())),
password = urlencoding::encode(&pg_creds.password.as_deref().unwrap_or("")),
host = urlencoding::encode(&pg_creds.host),
host = &pg_creds.host,
port = pg_creds.port.unwrap_or(5432),
dbname = dbname,
sslmode = ssl_mode
@@ -28,7 +28,6 @@ impl TriggerCrud for EmailTrigger {
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_ENABLED: bool = false;
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/email_triggers";
@@ -25,7 +25,6 @@ impl TriggerCrud for GcpTrigger {
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_ENABLED: bool = false;
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/gcp_triggers";
+63 -39
View File
@@ -50,7 +50,6 @@ pub trait TriggerCrud: Send + Sync + 'static {
const TABLE_NAME: &'static str;
const TRIGGER_TYPE: &'static str;
const SUPPORTS_ENABLED: bool;
const SUPPORTS_SERVER_STATE: bool;
const SUPPORTS_TEST_CONNECTION: bool;
const ROUTE_PREFIX: &'static str;
@@ -144,10 +143,11 @@ pub trait TriggerCrud: Send + Sync + 'static {
"email",
"edited_at",
"extra_perms",
"enabled",
];
if Self::SUPPORTS_SERVER_STATE {
fields.extend_from_slice(&["enabled", "server_id", "last_server_ping", "error"]);
fields.extend_from_slice(&["server_id", "last_server_ping", "error"]);
}
fields.extend_from_slice(&["error_handler_path", "error_handler_args", "retry"]);
@@ -206,6 +206,10 @@ pub trait TriggerCrud: Send + Sync + 'static {
Ok(deleted > 0)
}
async fn set_enabled_extra_action(&self, _: &mut PgConnection) -> Result<()> {
Ok(())
}
async fn set_enabled(
&self,
authed: &ApiAuthed,
@@ -214,38 +218,59 @@ pub trait TriggerCrud: Send + Sync + 'static {
path: &str,
enabled: bool,
) -> Result<bool> {
if !Self::SUPPORTS_SERVER_STATE {
return Err(anyhow::anyhow!(
"Enable/disable not supported for this trigger type".to_string(),
)
.into());
}
let updated = if Self::SUPPORTS_SERVER_STATE {
sqlx::query(&format!(
r#"
UPDATE
{}
SET
enabled = $1,
email = $2,
edited_by = $3,
edited_at = now(),
server_id = NULL,
error = NULL
WHERE
workspace_id = $4 AND
path = $5
"#,
Self::TABLE_NAME
))
.bind(enabled)
.bind(&authed.email)
.bind(&authed.username)
.bind(workspace_id)
.bind(path)
.execute(&mut *tx)
.await?
.rows_affected()
} else {
sqlx::query(&format!(
r#"
UPDATE
{}
SET
enabled = $1,
email = $2,
edited_by = $3,
edited_at = now()
WHERE
workspace_id = $4 AND
path = $5
"#,
Self::TABLE_NAME
))
.bind(enabled)
.bind(&authed.email)
.bind(&authed.username)
.bind(workspace_id)
.bind(path)
.execute(&mut *tx)
.await?
.rows_affected()
};
let updated = sqlx::query(&format!(
r#"
UPDATE
{}
SET
enabled = $1,
email = $2,
edited_by = $3,
edited_at = now(),
server_id = NULL,
error = NULL
WHERE
workspace_id = $4 AND
path = $5
"#,
Self::TABLE_NAME
))
.bind(enabled)
.bind(&authed.email)
.bind(&authed.username)
.bind(workspace_id)
.bind(path)
.execute(&mut *tx)
.await?
.rows_affected();
self.set_enabled_extra_action(&mut *tx).await?;
Ok(updated > 0)
}
@@ -296,10 +321,11 @@ pub trait TriggerCrud: Send + Sync + 'static {
"email",
"edited_at",
"extra_perms",
"enabled",
];
if Self::SUPPORTS_SERVER_STATE {
fields.extend_from_slice(&["enabled", "server_id", "last_server_ping", "error"]);
fields.extend_from_slice(&["server_id", "last_server_ping", "error"]);
}
fields.extend_from_slice(&["error_handler_path", "error_handler_args", "retry"]);
@@ -333,6 +359,7 @@ pub trait TriggerCrud: Send + Sync + 'static {
.sql()
.map_err(|e| Error::InternalErr(format!("SQL error: {}", e)))?;
tracing::info!("SQL: {}", sql);
let triggers = sqlx::query_as(&sql).fetch_all(&mut *tx).await?;
Ok(triggers)
@@ -346,11 +373,8 @@ pub fn trigger_routes<T: TriggerCrud + 'static>() -> Router {
.route("/get/*path", get(get_trigger::<T>))
.route("/update/*path", post(update_trigger::<T>))
.route("/delete/*path", delete(delete_trigger::<T>))
.route("/exists/*path", get(exists_trigger::<T>));
if T::SUPPORTS_ENABLED {
router = router.route("/setenabled/*path", post(set_enabled_trigger::<T>));
}
.route("/exists/*path", get(exists_trigger::<T>))
.route("/setenabled/*path", post(set_enabled_trigger::<T>));
if T::SUPPORTS_TEST_CONNECTION {
router = router.route("/test", post(test_connection::<T>));
@@ -196,6 +196,7 @@ pub async fn insert_new_trigger_into_db(
summary,
description,
is_flow,
enabled,
request_type,
authentication_method,
http_method,
@@ -209,7 +210,7 @@ pub async fn insert_new_trigger_into_db(
retry
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, now(), $19, $20, $21, $22
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, now(), $20, $21, $22, $23
)
"#,
w_id,
@@ -224,6 +225,7 @@ pub async fn insert_new_trigger_into_db(
trigger.config.summary,
trigger.config.description,
trigger.base.is_flow,
trigger.base.enabled.unwrap_or(true),
request_type as _,
trigger.config.authentication_method as _,
trigger.config.http_method as _,
@@ -356,7 +358,6 @@ impl TriggerCrud for HttpTrigger {
const TABLE_NAME: &'static str = "http_trigger";
const TRIGGER_TYPE: &'static str = "http";
const SUPPORTS_ENABLED: bool = false;
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/http_triggers";
@@ -482,22 +483,23 @@ impl TriggerCrud for HttpTrigger {
script_path = $7,
path = $8,
is_flow = $9,
http_method = $10,
static_asset_config = $11,
edited_by = $12,
email = $13,
request_type = $14,
authentication_method = $15,
summary = $16,
description = $17,
enabled = $10,
http_method = $11,
static_asset_config = $12,
edited_by = $13,
email = $14,
request_type = $15,
authentication_method = $16,
summary = $17,
description = $18,
edited_at = now(),
is_static_website = $18,
error_handler_path = $19,
error_handler_args = $20,
retry = $21
is_static_website = $19,
error_handler_path = $20,
error_handler_args = $21,
retry = $22
WHERE
workspace_id = $22 AND
path = $23
workspace_id = $23 AND
path = $24
"#,
route_path,
&route_path_key,
@@ -508,6 +510,7 @@ impl TriggerCrud for HttpTrigger {
trigger.base.script_path,
trigger.base.path,
trigger.base.is_flow,
trigger.base.enabled.unwrap_or(true),
trigger.config.http_method as _,
trigger.config.static_asset_config as _,
&authed.username,
@@ -539,22 +542,23 @@ impl TriggerCrud for HttpTrigger {
script_path = $4,
path = $5,
is_flow = $6,
http_method = $7,
static_asset_config = $8,
edited_by = $9,
email = $10,
request_type = $11,
authentication_method = $12,
summary = $13,
description = $14,
enabled = $7,
http_method = $8,
static_asset_config = $9,
edited_by = $10,
email = $11,
request_type = $12,
authentication_method = $13,
summary = $14,
description = $15,
edited_at = now(),
is_static_website = $15,
error_handler_path = $16,
error_handler_args = $17,
retry = $18
is_static_website = $16,
error_handler_path = $17,
error_handler_args = $18,
retry = $19
WHERE
workspace_id = $19 AND
path = $20
workspace_id = $20 AND
path = $21
"#,
trigger.config.wrap_body,
trigger.config.raw_string,
@@ -562,6 +566,7 @@ impl TriggerCrud for HttpTrigger {
trigger.base.script_path,
trigger.base.path,
trigger.base.is_flow,
trigger.base.enabled.unwrap_or(true),
trigger.config.http_method as _,
trigger.config.static_asset_config as _,
&authed.username,
@@ -586,6 +591,10 @@ impl TriggerCrud for HttpTrigger {
Ok(())
}
async fn set_enabled_extra_action(&self, tx: &mut PgConnection) -> Result<()> {
increase_trigger_version(tx).await
}
async fn delete_by_path(
&self,
tx: &mut PgConnection,
@@ -261,7 +261,8 @@ pub async fn refresh_routers(db: &DB) -> Result<(bool, RwLockReadGuard<'_, Route
FROM
http_trigger
WHERE
http_method = $1
http_method = $1 AND
enabled is TRUE
"#,
&http_method as &HttpMethod
)
@@ -28,7 +28,6 @@ impl TriggerCrud for KafkaTrigger {
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_ENABLED: bool = false;
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/kafka_triggers";
@@ -69,12 +69,12 @@ pub trait Listener: TriggerCrud + TriggerJobArgs {
"email",
"edited_at",
"extra_perms",
"enabled",
"error_handler_path",
"error_handler_args",
"retry",
];
if Self::SUPPORTS_SERVER_STATE {
fields.extend_from_slice(&["enabled", "server_id", "last_server_ping", "error"]);
}
fields.extend_from_slice(&["error_handler_path", "error_handler_args", "retry"]);
fields.extend_from_slice(Self::ADDITIONAL_SELECT_FIELDS);
let mut sqlb = SqlBuilder::select_from(Self::TABLE_NAME);
+1 -1
View File
@@ -47,6 +47,7 @@ pub struct BaseTrigger {
pub workspace_id: String,
pub path: String,
pub script_path: String,
pub enabled: Option<bool>,
pub is_flow: bool,
pub edited_by: String,
pub email: String,
@@ -56,7 +57,6 @@ pub struct BaseTrigger {
#[derive(Debug, FromRow, Clone, Serialize, Deserialize)]
pub struct ServerState {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub server_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -26,7 +26,6 @@ impl TriggerCrud for MqttTrigger {
const TABLE_NAME: &'static str = "mqtt_trigger";
const TRIGGER_TYPE: &'static str = "mqtt";
const SUPPORTS_ENABLED: bool = true;
const SUPPORTS_SERVER_STATE: bool = true;
const SUPPORTS_TEST_CONNECTION: bool = true;
const ROUTE_PREFIX: &'static str = "/mqtt_triggers";
@@ -25,7 +25,6 @@ impl TriggerCrud for NatsTrigger {
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_ENABLED: bool = false;
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/nats_triggers";
@@ -47,7 +47,6 @@ impl TriggerCrud for PostgresTrigger {
const TABLE_NAME: &'static str = "postgres_trigger";
const TRIGGER_TYPE: &'static str = "postgres";
const SUPPORTS_ENABLED: bool = true;
const SUPPORTS_SERVER_STATE: bool = true;
const SUPPORTS_TEST_CONNECTION: bool = true;
const ROUTE_PREFIX: &'static str = "/postgres_triggers";
@@ -64,7 +63,6 @@ impl TriggerCrud for PostgresTrigger {
DeployedObject::PostgresTrigger { path }
}
async fn create_trigger(
&self,
db: &DB,
@@ -350,14 +350,14 @@ pub async fn get_raw_postgres_connection(
}
let connector = build_tls_connector(ssl_mode, database.root_certificate_pem.as_ref())?;
let client = if let Some(connector) = connector {
let (client, connection) = config.connect(connector).await.map_err(to_anyhow)?;
tokio::spawn(async move {
tracing::info!("Successfully connected to PostgreSQL database for trigger execution");
if let Err(e) = connection.await {
tracing::debug!("{:#?}", e);
tracing::debug!("Error during PostgreSQL trigger connection: {:#?}", e);
};
tracing::info!("Successfully Connected into database");
tracing::info!("PostgreSQL trigger connection closed");
});
client
} else {
@@ -26,7 +26,6 @@ impl TriggerCrud for SqsTrigger {
const TABLE_NAME: &'static str = "";
const TRIGGER_TYPE: &'static str = "";
const SUPPORTS_ENABLED: bool = false;
const SUPPORTS_SERVER_STATE: bool = false;
const SUPPORTS_TEST_CONNECTION: bool = false;
const ROUTE_PREFIX: &'static str = "/sqs_triggers";
@@ -30,7 +30,6 @@ impl TriggerCrud for WebsocketTrigger {
const TABLE_NAME: &'static str = "websocket_trigger";
const TRIGGER_TYPE: &'static str = "websocket";
const SUPPORTS_ENABLED: bool = true;
const SUPPORTS_SERVER_STATE: bool = true;
const SUPPORTS_TEST_CONNECTION: bool = true;
const ROUTE_PREFIX: &'static str = "/websocket_triggers";
+4 -2
View File
@@ -69,6 +69,8 @@ struct WorkerPing {
memory_usage: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
wm_memory_usage: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
job_isolation: Option<String>,
}
// #[derive(Serialize, Deserialize)]
@@ -97,8 +99,8 @@ async fn list_worker_pings(
let rows = sqlx::query_as!(
WorkerPing,
"SELECT worker, worker_instance, EXTRACT(EPOCH FROM (now() - ping_at))::integer as last_ping, started_at, ip, jobs_executed,
CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,
custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage
CASE WHEN $4 IS TRUE THEN current_job_id ELSE NULL END as last_job_id, CASE WHEN $4 IS TRUE THEN current_job_workspace_id ELSE NULL END as last_job_workspace_id,
custom_tags, worker_group, wm_version, occupancy_rate, occupancy_rate_15s, occupancy_rate_5m, occupancy_rate_30m, memory, vcpus, memory_usage, wm_memory_usage, job_isolation
FROM worker_ping
WHERE ($1::integer IS NULL AND ping_at > now() - interval '5 minute') OR (ping_at > now() - ($1 || ' seconds')::interval)
ORDER BY ping_at desc LIMIT $2 OFFSET $3",
-32
View File
@@ -2455,38 +2455,6 @@ async fn create_workspace(
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by, edited_at) VALUES ($1, 'app_themes', 'App Themes', ARRAY[]::TEXT[], '{\"g/all\": false}', $2, now()) ON CONFLICT DO NOTHING",
nw.id,
username,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by, edited_at) VALUES ($1, 'app_custom', 'App Custom Components', ARRAY[]::TEXT[], '{\"g/all\": false}', $2, now()) ON CONFLICT DO NOTHING",
nw.id,
username,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by, edited_at) VALUES ($1, 'app_groups', 'App Groups', ARRAY[]::TEXT[], '{\"g/all\": false}', $2, now()) ON CONFLICT DO NOTHING",
nw.id,
username,
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO resource (workspace_id, path, value, description, resource_type, created_by, edited_at) VALUES ($1, 'f/app_themes/theme_0', '{\"name\": \"Default Theme\", \"value\": \"\"}', 'The default app theme', 'app_theme', $2, now()) ON CONFLICT DO NOTHING",
nw.id,
username,
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
@@ -448,6 +448,17 @@ pub(crate) async fn delete_workspace(
require_super_admin(&db, &authed.email).await?;
}
sqlx::query!("DELETE FROM ai_agent_memory WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM flow_conversation WHERE workspace_id = $1",
&w_id
)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM workspace_env WHERE workspace_id = $1", &w_id)
.execute(&mut *tx)
.await?;
+8 -4
View File
@@ -7,14 +7,14 @@ edition.workspace = true
[features]
default = []
enterprise = []
private = []
private = ["dep:aws-sdk-rds"]
jemalloc = ["dep:tikv-jemalloc-ctl"]
tantivy = []
prometheus = ["dep:prometheus"]
loki = ["dep:tracing-loki"]
benchmark = []
parquet = ["dep:object_store", "dep:aws-config", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion"]
aws_auth = ["dep:aws-sdk-sts", "dep:aws-config"]
parquet = ["dep:object_store", "dep:aws-sdk-sts", "dep:aws-smithy-types-convert", "dep:datafusion"]
aws_auth = ["dep:aws-sdk-sts"]
otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk",
"dep:opentelemetry", "dep:tracing-opentelemetry", "dep:opentelemetry-appender-tracing", "dep:tonic"]
smtp = ["dep:mail-send"]
@@ -62,12 +62,15 @@ tracing-loki = { version = "^0", optional = true }
magic-crypt.workspace = true
object_store = { workspace = true, optional = true }
prometheus = { workspace = true, optional = true }
aws-config = { workspace = true, optional = true }
aws-config.workspace = true
aws-sdk-sts = { workspace = true, optional = true }
aws-credential-types.workspace = true
aws-smithy-types.workspace = true
base64.workspace = true
bitflags.workspace = true
aws-smithy-types-convert = { workspace = true, optional = true }
aws-sdk-rds = { workspace = true, optional = true }
indexmap.workspace = true
bytes.workspace = true
mail-send = { workspace = true, optional = true }
@@ -86,6 +89,7 @@ openidconnect = { workspace = true, optional = true }
strum.workspace = true
strum_macros.workspace = true
url.workspace = true
urlencoding.workspace = true
async-recursion.workspace = true
semver.workspace = true
+17 -8
View File
@@ -10,7 +10,6 @@ lazy_static::lazy_static! {
static ref OPENAI_AZURE_BASE_PATH: Option<String> = std::env::var("OPENAI_AZURE_BASE_PATH").ok();
}
pub const AZURE_API_VERSION: &str = "2025-04-01-preview";
pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq, Hash, Clone)]
@@ -27,11 +26,18 @@ pub enum AIProvider {
OpenRouter,
TogetherAI,
CustomAI,
#[serde(rename = "aws_bedrock")]
AWSBedrock,
}
impl AIProvider {
/// Get the base URL for the AI provider
pub async fn get_base_url(&self, resource_base_url: Option<String>, db: &DB) -> Result<String> {
pub async fn get_base_url(
&self,
resource_base_url: Option<String>,
region: Option<String>,
db: &DB,
) -> Result<String> {
match self {
AIProvider::OpenAI => {
// Check for Azure base path override
@@ -74,6 +80,10 @@ impl AIProvider {
)))
}
}
AIProvider::AWSBedrock => Ok(format!(
"https://bedrock-runtime.{}.amazonaws.com",
region.unwrap_or_else(|| "us-east-1".to_string())
)),
}
}
@@ -89,13 +99,12 @@ impl AIProvider {
}
/// Build Azure OpenAI URL with deployment model path
pub fn build_azure_openai_url(base_url: &str, model: &str, path: &str) -> String {
pub fn build_azure_openai_url(base_url: &str, path: &str) -> String {
let base_url = base_url.trim_end_matches('/');
if base_url.ends_with("/deployments") {
format!("{}/{}/{}", base_url, model, path)
} else if base_url.ends_with("/openai") {
format!("{}/deployments/{}/{}", base_url, model, path)
if base_url.ends_with("/openai") {
format!("{}/v1/{}", base_url, path)
} else if base_url.ends_with("/deployments") {
format!("{}/v1/{}", base_url.trim_end_matches("/deployments"), path)
} else {
format!("{}/{}", base_url, path)
}
+15
View File
@@ -158,6 +158,21 @@ impl JWTAuthClaims {
.as_ref()
.is_some_and(|token_w_ids| token_w_ids.iter().any(|token_w_id| w_id == token_w_id))
}
pub fn compute_ext_jwt_hash(&self) -> i64 {
let mut hasher = DefaultHasher::new();
self.email.hash(&mut hasher);
self.username.hash(&mut hasher);
self.is_admin.hash(&mut hasher);
self.is_operator.hash(&mut hasher);
self.groups.hash(&mut hasher);
self.folders.hash(&mut hasher);
self.workspace_id.hash(&mut hasher);
self.workspace_ids.hash(&mut hasher);
self.label.hash(&mut hasher);
self.scopes.hash(&mut hasher);
hasher.finish() as i64
}
}
#[derive(Deserialize, Debug)]
+15
View File
@@ -285,6 +285,21 @@ pub struct FlowData {
pub flow: FlowValue,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FlowNotes {
pub notes: Option<Box<RawValue>>,
}
impl FlowData {
pub fn notes(&self) -> Option<FlowNotes> {
serde_json::from_str::<FlowNotes>(self.raw_flow.get())
.map_err(|e| {
tracing::error!("Failed to parse notes into FlowNotes: {}", e);
error::Error::internal_err(format!("Failed to parse notes into FlowNotes: {}", e))
})
.ok()
}
}
/// !!!Shouldn't be used. Reverted optimization for ai agent steps.!!!
#[derive(Deserialize)]
struct RevertedFlowNodeFlow {
+1 -1
View File
@@ -194,7 +194,7 @@ pub struct FlowValue {
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_input_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_env: Option<HashMap<String, Box<RawValue>>>
pub flow_env: Option<HashMap<String, Box<RawValue>>>,
}
impl FlowValue {
+169 -22
View File
@@ -35,6 +35,8 @@ pub mod bench;
pub mod cache;
pub mod client;
pub mod db;
#[cfg(all(feature = "enterprise", feature = "private"))]
mod db_iam_ee;
#[cfg(feature = "private")]
pub mod ee;
pub mod ee_oss;
@@ -341,7 +343,12 @@ pub fn parse_postgres_url(url: &str) -> Result<PostgresUrlComponents, Error> {
let scheme = parsed_url.scheme().to_string();
let username = parsed_url.username().to_string();
let username = urlencoding::decode(&username).map_err(to_anyhow)?.to_string();
let password = parsed_url.password().map(|p| p.to_string());
let password = match password {
Some(p) => Some(urlencoding::decode(&p).map_err(to_anyhow)?.to_string()),
None => None,
};
let host = parsed_url
.host_str()
.ok_or_else(|| Error::BadConfig("Missing host in PostgreSQL URL".to_string()))?
@@ -370,27 +377,120 @@ pub fn parse_postgres_url(url: &str) -> Result<PostgresUrlComponents, Error> {
})
}
pub async fn get_database_url() -> Result<String, Error> {
use std::env::var;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
match var("DATABASE_URL_FILE") {
Ok(file_path) => {
let mut file = File::open(file_path).await?;
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
Ok(contents.trim().to_string())
#[derive(Clone)]
pub enum DatabaseUrl {
#[cfg(all(feature = "enterprise", feature = "private"))]
IamRds(std::sync::Arc<tokio::sync::RwLock<db_iam_ee::IamRdsUrl>>),
Static(String),
}
impl DatabaseUrl {
pub async fn as_str(&self) -> String {
match self {
#[cfg(all(feature = "enterprise", feature = "private"))]
DatabaseUrl::IamRds(rds_url) => {
let guard = rds_url.read().await;
guard.as_str().to_string()
}
DatabaseUrl::Static(url) => url.clone(),
}
Err(_) => var("DATABASE_URL").map_err(|_| {
Error::BadConfig(
"Either DATABASE_URL_FILE or DATABASE_URL env var is missing".to_string(),
)
}),
}
pub async fn refresh(&self) -> anyhow::Result<()> {
match self {
#[cfg(all(feature = "enterprise", feature = "private"))]
DatabaseUrl::IamRds(rds_url) => rds_url.write().await.refresh().await,
DatabaseUrl::Static(_) => Ok(()),
}
}
}
static DATABASE_URL_CACHE: tokio::sync::OnceCell<DatabaseUrl> =
tokio::sync::OnceCell::const_new();
pub async fn get_database_url() -> Result<DatabaseUrl, Error> {
let database_url = DATABASE_URL_CACHE
.get_or_try_init(|| async {
use std::env::var;
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let url = match var("DATABASE_URL_FILE") {
Ok(file_path) => {
let mut file = File::open(file_path).await?;
let mut contents = String::new();
file.read_to_string(&mut contents).await?;
Ok(contents.trim().to_string())
}
Err(_) => var("DATABASE_URL").map_err(|_| {
Error::BadConfig(
"Either DATABASE_URL_FILE or DATABASE_URL env var is missing".to_string(),
)
}),
}?;
let parsed_url = url::Url::parse(&url)?;
if parsed_url.password().is_some_and(|x| x == "iamrds") {
let region = var("AWS_REGION").map_err(|_| {
Error::BadConfig(
"AWS_REGION env var is required for IAM RDS authentication".to_string(),
)
})?;
tracing::info!("iamrds mode detected, generating IAM RDS URL for region: {region}");
#[cfg(all(feature = "enterprise", feature = "private"))]
{
let rds_url = db_iam_ee::generate_database_url(&url, &region)
.await
.map_err(|e| {
Error::InternalErr(format!("Failed to generate IAM database URL: {}", e))
})?;
tracing::info!("IAM RDS URL generated successfully");
Ok::<DatabaseUrl, Error>(DatabaseUrl::IamRds(
std::sync::Arc::new(tokio::sync::RwLock::new(rds_url))
))
}
#[cfg(not(all(feature = "enterprise", feature = "private")))]
{
return Err(Error::BadConfig("IAM RDS authentication is not enabled in OSS mode".to_string()));
}
} else {
Ok::<DatabaseUrl, Error>(DatabaseUrl::Static(url.to_string()))
}
})
.await?;
// Check if we need to refresh and do so if necessary
#[cfg(all(feature = "enterprise", feature = "private"))]
if let DatabaseUrl::IamRds(ref rds_url_lock) = database_url {
// Check if refresh is needed
let needs_refresh = {
let read_guard = rds_url_lock.read().await;
read_guard.needs_refresh()
};
// If refresh is needed, acquire write lock and refresh
if needs_refresh {
let mut write_guard = rds_url_lock.write().await;
// Double-check after acquiring write lock (another task might have refreshed)
if write_guard.needs_refresh() {
write_guard.refresh().await.map_err(|e| {
Error::InternalErr(format!("Failed to refresh IAM token: {}", e))
})?;
}
}
}
// Return the URL string
Ok(database_url.clone())
}
pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
let database_url = get_database_url().await?;
let database_url = get_database_url().await?.as_str().await;
sqlx::postgres::PgPoolOptions::new()
.max_connections(2)
.connect_with(sqlx::postgres::PgConnectOptions::from_str(&database_url)?)
@@ -402,6 +502,8 @@ pub async fn connect_db(
server_mode: bool,
indexer_mode: bool,
worker_mode: bool,
#[cfg(feature = "private")]
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
use anyhow::Context;
@@ -426,11 +528,58 @@ pub async fn connect_db(
}
};
Ok(connect(&database_url, max_connections, worker_mode).await?)
let pool = connect(database_url.clone(), max_connections, worker_mode).await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
let pool2 = pool.clone();
#[cfg(all(feature = "enterprise", feature = "private"))]
if let DatabaseUrl::IamRds(database_url) = database_url {
tokio::spawn(async move {
loop {
tokio::select! {
_ = killpill_rx.recv() => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
let needs_refresh = {
let read_guard = database_url.read().await;
read_guard.needs_refresh()
};
if needs_refresh {
let new_url = tokio::time::timeout(std::time::Duration::from_secs(10), get_database_url()).await;
match new_url {
Ok(Ok(new_url)) => {
let new_url = new_url.as_str().await;
let connect_options = sqlx::postgres::PgConnectOptions::from_str(&new_url);
if let Err(e) = connect_options {
tracing::error!("Error parsing IAM RDS URL as connect options, retrying in 10s: {}", e);
continue;
}
pool2.set_connect_options(connect_options.unwrap());
tracing::info!("Refreshed IAM RDS URL successfully");
}
Ok(Err(e)) => {
tracing::error!("Error refreshing IAM RDS URL, trying again in 10s: {}", e);
continue;
}
Err(e) => {
tracing::error!("Timeout after 10s refreshing IAM RDS URL, trying again in 10 seconds: {}", e);
continue;
}
}
}
}
}
}
});
}
Ok(pool)
}
pub async fn connect(
database_url: &str,
database_url: DatabaseUrl,
max_connections: u32,
worker_mode: bool,
) -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
@@ -479,7 +628,7 @@ pub async fn connect(
}
})
.connect_with(
sqlx::postgres::PgConnectOptions::from_str(database_url)?.statement_cache_capacity(400),
sqlx::postgres::PgConnectOptions::from_str(&database_url.as_str().await)?.statement_cache_capacity(400),
)
.await
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
@@ -490,9 +639,7 @@ type Tag = String;
pub use db::DB;
use crate::{
auth::{PermsCache, FLOW_PERMS_CACHE, HASH_PERMS_CACHE},
db::{AuthedRef, UserDbWithAuthed},
scripts::ScriptHash,
auth::{FLOW_PERMS_CACHE, HASH_PERMS_CACHE, PermsCache}, db::{AuthedRef, UserDbWithAuthed}, error::to_anyhow, scripts::ScriptHash
};
#[derive(Clone)]
+23 -2
View File
@@ -396,6 +396,21 @@ pub struct S3Resource {
pub port: Option<u16>,
}
impl S3Resource {
pub fn endpoint_with_region_fallback(&self, region_fallback: Option<String>) -> String {
if self.endpoint.is_empty() {
let final_region = if self.region.is_empty() {
region_fallback.unwrap_or_else(|| "us-east-1".to_string())
} else {
self.region.clone()
};
format!("s3.{}.amazonaws.com", final_region)
} else {
self.endpoint.clone()
}
}
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AzureBlobResource {
pub endpoint: Option<String>,
@@ -642,7 +657,7 @@ pub async fn build_s3_client(s3_resource_ref: &S3Resource) -> error::Result<Arc<
let s3_resource = s3_resource_ref.clone();
let endpoint = render_endpoint(
s3_resource.endpoint,
s3_resource.endpoint_with_region_fallback(None),
s3_resource.use_ssl,
s3_resource.port,
s3_resource.path_style,
@@ -1216,7 +1231,13 @@ pub fn duckdb_connection_settings_internal(
duckdb_settings.push_str("SET s3_url_style='path';\n");
}
duckdb_settings.push_str(format!("SET s3_region='{}';\n", s3_resource.region).as_str());
duckdb_settings.push_str(format!("SET s3_endpoint='{}';\n", s3_resource.endpoint).as_str());
duckdb_settings.push_str(
format!(
"SET s3_endpoint='{}';\n",
s3_resource.endpoint_with_region_fallback(None)
)
.as_str(),
);
if !s3_resource.use_ssl {
duckdb_settings.push_str("SET s3_use_ssl=0;\n"); // default is true for DuckDB
}
+6
View File
@@ -947,3 +947,9 @@ pub struct ExpiringCacheEntry<T> {
pub value: T,
pub expiry: std::time::Instant,
}
impl<T> ExpiringCacheEntry<T> {
pub fn is_expired(&self) -> bool {
self.expiry < std::time::Instant::now()
}
}
+10 -3
View File
@@ -1301,6 +1301,7 @@ pub struct Ping {
pub occupancy_rate_15s: Option<f32>,
pub occupancy_rate_5m: Option<f32>,
pub occupancy_rate_30m: Option<f32>,
pub job_isolation: Option<String>,
pub ping_type: PingType,
}
pub async fn update_ping_http(
@@ -1348,6 +1349,7 @@ pub async fn update_ping_http(
&insert_ping.version.unwrap(),
insert_ping.vcpus,
insert_ping.memory,
insert_ping.job_isolation,
db,
)
.await?;
@@ -1363,6 +1365,7 @@ pub async fn update_ping_http(
insert_ping.occupancy_rate_15s,
insert_ping.occupancy_rate_5m,
insert_ping.occupancy_rate_30m,
insert_ping.job_isolation,
db,
)
.await?;
@@ -1476,10 +1479,11 @@ pub async fn insert_ping_query(
version: &str,
vcpus: Option<i64>,
memory: Option<i64>,
job_isolation: Option<String>,
db: &DB,
) -> anyhow::Result<()> {
sqlx::query!(
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, wm_version, vcpus, memory) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (worker)
"INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, wm_version, vcpus, memory, job_isolation) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (worker)
DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group",
worker_instance,
worker_name,
@@ -1489,7 +1493,8 @@ pub async fn insert_ping_query(
dw,
version,
vcpus,
memory
memory,
job_isolation.as_deref()
)
.execute(db)
.await?;
@@ -1506,11 +1511,12 @@ pub async fn update_worker_ping_from_job_query(
occupancy_rate_15s: Option<f32>,
occupancy_rate_5m: Option<f32>,
occupancy_rate_30m: Option<f32>,
job_isolation: Option<String>,
db: &DB,
) -> anyhow::Result<()> {
sqlx::query!(
"UPDATE worker_ping SET ping_at = now(), current_job_id = $1, current_job_workspace_id = $2, memory_usage = $3, wm_memory_usage = $4,
occupancy_rate = $6, occupancy_rate_15s = $7, occupancy_rate_5m = $8, occupancy_rate_30m = $9 WHERE worker = $5",
occupancy_rate = $6, occupancy_rate_15s = $7, occupancy_rate_5m = $8, occupancy_rate_30m = $9, job_isolation = $10 WHERE worker = $5",
job_id,
w_id,
memory_usage,
@@ -1520,6 +1526,7 @@ pub async fn update_worker_ping_from_job_query(
occupancy_rate_15s,
occupancy_rate_5m,
occupancy_rate_30m,
job_isolation,
)
.execute(db)
.await?;
+1 -1
View File
@@ -208,7 +208,7 @@ pub async fn get_ducklake_from_db_unchecked(
let catalog_resource =
if ducklake.catalog.resource_type == DucklakeCatalogResourceType::Instance {
let pg_creds = parse_postgres_url(&get_database_url().await?)?;
let pg_creds = parse_postgres_url(&get_database_url().await?.as_str().await)?;
json!({
"dbname": ducklake.catalog.resource_path,
"host": pg_creds.host,
+4 -4
View File
@@ -415,8 +415,8 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "duckdb"
version = "1.4.1"
source = "git+https://github.com/diegoimbert/duckdb-rs?branch=main#0df52a6941c9996d7ec60d585eaf6430db8c48cf"
version = "1.4.2"
source = "git+https://github.com/windmill-labs/duckdb-rs.git?rev=fe0702529de6ec5a568337726bba9355503157d2#fe0702529de6ec5a568337726bba9355503157d2"
dependencies = [
"arrow",
"cast",
@@ -702,8 +702,8 @@ checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "libduckdb-sys"
version = "1.4.1"
source = "git+https://github.com/diegoimbert/duckdb-rs?branch=main#0df52a6941c9996d7ec60d585eaf6430db8c48cf"
version = "1.4.2"
source = "git+https://github.com/windmill-labs/duckdb-rs.git?rev=fe0702529de6ec5a568337726bba9355503157d2#fe0702529de6ec5a568337726bba9355503157d2"
dependencies = [
"cc",
"flate2",
@@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
chrono = "0.4.41"
duckdb = { git = "https://github.com/diegoimbert/duckdb-rs", branch = "main", features = ["bundled"] }
duckdb = { rev = "fe0702529de6ec5a568337726bba9355503157d2", git = "https://github.com/windmill-labs/duckdb-rs.git", features = ["bundled"] }
rust_decimal = "1.37.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
@@ -1,2 +1,2 @@
cargo build --release -p windmill_duckdb_ffi_internal
CARGO_NET_GIT_FETCH_WITH_CLI=true cargo build --release -p windmill_duckdb_ffi_internal
cp target/release/libwindmill_duckdb_ffi_internal.* ../target/debug/
@@ -265,13 +265,22 @@ fn do_duckdb_inner(
None => {
type_aliases = Some(
(0..stmt.column_count())
.map(|i| stmt.column_logical_type(i).get_alias())
.map(|i| {
let logical_type = stmt.column_logical_type(i);
if logical_type.is_invalid() {
None
} else {
logical_type.get_alias()
}
})
.collect::<Vec<_>>(),
);
type_aliases.as_ref().unwrap()
}
};
// let type_aliases = (0..stmt.column_count()).map(|_| None).collect::<Vec<_>>();
let row = row_to_value(row, &column_names.as_slice(), &type_aliases.as_slice())
.map_err(|e| e.to_string())?;
rows_vec.push(row);
+2
View File
@@ -47,3 +47,5 @@ regex.workspace = true
backon.workspace = true
quick_cache.workspace = true
thiserror.workspace = true
dashmap.workspace = true
once_cell.workspace = true
+203
View File
@@ -0,0 +1,203 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2022
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use chrono::Datelike;
use dashmap::DashMap;
use sqlx::{Pool, Postgres};
use std::sync::Arc;
use tokio::sync::Notify;
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
struct UsageKey {
id: String,
is_workspace: bool,
month: i32,
}
pub struct UsageBuffer {
buffer: Arc<DashMap<UsageKey, i32>>,
db: Pool<Postgres>,
shutdown_notify: Arc<Notify>,
}
impl UsageBuffer {
pub fn new(db: Pool<Postgres>) -> Arc<Self> {
let buffer = Arc::new(Self {
buffer: Arc::new(DashMap::new()),
db,
shutdown_notify: Arc::new(Notify::new()),
});
// Spawn the periodic flush task
let buffer_clone = buffer.clone();
tokio::spawn(async move {
buffer_clone.flush_loop().await;
});
buffer
}
pub fn increment(&self, workspace_id: String, email: Option<String>) {
let month = Self::current_month();
// Increment workspace usage
self.buffer
.entry(UsageKey { id: workspace_id, is_workspace: true, month })
.and_modify(|counter| *counter += 1)
.or_insert(1);
// Increment user usage if email is provided
if let Some(email) = email {
self.buffer
.entry(UsageKey { id: email, is_workspace: false, month })
.and_modify(|counter| *counter += 1)
.or_insert(1);
}
}
async fn flush_loop(&self) {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
tokio::select! {
_ = interval.tick() => {
self.flush().await;
}
_ = self.shutdown_notify.notified() => {
// Final flush on shutdown
self.flush().await;
break;
}
}
}
}
async fn flush(&self) {
if self.buffer.is_empty() {
return;
}
// Drain all buffered usage counts
let mut to_flush = Vec::new();
self.buffer.retain(|key, value| {
to_flush.push((key.clone(), *value));
false
});
if to_flush.is_empty() {
return;
}
tracing::debug!(
"Flushing {} buffered usage entries to database",
to_flush.len()
);
// Batch update to database
for (key, count) in to_flush {
let result = tokio::time::timeout(
std::time::Duration::from_secs(10),
sqlx::query!(
"INSERT INTO usage (id, is_workspace, month_, usage)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + $4",
&key.id,
key.is_workspace,
key.month,
count
)
.execute(&self.db),
)
.await;
match result {
Ok(Ok(_)) => {}
Ok(Err(e)) => {
tracing::error!(
"Failed to flush usage for {} (is_workspace: {}): {:#}",
key.id,
key.is_workspace,
e
);
}
Err(_) => {
tracing::error!(
"Usage flush timed out for {} (is_workspace: {})",
key.id,
key.is_workspace
);
}
}
}
}
fn current_month() -> i32 {
let now = chrono::Utc::now();
(now.year() * 12 + now.month() as i32) as i32
}
}
lazy_static::lazy_static! {
static ref USAGE_BUFFER: once_cell::sync::OnceCell<Arc<UsageBuffer>> = once_cell::sync::OnceCell::new();
}
pub fn init_usage_buffer(db: Pool<Postgres>) {
USAGE_BUFFER.get_or_init(|| UsageBuffer::new(db));
}
pub fn increment_usage_async(db: Pool<Postgres>, workspace_id: String, email: Option<String>) {
if let Some(buffer) = USAGE_BUFFER.get() {
buffer.increment(workspace_id, email);
} else {
tracing::warn!("Usage buffer not initialized, falling back to direct database update");
// Fallback to old implementation if buffer not initialized
tokio::task::spawn(async move {
let result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
// Update workspace usage
let workspace_result = sqlx::query!(
"INSERT INTO usage (id, is_workspace, month_, usage)
VALUES ($1, TRUE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1)
ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1",
&workspace_id
)
.execute(&db)
.await;
if let Err(e) = workspace_result {
tracing::error!("Failed to update workspace usage for {}: {:#}", workspace_id, e);
}
// Update user usage if email is provided (non-premium workspaces only)
if let Some(ref email) = email {
let user_result = sqlx::query!(
"INSERT INTO usage (id, is_workspace, month_, usage)
VALUES ($1, FALSE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1)
ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1",
email
)
.execute(&db)
.await;
if let Err(e) = user_result {
tracing::error!("Failed to update user usage for {}: {:#}", email, e);
}
}
})
.await;
if let Err(_) = result {
tracing::error!(
"Usage update timed out after 10s for workspace {} and email {:?}",
workspace_id,
email
);
}
});
}
}
+1 -44
View File
@@ -3809,50 +3809,7 @@ async fn check_usage_limits(
}
#[cfg(feature = "cloud")]
fn increment_usage_async(db: Pool<Postgres>, workspace_id: String, email: Option<String>) {
tokio::task::spawn(async move {
let result = tokio::time::timeout(std::time::Duration::from_secs(10), async {
// Update workspace usage
let workspace_result = sqlx::query!(
"INSERT INTO usage (id, is_workspace, month_, usage)
VALUES ($1, TRUE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1)
ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1",
&workspace_id
)
.execute(&db)
.await;
if let Err(e) = workspace_result {
tracing::error!("Failed to update workspace usage for {}: {:#}", workspace_id, e);
}
// Update user usage if email is provided (non-premium workspaces only)
if let Some(ref email) = email {
let user_result = sqlx::query!(
"INSERT INTO usage (id, is_workspace, month_, usage)
VALUES ($1, FALSE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), 1)
ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + 1",
email
)
.execute(&db)
.await;
if let Err(e) = user_result {
tracing::error!("Failed to update user usage for {}: {:#}", email, e);
}
}
})
.await;
if let Err(_) = result {
tracing::error!(
"Usage update timed out after 10s for workspace {} and email {:?}",
workspace_id,
email
);
}
});
}
use crate::cloud_usage::increment_usage_async;
// #[instrument(level = "trace", skip_all)]
pub async fn push<'c, 'd>(
+5
View File
@@ -14,3 +14,8 @@ pub mod schedule;
pub use jobs::*;
pub mod flow_status;
pub mod tags;
#[cfg(feature = "cloud")]
pub mod cloud_usage;
#[cfg(feature = "cloud")]
pub use cloud_usage::init_usage_buffer;
+4
View File
@@ -58,6 +58,10 @@ windmill-parser-graphql.workspace = true
windmill-parser-php = { workspace = true, optional = true }
windmill-git-sync.workspace = true
rmcp = { version = "0.8.1", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
aws-sdk-bedrockruntime.workspace = true
aws-config.workspace = true
aws-credential-types.workspace = true
aws-smithy-types.workspace = true
flume.workspace = true
sqlx.workspace = true
uuid.workspace = true
@@ -4,6 +4,8 @@ use ulid;
use windmill_common::{client::AuthedClient, error::Error, s3_helpers::S3Object};
use windmill_queue::MiniPulledJob;
use crate::ai::types::*;
/// Upload image to S3 and return S3Object
pub async fn upload_image_to_s3(
base64_image: &str,
@@ -66,3 +68,53 @@ pub async fn download_and_encode_s3_image(
Ok((mime_type.to_string(), base64_data))
}
/// Prepare messages for API by converting S3Objects to base64 ImageUrls
pub async fn prepare_messages_for_api(
messages: &[OpenAIMessage],
client: &AuthedClient,
workspace_id: &str,
) -> Result<Vec<OpenAIMessage>, Error> {
let mut prepared_messages = Vec::new();
for message in messages {
let mut prepared_message = message.clone();
if let Some(content) = &message.content {
match content {
OpenAIContent::Text(text) => {
prepared_message.content = Some(OpenAIContent::Text(text.clone()));
}
OpenAIContent::Parts(parts) => {
let mut prepared_content = Vec::new();
for part in parts {
match part {
ContentPart::S3Object { s3_object } => {
// Convert S3Object to base64 image URL
let (mime_type, image_bytes) =
download_and_encode_s3_image(s3_object, client, workspace_id)
.await?;
prepared_content.push(ContentPart::ImageUrl {
image_url: ImageUrlData {
url: format!("data:{};base64,{}", mime_type, image_bytes),
},
});
}
other => {
// Keep Text and ImageUrl as-is
prepared_content.push(other.clone());
}
}
}
prepared_message.content = Some(OpenAIContent::Parts(prepared_content));
}
}
}
prepared_messages.push(prepared_message);
}
Ok(prepared_messages)
}
@@ -0,0 +1,804 @@
use crate::ai::{
image_handler::prepare_messages_for_api,
providers::openai::{OpenAIFunction, OpenAIToolCall},
query_builder::{ParsedResponse, StreamEventProcessor},
types::StreamingEvent,
types::{ContentPart, OpenAIContent, OpenAIMessage, ToolDef},
};
use aws_config::BehaviorVersion;
use aws_credential_types::provider::token::ProvideToken;
use aws_sdk_bedrockruntime::types::{
ContentBlock, ConversationRole, ConverseStreamOutput, ImageBlock, ImageFormat, ImageSource,
InferenceConfiguration, Message, SystemContentBlock, Tool, ToolInputSchema, ToolSpecification,
};
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
use std::collections::HashMap;
use windmill_common::{client::AuthedClient, error::Error};
/// Constants for commonly used strings to avoid allocations
const FUNCTION_TYPE: &str = "function";
const EMPTY_JSON: &str = "{}";
#[derive(Debug, Clone)]
pub struct BearerTokenProvider {
token: String,
}
impl BearerTokenProvider {
pub fn new(token: String) -> Self {
Self { token }
}
}
impl ProvideToken for BearerTokenProvider {
fn provide_token<'a>(&'a self) -> aws_credential_types::provider::future::ProvideToken<'a>
where
Self: 'a,
{
aws_credential_types::provider::future::ProvideToken::ready(Ok(
aws_credential_types::Token::new(self.token.clone(), None),
))
}
}
pub struct BedrockClient {
client: BedrockRuntimeClient,
}
impl BedrockClient {
pub async fn from_bearer_token(bearer_token: String, region: &str) -> Result<Self, Error> {
let config = aws_sdk_bedrockruntime::config::Builder::new()
.region(aws_config::Region::new(region.to_string()))
.behavior_version(BehaviorVersion::latest())
.token_provider(BearerTokenProvider::new(bearer_token))
.build();
Ok(Self { client: BedrockRuntimeClient::from_conf(config) })
}
pub fn client(&self) -> &BedrockRuntimeClient {
&self.client
}
}
/// Format AWS SDK errors with detailed information
fn format_bedrock_error<E, R>(error: &aws_sdk_bedrockruntime::error::SdkError<E, R>) -> String
where
E: std::fmt::Debug + std::fmt::Display,
R: std::fmt::Debug,
{
use aws_sdk_bedrockruntime::error::SdkError;
match error {
SdkError::ServiceError(err) => {
// Include both the display and debug representations for maximum detail
format!("Service error: {} (details: {:?})", err.err(), err)
}
SdkError::ConstructionFailure(err) => {
format!("Request construction failed: {:?}", err)
}
SdkError::DispatchFailure(err) => {
format!("Request dispatch failed: {:?}", err)
}
SdkError::ResponseError(err) => {
format!("Response error: {:?}", err)
}
SdkError::TimeoutError(err) => {
format!("Request timeout: {:?}", err)
}
_ => format!("{:?}", error),
}
}
/// Convert AWS Smithy Document to serde_json::Value
fn document_to_json(doc: &aws_smithy_types::Document) -> serde_json::Value {
use aws_smithy_types::Document;
match doc {
Document::Object(map) => {
let mut obj = serde_json::Map::new();
for (k, v) in map {
obj.insert(k.clone(), document_to_json(v));
}
serde_json::Value::Object(obj)
}
Document::Array(arr) => {
serde_json::Value::Array(arr.iter().map(document_to_json).collect())
}
Document::Number(num) => {
// Try to parse as different number types
serde_json::Value::Number(
serde_json::Number::from_f64(num.to_f64_lossy())
.unwrap_or(serde_json::Number::from(0)),
)
}
Document::String(s) => serde_json::Value::String(s.clone()),
Document::Bool(b) => serde_json::Value::Bool(*b),
Document::Null => serde_json::Value::Null,
}
}
/// Convert serde_json::Value to AWS Smithy Document
fn json_to_document(value: serde_json::Value) -> aws_smithy_types::Document {
use aws_smithy_types::Document;
use serde_json::Value;
match value {
Value::Object(map) => {
let mut doc_map = std::collections::HashMap::new();
for (k, v) in map {
doc_map.insert(k, json_to_document(v));
}
Document::Object(doc_map)
}
Value::Array(arr) => Document::Array(arr.into_iter().map(json_to_document).collect()),
Value::Number(num) => {
if let Some(i) = num.as_i64() {
Document::Number(aws_smithy_types::Number::PosInt(i as u64))
} else if let Some(f) = num.as_f64() {
Document::Number(aws_smithy_types::Number::Float(f))
} else {
Document::Number(aws_smithy_types::Number::PosInt(0))
}
}
Value::String(s) => Document::String(s),
Value::Bool(b) => Document::Bool(b),
Value::Null => Document::Null,
}
}
/// Convert OpenAI-style messages to Bedrock format
///
/// Separates system messages from conversation messages as required by Bedrock API.
///
/// # Returns
/// Tuple of (conversation_messages, system_prompts)
pub fn openai_messages_to_bedrock(
messages: &[OpenAIMessage],
) -> Result<(Vec<Message>, Vec<SystemContentBlock>), Error> {
let mut bedrock_messages = Vec::new();
let mut system_prompts = Vec::new();
for msg in messages {
match msg.role.as_str() {
"system" => {
// Extract system messages separately
if let Some(ref content) = msg.content {
let text = content_to_text(content);
if !text.is_empty() {
system_prompts.push(SystemContentBlock::Text(text));
}
}
}
"user" | "assistant" => {
bedrock_messages.push(convert_message(msg)?);
}
"tool" => {
// Tool results are handled as user messages with ToolResult content
bedrock_messages.push(convert_tool_message(msg)?);
}
_ => {
return Err(Error::BadRequest(format!("Unsupported role: {}", msg.role)));
}
}
}
Ok((bedrock_messages, system_prompts))
}
/// Helper to extract text from OpenAIContent (ignoring images)
fn content_to_text(content: &OpenAIContent) -> String {
match content {
OpenAIContent::Text(text) => text.to_string(),
OpenAIContent::Parts(parts) => {
// Extract only text parts and join them
let text_parts: Vec<&str> = parts
.iter()
.filter_map(|part| match part {
ContentPart::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
text_parts.join(" ")
}
}
}
/// Parse image data URL and extract format and base64 data
fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec<u8>), Error> {
if !url.starts_with("data:") {
return Err(Error::internal_err("Image URL must be a data URL"));
}
// Parse data:image/png;base64,<data>
let base64_start = url
.find("base64,")
.ok_or_else(|| Error::internal_err("Invalid data URL format"))?;
let base64_data = &url[base64_start + 7..];
let mime_type = url
.split(';')
.next()
.and_then(|s| s.strip_prefix("data:"))
.unwrap_or("image/png");
// Extract format from MIME type (e.g., "image/png" -> "png")
let format_str = mime_type
.rsplit_once('/')
.map(|(_, format)| format)
.unwrap_or("png");
// Map to ImageFormat enum
let format = match format_str {
"png" => ImageFormat::Png,
"jpeg" | "jpg" => ImageFormat::Jpeg,
"gif" => ImageFormat::Gif,
"webp" => ImageFormat::Webp,
_ => ImageFormat::Png, // Default to PNG
};
// Decode base64
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
.map_err(|e| Error::internal_err(format!("Failed to decode base64 image: {}", e)))?;
Ok((format, bytes))
}
/// Convert a ContentPart to Bedrock ContentBlock
fn content_part_to_block(part: &ContentPart) -> Result<Option<ContentBlock>, Error> {
match part {
ContentPart::Text { text } => {
if text.is_empty() {
Ok(None)
} else {
Ok(Some(ContentBlock::Text(text.clone())))
}
}
ContentPart::ImageUrl { image_url } => {
let (format, bytes) = parse_image_data_url(&image_url.url)?;
let image_source = ImageSource::Bytes(bytes.into());
let image_block = ImageBlock::builder()
.format(format)
.source(image_source)
.build()
.map_err(|e| Error::internal_err(format!("Failed to build image block: {}", e)))?;
Ok(Some(ContentBlock::Image(image_block)))
}
ContentPart::S3Object { .. } => {
// S3Objects are already converted to ImageUrl by prepare_messages_for_api
// If we somehow get here, skip it
Ok(None)
}
}
}
/// Convert a single OpenAI message to Bedrock Message
fn convert_message(msg: &OpenAIMessage) -> Result<Message, Error> {
let role = match msg.role.as_str() {
"user" => ConversationRole::User,
"assistant" => ConversationRole::Assistant,
_ => {
return Err(Error::internal_err(format!(
"Unsupported role: {}",
msg.role
)));
}
};
let mut content_blocks = Vec::new();
// Handle content (text and/or images)
if let Some(ref content) = msg.content {
match content {
OpenAIContent::Text(text) => {
if !text.is_empty() {
content_blocks.push(ContentBlock::Text(text.clone()));
}
}
OpenAIContent::Parts(parts) => {
for part in parts {
if let Some(block) = content_part_to_block(part)? {
content_blocks.push(block);
}
}
}
}
}
// Handle tool calls (for assistant messages)
if let Some(ref tool_calls) = msg.tool_calls {
for tc in tool_calls {
content_blocks.push(convert_tool_call_to_content(tc)?);
}
}
// Bedrock requires at least one content block
if content_blocks.is_empty() {
content_blocks.push(ContentBlock::Text(String::new()));
}
Message::builder()
.role(role)
.set_content(Some(content_blocks))
.build()
.map_err(|e| Error::internal_err(format!("Failed to build message: {}", e)))
}
/// Convert OpenAI tool call to Bedrock ToolUse content block
fn convert_tool_call_to_content(tool_call: &OpenAIToolCall) -> Result<ContentBlock, Error> {
let input = json_to_document(
serde_json::from_str(&tool_call.function.arguments)
.unwrap_or_else(|_| serde_json::json!({})),
);
Ok(ContentBlock::ToolUse(
aws_sdk_bedrockruntime::types::ToolUseBlock::builder()
.tool_use_id(&tool_call.id)
.name(&tool_call.function.name)
.input(input)
.build()
.map_err(|e| Error::internal_err(format!("Failed to build tool use: {}", e)))?,
))
}
/// Convert tool result message to Bedrock format
fn convert_tool_message(msg: &OpenAIMessage) -> Result<Message, Error> {
let tool_call_id = msg
.tool_call_id
.as_ref()
.ok_or_else(|| Error::internal_err("Tool message missing tool_call_id"))?;
let content_str = msg
.content
.as_ref()
.map(|c| content_to_text(c))
.unwrap_or_default();
// Try to parse as JSON, otherwise use text
let tool_result_content =
if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&content_str) {
if json_val.is_object() {
vec![aws_sdk_bedrockruntime::types::ToolResultContentBlock::Json(
json_to_document(json_val),
)]
} else {
// Wrap primitives and arrays in an object
vec![aws_sdk_bedrockruntime::types::ToolResultContentBlock::Json(
json_to_document(serde_json::json!({"result": json_val})),
)]
}
} else {
vec![aws_sdk_bedrockruntime::types::ToolResultContentBlock::Text(
content_str.to_string(),
)]
};
let tool_result = ContentBlock::ToolResult(
aws_sdk_bedrockruntime::types::ToolResultBlock::builder()
.tool_use_id(tool_call_id)
.set_content(Some(tool_result_content))
.build()
.map_err(|e| Error::internal_err(format!("Failed to build tool result: {}", e)))?,
);
Message::builder()
.role(ConversationRole::User)
.content(tool_result)
.build()
.map_err(|e| Error::internal_err(format!("Failed to build tool result message: {}", e)))
}
/// Convert OpenAI tool definitions to Bedrock format
pub fn openai_tools_to_bedrock(tools: &[ToolDef]) -> Result<Vec<Tool>, Error> {
tools
.iter()
.map(|tool_def| {
let spec = &tool_def.function;
// Convert parameters (RawValue) to Document via serde_json::Value
let param_value: serde_json::Value = serde_json::from_str(spec.parameters.get())
.map_err(|e| Error::internal_err(format!("Invalid tool schema: {}", e)))?;
let input_schema = ToolInputSchema::Json(json_to_document(param_value));
let tool_spec = ToolSpecification::builder()
.name(&spec.name)
.set_description(spec.description.clone())
.input_schema(input_schema)
.build()
.map_err(|e| Error::internal_err(format!("Failed to build tool spec: {}", e)))?;
Ok(Tool::ToolSpec(tool_spec))
})
.collect()
}
/// Create inference configuration from parameters
pub fn create_inference_config(
temperature: Option<f32>,
max_tokens: Option<i32>,
) -> Option<InferenceConfiguration> {
if temperature.is_none() && max_tokens.is_none() {
return None;
}
let mut builder = InferenceConfiguration::builder();
if let Some(temp) = temperature {
builder = builder.temperature(temp);
}
if let Some(max_tok) = max_tokens {
builder = builder.max_tokens(max_tok);
}
Some(builder.build())
}
/// Extract text content and tool calls from Bedrock Converse response
pub fn bedrock_response_to_openai(
output: &aws_sdk_bedrockruntime::operation::converse::ConverseOutput,
) -> Result<(Option<String>, Vec<OpenAIToolCall>), Error> {
let mut text_content = String::new();
let mut tool_calls = Vec::new();
if let Some(message) = output.output().and_then(|o| o.as_message().ok()) {
let content_blocks = message.content();
if !content_blocks.is_empty() {
for block in content_blocks {
match block {
ContentBlock::Text(text) => {
text_content.push_str(&text);
}
ContentBlock::ToolUse(tool_use) => {
// Convert to OpenAI tool call format
// Convert aws_smithy_types::Document to serde_json::Value
let input_value = document_to_json(tool_use.input());
let arguments = serde_json::to_string(&input_value)
.unwrap_or_else(|_| EMPTY_JSON.to_string());
tool_calls.push(OpenAIToolCall {
id: tool_use.tool_use_id().to_string(),
r#type: FUNCTION_TYPE.to_string(),
function: OpenAIFunction {
name: tool_use.name().to_string(),
arguments,
},
});
}
_ => {}
}
}
}
}
let content = if text_content.is_empty() {
None
} else {
Some(text_content)
};
Ok((content, tool_calls))
}
/// Extract text delta from Bedrock stream event
pub fn bedrock_stream_event_to_text(event: &ConverseStreamOutput) -> Option<String> {
match event {
ConverseStreamOutput::ContentBlockDelta(delta) => delta
.delta()
.and_then(|d| d.as_text().ok())
.map(|s| s.to_string()),
_ => None,
}
}
/// Represents a streaming tool call being accumulated
#[derive(Debug, Clone)]
pub struct StreamingToolCall {
pub id: String,
pub name: String,
pub arguments: String,
}
/// Extract tool use start event from stream
pub fn bedrock_stream_event_to_tool_start(
event: &ConverseStreamOutput,
) -> Option<StreamingToolCall> {
match event {
ConverseStreamOutput::ContentBlockStart(start) => {
if let Some(tool_use) = start.start().and_then(|s| s.as_tool_use().ok()) {
Some(StreamingToolCall {
id: tool_use.tool_use_id().to_string(),
name: tool_use.name().to_string(),
arguments: String::new(),
})
} else {
None
}
}
_ => None,
}
}
/// Extract tool use input delta from stream
pub fn bedrock_stream_event_to_tool_delta(event: &ConverseStreamOutput) -> Option<String> {
match event {
ConverseStreamOutput::ContentBlockDelta(delta) => delta
.delta()
.and_then(|d| d.as_tool_use().ok())
.map(|tool_use| tool_use.input().to_string()),
_ => None,
}
}
/// Check if stream event indicates content block stop
pub fn bedrock_stream_event_is_block_stop(event: &ConverseStreamOutput) -> bool {
matches!(event, ConverseStreamOutput::ContentBlockStop(_))
}
/// Convert accumulated streaming tool calls to OpenAI format
pub fn streaming_tool_calls_to_openai(tool_calls: Vec<StreamingToolCall>) -> Vec<OpenAIToolCall> {
tool_calls
.into_iter()
.map(|tc| OpenAIToolCall {
id: tc.id,
function: OpenAIFunction { name: tc.name, arguments: tc.arguments },
r#type: FUNCTION_TYPE.to_string(),
})
.collect()
}
#[derive(Default)]
pub struct BedrockQueryBuilder;
impl BedrockQueryBuilder {
/// Execute Bedrock request (streaming or non-streaming)
pub async fn execute_request(
&self,
messages: &[OpenAIMessage],
tools: Option<&[ToolDef]>,
model: &str,
temperature: Option<f32>,
max_tokens: Option<u32>,
api_key: &str,
region: &str,
should_stream: bool,
stream_event_processor: Option<StreamEventProcessor>,
client: &AuthedClient,
workspace_id: &str,
structured_output_tool_name: Option<&str>,
) -> Result<ParsedResponse, Error> {
// Create Bedrock client with bearer token authentication
let bedrock_client = BedrockClient::from_bearer_token(api_key.to_string(), region).await?;
// Prepare messages: convert S3Objects to ImageUrls by downloading from S3
let prepared_messages = prepare_messages_for_api(messages, client, workspace_id).await?;
// Convert messages to Bedrock format (separates system prompts)
let (bedrock_messages, system_prompts) = openai_messages_to_bedrock(&prepared_messages)?;
// Build inference configuration
let inference_config = create_inference_config(temperature, max_tokens.map(|t| t as i32));
// Build tool configuration with optional ToolChoice
let tool_config = self.build_tool_config(tools, structured_output_tool_name.is_some())?;
if should_stream {
self.execute_converse_stream(
&bedrock_client,
model,
bedrock_messages,
system_prompts,
inference_config,
tool_config,
stream_event_processor,
)
.await
} else {
self.execute_converse(
&bedrock_client,
model,
bedrock_messages,
system_prompts,
inference_config,
tool_config,
)
.await
}
}
/// Build tool configuration with optional ToolChoice for structured output
fn build_tool_config(
&self,
tools: Option<&[ToolDef]>,
force_tool_use: bool,
) -> Result<Option<aws_sdk_bedrockruntime::types::ToolConfiguration>, Error> {
if let Some(tools) = tools {
let bedrock_tools = openai_tools_to_bedrock(tools)?;
let mut tool_config_builder =
aws_sdk_bedrockruntime::types::ToolConfiguration::builder()
.set_tools(Some(bedrock_tools));
// For structured output, force the model to use the tool
if force_tool_use {
tool_config_builder = tool_config_builder.tool_choice(
aws_sdk_bedrockruntime::types::ToolChoice::Any(
aws_sdk_bedrockruntime::types::AnyToolChoice::builder().build(),
),
);
}
Ok(Some(tool_config_builder.build().map_err(|e| {
Error::internal_err(format!("Failed to build tool configuration: {}", e))
})?))
} else {
Ok(None)
}
}
/// Execute non-streaming Bedrock request
async fn execute_converse(
&self,
bedrock_client: &BedrockClient,
model: &str,
bedrock_messages: Vec<aws_sdk_bedrockruntime::types::Message>,
system_prompts: Vec<aws_sdk_bedrockruntime::types::SystemContentBlock>,
inference_config: Option<aws_sdk_bedrockruntime::types::InferenceConfiguration>,
tool_config: Option<aws_sdk_bedrockruntime::types::ToolConfiguration>,
) -> Result<ParsedResponse, Error> {
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));
}
// Execute the request
let response = request_builder.send().await.map_err(|e| {
let error_msg = format!("Bedrock API error: {}", format_bedrock_error(&e));
Error::internal_err(error_msg)
})?;
// Convert response back to OpenAI format
let (content, tool_calls) = bedrock_response_to_openai(&response)?;
Ok(ParsedResponse::Text { content, tool_calls, events_str: None })
}
/// Execute streaming Bedrock request
async fn execute_converse_stream(
&self,
bedrock_client: &BedrockClient,
model: &str,
bedrock_messages: Vec<aws_sdk_bedrockruntime::types::Message>,
system_prompts: Vec<aws_sdk_bedrockruntime::types::SystemContentBlock>,
inference_config: Option<aws_sdk_bedrockruntime::types::InferenceConfiguration>,
tool_config: Option<aws_sdk_bedrockruntime::types::ToolConfiguration>,
stream_event_processor: Option<StreamEventProcessor>,
) -> Result<ParsedResponse, Error> {
// Build streaming 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));
}
// Execute streaming request
let mut stream = request_builder
.send()
.await
.map_err(|e| {
let error_msg =
format!("Bedrock streaming API error: {}", format_bedrock_error(&e));
Error::internal_err(error_msg)
})?
.stream;
let mut accumulated_text = String::new();
let mut events_str = String::new();
let mut accumulated_tool_calls: HashMap<String, StreamingToolCall> = HashMap::new();
let mut current_tool_use_id: Option<String> = None;
// Process stream events
loop {
match stream.recv().await {
Ok(Some(event)) => {
// Handle tool use start
if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) {
current_tool_use_id = Some(tool_call.id.clone());
accumulated_tool_calls.insert(tool_call.id.clone(), tool_call);
}
// Handle text delta
if let Some(text_delta) = bedrock_stream_event_to_text(&event) {
accumulated_text.push_str(&text_delta);
if let Some(processor) = stream_event_processor.as_ref() {
processor
.send(
StreamingEvent::TokenDelta { content: text_delta },
&mut events_str,
)
.await?;
}
}
// Handle tool use input delta
if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) {
if let Some(tool_id) = &current_tool_use_id {
if let Some(tool_call) = accumulated_tool_calls.get_mut(tool_id) {
tool_call.arguments.push_str(&input_delta);
}
}
}
// Handle content block stop
if bedrock_stream_event_is_block_stop(&event) {
current_tool_use_id = None;
}
}
Ok(None) => break, // Stream ended
Err(e) => {
return Err(Error::internal_err(format!("Bedrock stream error: {}", e)));
}
}
}
// Send tool call events to stream processor
if let Some(processor) = stream_event_processor.as_ref() {
for tool_call in accumulated_tool_calls.values() {
processor
.send(
StreamingEvent::ToolCallArguments {
call_id: tool_call.id.clone(),
function_name: tool_call.name.clone(),
arguments: tool_call.arguments.clone(),
},
&mut events_str,
)
.await?;
}
}
let content = if accumulated_text.is_empty() {
None
} else {
Some(accumulated_text)
};
let tool_calls =
streaming_tool_calls_to_openai(accumulated_tool_calls.into_values().collect());
Ok(ParsedResponse::Text {
content,
tool_calls,
events_str: if events_str.is_empty() {
None
} else {
Some(events_str)
},
})
}
}

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