Merge remote-tracking branch 'origin/main' into di/flow-input-redesign

This commit is contained in:
Diego Imbert
2025-09-18 20:10:46 +02:00
118 changed files with 4024 additions and 2454 deletions
+32 -14
View File
@@ -29,10 +29,16 @@ jobs:
--health-retries 5
steps:
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
- name: Comment on PR - Starting
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ steps.app.outputs.token }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
@@ -44,6 +50,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
with:
token: ${{ steps.app.outputs.token }}
ref: ${{ github.event.issue.pull_request.head.ref }}
fetch-depth: 0
@@ -70,21 +77,25 @@ jobs:
- name: Run update-sqlx script
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/windmill
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
set -e # Exit on any command failure
PR_NUMBER=${{ github.event.issue.number }}
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
echo "Checking out PR branch: $BRANCH_NAME"
git checkout $BRANCH_NAME
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git config --local user.email "windmill-internal-app[bot]@users.noreply.github.com"
git config --local user.name "windmill-internal-app[bot]"
git config pull.rebase true
git pull origin $BRANCH_NAME
mkdir frontend/build
mkdir -p frontend/build
cd backend
cargo install sqlx-cli --version 0.8.5
sqlx migrate run
./update_sqlx.sh --dir ./windmill-ee-private
if ! ./update_sqlx.sh --dir ./windmill-ee-private; then
gh pr comment $PR_NUMBER --body "❌ SQLx update failed. Please check the workflow logs for details."
exit 1
fi
# Pass the branch name to the next step
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
@@ -97,7 +108,7 @@ jobs:
- name: Comment on PR - Completed
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ steps.app.outputs.token }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
@@ -114,10 +125,16 @@ jobs:
pull-requests: write
issues: write
steps:
- uses: actions/create-github-app-token@v2
id: app
with:
app-id: ${{ vars.INTERNAL_APP_ID }}
private-key: ${{ secrets.INTERNAL_APP_KEY }}
- name: Comment on PR - Starting
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ steps.app.outputs.token }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
@@ -129,6 +146,7 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v3
with:
token: ${{ steps.app.outputs.token }}
ref: ${{ github.event.issue.pull_request.head.ref }}
fetch-depth: 0
@@ -149,19 +167,19 @@ jobs:
- name: Update ee-repo-ref.txt
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
PR_NUMBER=${{ github.event.issue.number }}
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
echo "Checking out PR branch: $BRANCH_NAME"
git checkout $BRANCH_NAME
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git config --local user.email "windmill-internal-app[bot]@users.noreply.github.com"
git config --local user.name "windmill-internal-app[bot]"
git config pull.rebase true
git pull origin $BRANCH_NAME
echo "${{ steps.get-commit-hash.outputs.commit_hash }}" > backend/ee-repo-ref.txt
echo "Updated backend/ee-repo-ref.txt with commit hash: ${{ steps.get-commit-hash.outputs.commit_hash }}"
# commit and push the changes
PR_NUMBER=${{ github.event.issue.number }}
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
git add backend/ee-repo-ref.txt
git commit -m "Update ee-repo-ref.txt" || echo "No changes to commit"
git push origin $BRANCH_NAME
@@ -169,7 +187,7 @@ jobs:
- name: Comment on PR - Completed
uses: actions/github-script@v6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
github-token: ${{ steps.app.outputs.token }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## [1.544.1](https://github.com/windmill-labs/windmill/compare/v1.544.0...v1.544.1) (2025-09-18)
### Bug Fixes
* fix onLoad auth issue ([bb4699b](https://github.com/windmill-labs/windmill/commit/bb4699bdc6d4e458175594db92b34fdf12fadf1d))
## [1.544.0](https://github.com/windmill-labs/windmill/compare/v1.543.0...v1.544.0) (2025-09-18)
### Features
* **ai agent:** allow multiple images input for ai agent + code cleaning ([#6591](https://github.com/windmill-labs/windmill/issues/6591)) ([3199f9f](https://github.com/windmill-labs/windmill/commit/3199f9fffd36c84d7cbb4a512935f7eb19fa5049))
* **aichat:** add max tokens settings ([#6613](https://github.com/windmill-labs/windmill/issues/6613)) ([d837bad](https://github.com/windmill-labs/windmill/commit/d837badf2c70c483e260b099a663fdda3f1f509a))
* allow operator to use script/flow with dynselect input ([#6616](https://github.com/windmill-labs/windmill/issues/6616)) ([e98bde6](https://github.com/windmill-labs/windmill/commit/e98bde6be6633e9a529b67672fcde5e4ce5f9129))
* **backend:** flow streaming ([#6520](https://github.com/windmill-labs/windmill/issues/6520)) ([993baf4](https://github.com/windmill-labs/windmill/commit/993baf46bd46524b5cefe1d6ffc037e9c6ec32d2))
* fix gcp cleanup and add ack-deadline for gcp push delivery [#6631](https://github.com/windmill-labs/windmill/issues/6631) ([4b71495](https://github.com/windmill-labs/windmill/commit/4b7149527b52ac42094af580e3788326c30a0c70))
* **flow:** Add helper to add expression to arrays ([#6629](https://github.com/windmill-labs/windmill/issues/6629)) ([56ddad2](https://github.com/windmill-labs/windmill/commit/56ddad2d5a960c782fad89c2c86ad9cc2c4dd8cb))
* **frontend:** allow publishing script to hub from list view ([#6634](https://github.com/windmill-labs/windmill/issues/6634)) ([39b2f54](https://github.com/windmill-labs/windmill/commit/39b2f547799a6d75012ca2595d532e87a3035e85))
* simplify sync vs promotion mode ui in git sync settings ([#6615](https://github.com/windmill-labs/windmill/issues/6615)) ([7707bb8](https://github.com/windmill-labs/windmill/commit/7707bb8fecd85cc65a4e511f3033170a1c24fb1d))
* update git sync script for email triggers ([#6582](https://github.com/windmill-labs/windmill/issues/6582)) ([e97c535](https://github.com/windmill-labs/windmill/commit/e97c535376177b8681fbb6b6553d6f241462f7a9))
### Bug Fixes
* add ack deadline gcp ([#6625](https://github.com/windmill-labs/windmill/issues/6625)) ([426065e](https://github.com/windmill-labs/windmill/commit/426065efee5e5e775dde403f7e7f7c78d002909a))
* **aiagent:** fix endpoint for azure ([#6633](https://github.com/windmill-labs/windmill/issues/6633)) ([709a937](https://github.com/windmill-labs/windmill/commit/709a937ac2dab3d8a18b01f200976fa2d4625e89))
* Don't reencrypt secrets on workspace forking ([#6622](https://github.com/windmill-labs/windmill/issues/6622)) ([9325f56](https://github.com/windmill-labs/windmill/commit/9325f5636c2e957a724bb051d41e15d2966b2899))
* jumpcloud scim support + instance settings ui bug (nextcloud oauth) ([#6618](https://github.com/windmill-labs/windmill/issues/6618)) ([9ff4ca0](https://github.com/windmill-labs/windmill/commit/9ff4ca06629a0cf7da2996f2b22ea7915cc4705e))
## [1.543.0](https://github.com/windmill-labs/windmill/compare/v1.542.4...v1.543.0) (2025-09-15)
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT parent_job FROM v2_job WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "parent_job",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true
]
},
"hash": "0997b46bae6e2374b568e8367898d6fc79c331431326250c1a59674054ceaabd"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE \n gcp_trigger \n SET \n gcp_resource_path = $1,\n subscription_id = $2,\n topic_id = $3,\n delivery_type = $4,\n delivery_config = $5,\n is_flow = $6, \n edited_by = $7, \n email = $8,\n script_path = $9,\n path = $10,\n enabled = $11,\n edited_at = now(), \n error = NULL,\n server_id = NULL,\n error_handler_path = $14,\n error_handler_args = $15,\n retry = $16,\n auto_acknowledge_msg = $17\n WHERE \n workspace_id = $12 AND \n path = $13\n ",
"query": "\n UPDATE \n gcp_trigger \n SET \n gcp_resource_path = $1,\n subscription_id = $2,\n topic_id = $3,\n delivery_type = $4,\n delivery_config = $5,\n is_flow = $6, \n edited_by = $7, \n email = $8,\n script_path = $9,\n path = $10,\n enabled = $11,\n edited_at = now(), \n error = NULL,\n server_id = NULL,\n error_handler_path = $14,\n error_handler_args = $15,\n retry = $16,\n auto_acknowledge_msg = $17,\n ack_deadline = $18\n WHERE \n workspace_id = $12 AND \n path = $13\n ",
"describe": {
"columns": [],
"parameters": {
@@ -31,10 +31,11 @@
"Varchar",
"Jsonb",
"Jsonb",
"Bool"
"Bool",
"Int4"
]
},
"nullable": []
},
"hash": "1a9ba16c90d3d65c4ff39aaddb3079009e03af711e7f6b53332537cf4cb0e8dd"
"hash": "17ca259e1c78e1317fdd19436e15bef428fc4f0d52776d7a5fca64f17225ef30"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE v2_job_status\n SET flow_status = jsonb_set(flow_status, array['stream_job'], to_jsonb($1::UUID::TEXT))\n WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "2e589e039986e7a2c75e328868874669b32cbe0dae6822b2d2fad0635c5f6087"
}
@@ -1,70 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at\n FROM variable \n WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "value",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "is_secret",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "description",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 6,
"name": "account",
"type_info": "Int4"
},
{
"ordinal": 7,
"name": "is_oauth",
"type_info": "Bool"
},
{
"ordinal": 8,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
true
]
},
"hash": "31869c5dba5cefd4ffaae7720617a40ef42ca940ca4ff7f9fb2e69e63d830d27"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n c.id IS NOT NULL AS completed,\n CASE\n WHEN q.id IS NOT NULL THEN (CASE WHEN NOT $5 AND q.running THEN true ELSE null END)\n ELSE false\n END AS running,\n CASE WHEN $7::BOOLEAN THEN NULL ELSE SUBSTR(logs, GREATEST($1 - log_offset, 0)) END AS logs,\n SUBSTR(rs.stream, $8) AS new_result_stream,\n COALESCE(r.memory_peak, c.memory_peak) AS mem_peak,\n COALESCE(c.flow_status, f.flow_status) AS \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n COALESCE(c.workflow_as_code_status, f.workflow_as_code_status) AS \"workflow_as_code_status: sqlx::types::Json<Box<RawValue>>\",\n CASE WHEN $7::BOOLEAN THEN NULL ELSE job_logs.log_offset + CHAR_LENGTH(job_logs.logs) + 1 END AS log_offset,\n CHAR_LENGTH(rs.stream) + 1 AS stream_offset,\n created_by AS \"created_by!\",\n CASE WHEN $4::BOOLEAN THEN (\n SELECT scalar_int FROM job_stats WHERE job_id = $3 AND metric_id = 'progress_perc'\n ) END AS progress,\n rs.stream AS \"result_stream: Option<String>\"\n FROM v2_job j\n LEFT JOIN v2_job_queue q USING (id)\n LEFT JOIN v2_job_runtime r USING (id)\n LEFT JOIN v2_job_status f USING (id)\n LEFT JOIN v2_job_completed c USING (id)\n LEFT JOIN job_result_stream rs ON rs.job_id = $3\n LEFT JOIN job_logs ON job_logs.job_id = $3\n WHERE j.workspace_id = $2 AND j.id = $3\n AND ($6::text[] IS NULL OR j.tag = ANY($6))",
"query": "SELECT\n c.id IS NOT NULL AS completed,\n CASE\n WHEN q.id IS NOT NULL THEN (CASE WHEN NOT $5 AND q.running THEN true ELSE null END)\n ELSE false\n END AS running,\n CASE WHEN $7::BOOLEAN THEN NULL ELSE SUBSTR(logs, GREATEST($1 - log_offset, 0)) END AS logs,\n SUBSTR(rs.stream, $8) AS new_result_stream,\n COALESCE(r.memory_peak, c.memory_peak) AS mem_peak,\n COALESCE(c.flow_status, f.flow_status) AS \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n (COALESCE(c.flow_status, f.flow_status)->>'stream_job')::uuid AS stream_job,\n COALESCE(c.workflow_as_code_status, f.workflow_as_code_status) AS \"workflow_as_code_status: sqlx::types::Json<Box<RawValue>>\",\n CASE WHEN $7::BOOLEAN THEN NULL ELSE job_logs.log_offset + CHAR_LENGTH(job_logs.logs) + 1 END AS log_offset,\n CHAR_LENGTH(rs.stream) + 1 AS stream_offset,\n created_by AS \"created_by!\",\n CASE WHEN $4::BOOLEAN THEN (\n SELECT scalar_int FROM job_stats WHERE job_id = $3 AND metric_id = 'progress_perc'\n ) END AS progress,\n rs.stream AS \"result_stream: Option<String>\"\n FROM v2_job j\n LEFT JOIN v2_job_queue q USING (id)\n LEFT JOIN v2_job_runtime r USING (id)\n LEFT JOIN v2_job_status f USING (id)\n LEFT JOIN v2_job_completed c USING (id)\n LEFT JOIN job_result_stream rs ON rs.job_id = $3\n LEFT JOIN job_logs ON job_logs.job_id = $3\n WHERE j.workspace_id = $2 AND j.id = $3\n AND ($6::text[] IS NULL OR j.tag = ANY($6))",
"describe": {
"columns": [
{
@@ -35,31 +35,36 @@
},
{
"ordinal": 6,
"name": "stream_job",
"type_info": "Uuid"
},
{
"ordinal": 7,
"name": "workflow_as_code_status: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 7,
"ordinal": 8,
"name": "log_offset",
"type_info": "Int4"
},
{
"ordinal": 8,
"ordinal": 9,
"name": "stream_offset",
"type_info": "Int4"
},
{
"ordinal": 9,
"ordinal": 10,
"name": "created_by!",
"type_info": "Varchar"
},
{
"ordinal": 10,
"ordinal": 11,
"name": "progress",
"type_info": "Int4"
},
{
"ordinal": 11,
"ordinal": 12,
"name": "result_stream: Option<String>",
"type_info": "Text"
}
@@ -86,10 +91,11 @@
null,
null,
null,
null,
false,
null,
false
]
},
"hash": "70ddcf86865a315934843285e8ec618c47a5acbe400d79840c4a2d86f8886393"
"hash": "40999264f09a781c4393b50c2c41ae5a5e64086198cb67aba72345bb3cdf7773"
}
@@ -1,48 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT result as \"result: sqlx::types::Json<Box<RawValue>>\", v2_job.tag,\n v2_job_queue.running as \"running: Option<bool>\", SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\", CHAR_LENGTH(rs.stream) AS stream_offset\n FROM v2_job\n LEFT JOIN v2_job_queue USING (id)\n LEFT JOIN v2_job_completed USING (id)\n LEFT JOIN job_result_stream rs ON rs.job_id = $2\n WHERE v2_job.id = $2 AND v2_job.workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "result: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "running: Option<bool>",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "result_stream: Option<String>",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "stream_offset",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Int4"
]
},
"nullable": [
true,
false,
false,
null,
null
]
},
"hash": "69924462c788dbc8f31aacc7f8ae588d76bf1f25d631833ce4b194818a7d1437"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COALESCE(jc.result, NULL) as \"result: sqlx::types::Json<Box<RawValue>>\",\n SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\",\n CHAR_LENGTH(rs.stream) + 1 AS stream_offset\n FROM (\n SELECT $2::uuid as job_id, $1::text as workspace_id\n ) base\n LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id\n LEFT JOIN job_result_stream rs ON rs.job_id = base.job_id\n WHERE base.job_id = $2",
"query": "SELECT\n COALESCE(jc.result, NULL) as \"result: sqlx::types::Json<Box<RawValue>>\",\n SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\",\n CHAR_LENGTH(rs.stream) + 1 AS stream_offset,\n COALESCE(js.flow_status, jc.flow_status) as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job\n FROM (\n SELECT $2::uuid as job_id, $1::text as workspace_id\n ) base\n LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id\n LEFT JOIN v2_job_status js ON js.id = base.job_id\n LEFT JOIN job_result_stream rs ON rs.job_id = base.job_id\n WHERE base.job_id = $2",
"describe": {
"columns": [
{
@@ -17,20 +17,33 @@
"ordinal": 2,
"name": "stream_offset",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "stream_job",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Int4"
"Int4",
"Bool"
]
},
"nullable": [
null,
null,
null,
null,
null
]
},
"hash": "d8c209b177da2e147a3549c888969478cd80aa157700e5c1f3b9f4d12dd31a1d"
"hash": "80809d397cf84f7278ebb276078871b371663257a127eb35512695c487066fd7"
}
@@ -0,0 +1,55 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n jc.result as \"result: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n v2_job_queue.running as \"running: Option<bool>\",\n SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\",\n CHAR_LENGTH(rs.stream) AS stream_offset,\n CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job\n FROM v2_job\n LEFT JOIN v2_job_queue USING (id)\n LEFT JOIN v2_job_completed jc USING (id)\n LEFT JOIN v2_job_status js USING (id)\n LEFT JOIN job_result_stream rs ON rs.job_id = $2\n WHERE v2_job.id = $2 AND v2_job.workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "result: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "running: Option<bool>",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "result_stream: Option<String>",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "stream_offset",
"type_info": "Int4"
},
{
"ordinal": 5,
"name": "stream_job",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Int4",
"Bool"
]
},
"nullable": [
true,
false,
false,
null,
null,
null
]
},
"hash": "8126b118704341846e88bd289f1afe83c07b7a8b422f48022994370b3e433f34"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n COALESCE(jc.result, jc.result) as \"result: sqlx::types::Json<Box<RawValue>>\",\n jq.running as \"running: Option<bool>\",\n SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\",\n CHAR_LENGTH(rs.stream) + 1 AS stream_offset\n FROM (\n SELECT $1::uuid as job_id, $2::text as workspace_id\n ) base\n LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id\n LEFT JOIN v2_job_queue jq ON jq.id = base.job_id AND jq.workspace_id = base.workspace_id\n LEFT JOIN job_result_stream rs ON rs.job_id = base.job_id\n WHERE base.job_id = $1",
"query": "SELECT\n COALESCE(jc.result, jc.result) as \"result: sqlx::types::Json<Box<RawValue>>\",\n jq.running as \"running: Option<bool>\",\n SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\",\n CHAR_LENGTH(rs.stream) + 1 AS stream_offset,\n CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job\n FROM (\n SELECT $1::uuid as job_id, $2::text as workspace_id\n ) base\n LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id\n LEFT JOIN v2_job_queue jq ON jq.id = base.job_id AND jq.workspace_id = base.workspace_id\n LEFT JOIN v2_job_status js ON js.id = base.job_id\n LEFT JOIN job_result_stream rs ON rs.job_id = base.job_id\n WHERE base.job_id = $1",
"describe": {
"columns": [
{
@@ -22,21 +22,28 @@
"ordinal": 3,
"name": "stream_offset",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "stream_job",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Int4"
"Int4",
"Bool"
]
},
"nullable": [
null,
false,
null,
null,
null
]
},
"hash": "6907eb134dc5dbf118387e073897f86574c92de16252b2b1c475ab8146e5343d"
"hash": "a58a345f7082181f89e7f88929b7149791de48bc2e489edb55d63f67702cce05"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Jsonb",
"Int4",
"Bool",
"Timestamptz"
]
},
"nullable": []
},
"hash": "a6d1b80e1b407610987c98521f8e36dc8e96a63c4690721ae0bc169a3d83aff1"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO workspace_key (workspace_id, kind, key)\n SELECT $2, kind, key FROM workspace_key WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "bd5d39d1ef26ac0526a2ae834b45bcf902d143eec8faec306c651e74ad14c68e"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at)\n SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at\n FROM variable\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "c4121e4d1de409f66f1984ad2a962dd86568e89f278b93320911f8ae5475f038"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "SELECT \n (flow_status->'step')::integer as step,\n jsonb_array_length(flow_status->'modules') as len,\n flow_status->'modules'->-1->>'branch_chosen' IS NOT NULL as is_branch_one,\n parent_job as ppp_job\n FROM v2_job \n LEFT JOIN v2_job_status USING (id)\n WHERE v2_job.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "step",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "len",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "is_branch_one",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "ppp_job",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null,
null,
null,
true
]
},
"hash": "d8ef35b4990eb9b2a306494d5b9acde9f57cfaba047c5b7680e0dccd2c1507df"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO gcp_trigger (\n gcp_resource_path,\n subscription_id,\n topic_id,\n delivery_type,\n delivery_config,\n workspace_id, \n path, \n script_path, \n is_flow, \n email, \n enabled, \n edited_by,\n error_handler_path,\n error_handler_args,\n retry,\n auto_acknowledge_msg\n ) \n VALUES (\n $1, \n $2, \n $3, \n $4,\n $5,\n $6, \n $7, \n $8, \n $9,\n $10,\n $11,\n $12,\n $13,\n $14,\n $15,\n $16\n )",
"query": "\n INSERT INTO gcp_trigger (\n gcp_resource_path,\n subscription_id,\n topic_id,\n delivery_type,\n delivery_config,\n workspace_id, \n path, \n script_path, \n is_flow, \n email, \n enabled, \n edited_by,\n error_handler_path,\n error_handler_args,\n retry,\n auto_acknowledge_msg,\n ack_deadline\n ) \n VALUES (\n $1, \n $2, \n $3, \n $4,\n $5,\n $6, \n $7, \n $8, \n $9,\n $10,\n $11,\n $12,\n $13,\n $14,\n $15,\n $16,\n $17\n )",
"describe": {
"columns": [],
"parameters": {
@@ -30,10 +30,11 @@
"Varchar",
"Jsonb",
"Jsonb",
"Bool"
"Bool",
"Int4"
]
},
"nullable": []
},
"hash": "e619fa013528a6275f98e14ae1727c55b0d4f4a5e4ee87c29251042e2916f0a0"
"hash": "e7c61bbdcf882f6e1e9b11df03e7a2ee318c72b365c0fe0b9fbec886a461f5e4"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT SUBSTR(rs.stream, $1) AS new_result_stream, CHAR_LENGTH(rs.stream) + 1 AS stream_offset FROM job_result_stream rs WHERE rs.job_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "new_result_stream",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "stream_offset",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Int4",
"Uuid"
]
},
"nullable": [
null,
null
]
},
"hash": "f17f914d2522bf7cb5de9d7ba5557ee0dce940039ab42fd39bf079d87b6cad8a"
}
+91 -71
View File
@@ -620,7 +620,7 @@ dependencies = [
"thiserror 1.0.69",
"time",
"tokio",
"tokio-rustls 0.26.2",
"tokio-rustls 0.26.3",
"tokio-util",
"tokio-websockets",
"tracing",
@@ -3613,7 +3613,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.16",
"tokio",
"tokio-rustls 0.26.2",
"tokio-rustls 0.26.3",
"tokio-socks",
"tokio-util",
"tower 0.5.2",
@@ -5879,7 +5879,7 @@ dependencies = [
"js-sys",
"libc",
"r-efi",
"wasi 0.14.6+wasi-0.2.4",
"wasi 0.14.7+wasi-0.2.4",
"wasm-bindgen",
]
@@ -6608,7 +6608,7 @@ dependencies = [
"pin-project-lite",
"rustls-native-certs 0.7.3",
"tokio",
"tokio-rustls 0.26.2",
"tokio-rustls 0.26.3",
"tower-service",
]
@@ -6657,7 +6657,7 @@ dependencies = [
"rustls-native-certs 0.8.1",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.2",
"tokio-rustls 0.26.3",
"tower-service",
"webpki-roots 1.0.2",
]
@@ -7176,9 +7176,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.78"
version = "0.3.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0b063578492ceec17683ef2f8c5e89121fbd0b172cbc280635ab7567db2738"
checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e"
dependencies = [
"once_cell",
"wasm-bindgen",
@@ -7186,9 +7186,9 @@ dependencies = [
[[package]]
name = "json-patch"
version = "4.0.0"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "159294d661a039f7644cea7e4d844e6b25aaf71c1ffe9d73a96d768c24b0faf4"
checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90"
dependencies = [
"jsonptr",
"serde",
@@ -7910,7 +7910,7 @@ dependencies = [
"rustls-pki-types",
"smtp-proto",
"tokio",
"tokio-rustls 0.26.2",
"tokio-rustls 0.26.3",
"webpki-roots 0.26.11",
]
@@ -9911,11 +9911,11 @@ dependencies = [
[[package]]
name = "proc-macro-crate"
version = "3.3.0"
version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
dependencies = [
"toml_edit 0.22.27",
"toml_edit 0.23.4",
]
[[package]]
@@ -10719,7 +10719,7 @@ dependencies = [
"sync_wrapper 1.0.2",
"tokio",
"tokio-native-tls",
"tokio-rustls 0.26.2",
"tokio-rustls 0.26.3",
"tokio-util",
"tower 0.5.2",
"tower-http",
@@ -13738,9 +13738,9 @@ dependencies = [
[[package]]
name = "tokio-rustls"
version = "0.26.2"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd"
dependencies = [
"rustls 0.23.29",
"tokio",
@@ -13832,7 +13832,7 @@ dependencies = [
"rustls-native-certs 0.8.1",
"rustls-pki-types",
"tokio",
"tokio-rustls 0.26.2",
"tokio-rustls 0.26.3",
"tokio-util",
]
@@ -13844,7 +13844,7 @@ checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_datetime 0.6.11",
"toml_edit 0.19.15",
]
@@ -13857,6 +13857,15 @@ dependencies = [
"serde",
]
[[package]]
name = "toml_datetime"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.19.15"
@@ -13866,18 +13875,28 @@ dependencies = [
"indexmap 2.11.1",
"serde",
"serde_spanned",
"toml_datetime",
"toml_datetime 0.6.11",
"winnow 0.5.40",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
version = "0.23.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93"
dependencies = [
"indexmap 2.11.1",
"toml_datetime",
"toml_datetime 0.7.0",
"toml_parser",
"winnow 0.7.13",
]
[[package]]
name = "toml_parser"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10"
dependencies = [
"winnow 0.7.13",
]
@@ -13907,7 +13926,7 @@ dependencies = [
"rustls-pemfile 2.2.0",
"socket2 0.5.10",
"tokio",
"tokio-rustls 0.26.2",
"tokio-rustls 0.26.3",
"tokio-stream",
"tower 0.4.13",
"tower-layer",
@@ -14740,9 +14759,9 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasi"
version = "0.14.6+wasi-0.2.4"
version = "0.14.7+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f71243a3f320c00a8459e455c046ce571229c2f31fd11645d9dc095e3068ca0"
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
dependencies = [
"wasip2",
]
@@ -14764,9 +14783,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.101"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e14915cadd45b529bb8d1f343c4ed0ac1de926144b746e2710f9cd05df6603b"
checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819"
dependencies = [
"cfg-if",
"once_cell",
@@ -14777,9 +14796,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-backend"
version = "0.2.101"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e28d1ba982ca7923fd01448d5c30c6864d0a14109560296a162f80f305fb93bb"
checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c"
dependencies = [
"bumpalo",
"log",
@@ -14791,9 +14810,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-futures"
version = "0.4.51"
version = "0.4.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ca85039a9b469b38336411d6d6ced91f3fc87109a2a27b0c197663f5144dffe"
checksum = "a0b221ff421256839509adbb55998214a70d829d3a28c69b4a6672e9d2a42f67"
dependencies = [
"cfg-if",
"js-sys",
@@ -14804,9 +14823,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.101"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c3d463ae3eff775b0c45df9da45d68837702ac35af998361e2c84e7c5ec1b0d"
checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -14814,9 +14833,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.101"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb4ce89b08211f923caf51d527662b75bdc9c9c7aab40f86dcb9fb85ac552aa"
checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32"
dependencies = [
"proc-macro2",
"quote",
@@ -14827,18 +14846,18 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.101"
version = "0.2.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f143854a3b13752c6950862c906306adb27c7e839f7414cec8fea35beab624c1"
checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.51"
version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80cc7f8a4114fdaa0c58383caf973fc126cf004eba25c9dc639bccd3880d55ad"
checksum = "aee0a0f5343de9221a0d233b04520ed8dc2e6728dce180b1dcd9288ec9d9fa3c"
dependencies = [
"js-sys",
"minicov",
@@ -14849,9 +14868,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.51"
version = "0.3.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5ada2ab788d46d4bda04c9d567702a79c8ced14f51f221646a16ed39d0e6a5d"
checksum = "a369369e4360c2884c3168d22bded735c43cccae97bbc147586d4b480edd138d"
dependencies = [
"proc-macro2",
"quote",
@@ -14898,9 +14917,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.78"
version = "0.3.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77e4b637749ff0d92b8fad63aa1f7cff3cbe125fd49c175cd6345e7272638b12"
checksum = "fbe734895e869dc429d78c4b433f8d17d95f8d05317440b4fad5ab2d33e596dc"
dependencies = [
"js-sys",
"wasm-bindgen",
@@ -15109,7 +15128,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"aws-sdk-config",
@@ -15169,7 +15188,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"argon2",
@@ -15289,7 +15308,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -15304,7 +15323,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"chrono",
"serde",
@@ -15317,7 +15336,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"axum",
@@ -15336,7 +15355,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15420,7 +15439,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"regex",
"serde",
@@ -15435,7 +15454,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"bytes",
@@ -15459,7 +15478,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15471,7 +15490,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15480,7 +15499,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15492,7 +15511,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15504,7 +15523,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"gosyn",
@@ -15516,7 +15535,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15528,7 +15547,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15540,7 +15559,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -15551,7 +15570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15562,7 +15581,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15574,7 +15593,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15597,7 +15616,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15611,7 +15630,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15628,7 +15647,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15642,7 +15661,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -15660,7 +15679,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"getrandom 0.2.16",
@@ -15685,7 +15704,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"serde_json",
@@ -15695,7 +15714,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -15728,7 +15747,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -15738,12 +15757,13 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.543.0"
version = "1.544.1"
dependencies = [
"anyhow",
"async-once-cell",
"async-recursion",
"async-stream",
"async-trait",
"backon",
"base64 0.22.1",
"bit-vec 0.6.3",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.543.0"
version = "1.544.1"
authors.workspace = true
edition.workspace = true
@@ -34,7 +34,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.543.0"
version = "1.544.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
08f610ecc5486ad2568e0abd22405baa90a93481
daa1c26c268c778e55756f02a459b6c7628c9267
@@ -0,0 +1,2 @@
-- Add down migration script here
ALTER TABLE gcp_trigger DROP COLUMN ack_deadline;
@@ -0,0 +1,2 @@
-- Add up migration script here
ALTER TABLE gcp_trigger ADD COLUMN ack_deadline INTEGER;
+24 -71
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.543.0
version: 1.544.1
title: Windmill API
contact:
@@ -1306,8 +1306,6 @@ paths:
type: string
endpoint_sync:
type: string
endpoint_openai_sync:
type: string
summary:
type: string
description:
@@ -1318,7 +1316,6 @@ paths:
- workspace
- endpoint_async
- endpoint_sync
- endpoint_openai_sync
- summary
- kind
@@ -5606,35 +5603,6 @@ paths:
type: string
format: uuid
/w/{workspace}/jobs/openai_sync/p/{path}:
post:
summary: run script by path in openai format
operationId: openaiSyncScriptByPath
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/ParentJob"
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/IncludeHeader"
- $ref: "#/components/parameters/QueueLimit"
requestBody:
description: script args
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ScriptArgs"
responses:
"200":
description: job result
content:
application/json:
schema: {}
/w/{workspace}/jobs/run_wait_result/p/{path}:
post:
summary: run script by path
@@ -5650,6 +5618,7 @@ paths:
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/IncludeHeader"
- $ref: "#/components/parameters/QueueLimit"
- $ref: "#/components/parameters/SkipPreprocessor"
requestBody:
description: script args
@@ -5681,34 +5650,7 @@ paths:
- $ref: "#/components/parameters/IncludeHeader"
- $ref: "#/components/parameters/QueueLimit"
- $ref: "#/components/parameters/Payload"
responses:
"200":
description: job result
content:
application/json:
schema: {}
/w/{workspace}/jobs/openai_sync/f/{path}:
post:
summary: run flow by path and wait until completion in openai format
operationId: openaiSyncFlowByPath
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- $ref: "#/components/parameters/ScriptPath"
- $ref: "#/components/parameters/IncludeHeader"
- $ref: "#/components/parameters/QueueLimit"
- $ref: "#/components/parameters/NewJobId"
requestBody:
description: script args
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ScriptArgs"
- $ref: "#/components/parameters/SkipPreprocessor"
responses:
"200":
@@ -5729,6 +5671,7 @@ paths:
- $ref: "#/components/parameters/IncludeHeader"
- $ref: "#/components/parameters/QueueLimit"
- $ref: "#/components/parameters/NewJobId"
- $ref: "#/components/parameters/SkipPreprocessor"
requestBody:
description: script args
@@ -7114,11 +7057,7 @@ paths:
in: query
schema:
type: integer
- name: skip_preprocessor
description: skip the preprocessor
in: query
schema:
type: boolean
- $ref: "#/components/parameters/SkipPreprocessor"
- $ref: "#/components/parameters/ParentJob"
- $ref: "#/components/parameters/WorkerTag"
- $ref: "#/components/parameters/NewJobId"
@@ -7276,11 +7215,7 @@ paths:
in: query
schema:
type: integer
- name: skip_preprocessor
description: skip the preprocessor
in: query
schema:
type: boolean
- $ref: "#/components/parameters/SkipPreprocessor"
- $ref: "#/components/parameters/ParentJob"
- $ref: "#/components/parameters/WorkerTag"
- $ref: "#/components/parameters/CacheTtl"
@@ -14137,6 +14072,12 @@ components:
in: query
schema:
type: string
SkipPreprocessor:
name: skip_preprocessor
description: skip the preprocessor
in: query
schema:
type: boolean
Payload:
name: payload
description: |
@@ -14514,6 +14455,12 @@ components:
type: object
additionalProperties:
type: string
max_tokens_per_model:
type: object
additionalProperties:
type: integer
minimum: 1
maximum: 2000000
Alert:
type: object
@@ -16818,6 +16765,12 @@ components:
type: boolean
auto_acknowledge_msg:
type: boolean
ack_deadline:
type: integer
format: int32
minimum: 10
maximum: 600
description: "Time in seconds within which the message must be acknowledged. If not provided, defaults to the subscription's acknowledgment deadline (600 seconds)."
error_handler_path:
type: string
error_handler_args:
+6 -27
View File
@@ -10,8 +10,8 @@ use reqwest::{Client, RequestBuilder};
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::collections::HashMap;
use windmill_common::ai_providers::{AIProvider, ProviderConfig, ProviderModel};
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::ai_providers::{AIProvider, ProviderConfig, ProviderModel, AZURE_API_VERSION};
use windmill_common::error::{to_anyhow, Error, Result};
lazy_static::lazy_static! {
@@ -25,9 +25,6 @@ lazy_static::lazy_static! {
pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringAIRequestConfig> = Cache::new(500);
}
const AZURE_API_VERSION: &str = "2025-04-01-preview";
const OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
#[derive(Deserialize, Debug)]
struct AIOAuthResource {
client_id: String,
@@ -154,21 +151,13 @@ impl AIRequestConfig {
let base_url = self.base_url.trim_end_matches('/');
let is_azure = matches!(provider, AIProvider::OpenAI) && base_url != OPENAI_BASE_URL
|| matches!(provider, AIProvider::AzureOpenAI);
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 url = if is_azure && method != Method::GET {
if base_url.ends_with("/deployments") {
let model = Self::get_azure_model(&body)?;
format!("{}/{}/{}", base_url, model, path)
} else if base_url.ends_with("/openai") {
let model = Self::get_azure_model(&body)?;
format!("{}/deployments/{}/{}", base_url, model, path)
} else {
format!("{}/{}", base_url, path)
}
let model = AIProvider::extract_model_from_body(&body)?;
AIProvider::build_azure_openai_url(base_url, &model, path)
} else if is_anthropic_sdk {
let truncated_base_url = base_url.trim_end_matches("/v1");
format!("{}/{}", truncated_base_url, path)
@@ -233,18 +222,6 @@ impl AIRequestConfig {
.map_err(|e| Error::internal_err(format!("Failed to reserialize request body: {}", e)))?
.into())
}
fn get_azure_model(body: &Bytes) -> Result<String> {
#[derive(Deserialize, Debug)]
struct AzureModel {
model: String,
}
let azure_model: AzureModel = serde_json::from_slice(body)
.map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?;
Ok(azure_model.model)
}
}
#[derive(Clone, Debug)]
@@ -272,6 +249,8 @@ pub struct AIConfig {
pub code_completion_model: Option<ProviderModel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub custom_prompts: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens_per_model: Option<HashMap<String, i32>>,
}
pub fn global_service() -> Router {
+1
View File
@@ -415,6 +415,7 @@ pub struct Tokened {
pub token: String,
}
#[derive(Clone, Debug)]
pub struct OptTokened {
#[allow(dead_code)]
pub token: Option<String>,
+2
View File
@@ -183,6 +183,7 @@ pub struct GcpTriggerConfig {
pub create_update: Option<CreateUpdateConfig>,
pub topic_id: String,
pub auto_acknowledge_msg: Option<bool>,
pub ack_deadline: Option<i32>,
}
#[cfg(all(feature = "enterprise", feature = "nats", feature = "private"))]
@@ -397,6 +398,7 @@ async fn set_gcp_trigger_config(
gcp_config.create_update,
false,
capture_config.is_flow,
gcp_config.ack_deadline
)
.await?;
gcp_config.create_update = Some(config);
+358 -75
View File
@@ -44,6 +44,7 @@ use windmill_common::{email_oss::send_email_html, server::load_smtp_config};
use windmill_common::scripts::PREVIEW_IS_CODEBASE_HASH;
use windmill_common::variables::get_workspace_key;
use crate::triggers::trigger_helpers::ScriptId;
use crate::{
add_webhook_allowed_origin,
args::{self, RawWebhookArgs},
@@ -171,6 +172,27 @@ pub fn workspaced_service() -> Router {
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_and_stream/f/*script_path",
get(stream_flow_by_path)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_and_stream/p/*script_path",
get(stream_script_by_path)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run_and_stream/h/:hash",
get(stream_script_by_hash)
.head(|| async { "" })
.layer(cors.clone())
.layer(ce_headers.clone()),
)
.route(
"/run/h/:hash",
post(run_job_by_hash)
@@ -5145,6 +5167,174 @@ pub async fn run_wait_result_flow_by_path(
.await
}
pub async fn stream_flow_by_path(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, flow_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
stream_job(
authed,
db,
user_db,
w_id,
RunnableId::from_flow_path(flow_path.to_path()),
args,
run_query,
)
.await
}
pub async fn stream_script_by_path(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_path)): Path<(String, StripPath)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
stream_job(
authed,
db,
user_db,
w_id,
RunnableId::from_script_path(script_path.to_path()),
args,
run_query,
)
.await
}
pub async fn stream_script_by_hash(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, script_hash)): Path<(String, ScriptHash)>,
Query(run_query): Query<RunJobQuery>,
args: RawWebhookArgs,
) -> error::Result<Response> {
stream_job(
authed,
db,
user_db,
w_id,
RunnableId::from_script_hash(script_hash),
args,
run_query,
)
.await
}
pub async fn stream_job(
authed: ApiAuthed,
db: DB,
user_db: UserDB,
w_id: String,
runnable_id: RunnableId,
args: RawWebhookArgs,
run_query: RunJobQuery,
) -> error::Result<Response> {
let payload_r = run_query.payload.clone().map(decode_payload).map(|x| {
x.map_err(|e| Error::internal_err(format!("Impossible to decode query payload: {e:#?}")))
});
let payload_args = if let Some(payload) = payload_r {
payload?
} else {
HashMap::new()
};
let mut args = args.process_args(&authed, &db, &w_id, None).await?;
args.body = args::Body::HashMap(payload_args);
let args = args
.to_args_from_runnable(&db, &w_id, runnable_id.clone(), run_query.skip_preprocessor)
.await?;
let uuid = match runnable_id {
RunnableId::ScriptId(ScriptId::ScriptPath(script_path))
| RunnableId::HubScript(script_path) => {
run_script_by_path_inner(
authed.clone(),
db.clone(),
user_db,
w_id.clone(),
StripPath(script_path),
run_query,
args,
)
.await?
.0
}
RunnableId::ScriptId(ScriptId::ScriptHash(script_hash)) => {
run_job_by_hash_inner(
authed.clone(),
db.clone(),
user_db,
w_id.clone(),
script_hash,
run_query,
args,
)
.await?
.0
}
RunnableId::FlowPath(flow_path) => {
run_flow_by_path_inner(
authed.clone(),
db.clone(),
user_db,
w_id.clone(),
StripPath(flow_path),
run_query,
args,
)
.await?
.0
}
};
let opt_authed = Some(authed.clone());
let opt_tokened = OptTokened { token: None }; // ignored when authed is some
let (tx, rx) = tokio::sync::mpsc::channel(32);
let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| {
format!(
"data: {}\n\n",
serde_json::to_string(&x).unwrap_or_default()
)
});
start_job_update_sse_stream(
opt_authed,
opt_tokened,
db,
w_id,
uuid,
None,
None,
None,
None,
Some(true),
Some(true),
None,
None,
tx,
);
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
Ok(Response::builder()
.status(200)
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(body)
.unwrap())
}
pub async fn run_wait_result_flow_by_path_internal(
db: sqlx::Pool<Postgres>,
run_query: RunJobQuery,
@@ -6303,6 +6493,7 @@ pub struct JobUpdateQuery {
pub no_logs: Option<bool>,
pub only_result: Option<bool>,
pub fast: Option<bool>,
pub is_flow: Option<bool>,
}
#[derive(Serialize, Debug)]
@@ -6331,6 +6522,8 @@ pub struct JobUpdate {
pub job: Option<Job>,
#[serde(skip_serializing_if = "Option::is_none")]
pub only_result: Option<Box<serde_json::value::RawValue>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_stream_job_id: Option<Uuid>,
}
impl JobUpdate {
@@ -6349,6 +6542,7 @@ impl Hash for JobUpdate {
self.mem_peak.hash(state);
self.progress.hash(state);
self.stream_offset.hash(state);
self.flow_stream_job_id.hash(state);
if !self.completed.unwrap_or(false) {
self.flow_status.as_ref().map(|x| x.get().hash(state));
self.workflow_as_code_status
@@ -6421,6 +6615,7 @@ async fn get_job_update(
running,
only_result,
no_logs,
is_flow,
..
}): Query<JobUpdateQuery>,
) -> JsonResult<JobUpdate> {
@@ -6439,6 +6634,8 @@ async fn get_job_update(
false,
only_result,
no_logs,
is_flow,
None,
)
.await?,
))
@@ -6457,9 +6654,12 @@ async fn get_job_update_sse(
no_logs,
only_result,
fast,
is_flow,
}): Query<JobUpdateQuery>,
) -> Response {
let stream = get_job_update_sse_stream(
) -> error::Result<Response> {
let (tx, rx) = tokio::sync::mpsc::channel(32);
start_job_update_sse_stream(
opt_authed,
opt_tokened,
db,
@@ -6472,8 +6672,11 @@ async fn get_job_update_sse(
only_result,
fast,
no_logs,
)
.map(|x| {
is_flow,
tx,
);
let stream = tokio_stream::wrappers::ReceiverStream::new(rx).map(|x| {
format!(
"data: {}\n\n",
serde_json::to_string(&x).unwrap_or_default()
@@ -6482,26 +6685,26 @@ async fn get_job_update_sse(
let body = axum::body::Body::from_stream(stream.map(Result::<_, std::convert::Infallible>::Ok));
Response::builder()
Ok(Response::builder()
.status(200)
.header("Content-Type", "text/event-stream")
.header("Cache-Control", "no-cache")
.header("Connection", "keep-alive")
.body(body)
.unwrap()
.unwrap())
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum JobUpdateSSEStream {
Update(JobUpdate),
Error(String),
Error { error: String },
NotFound,
Timeout,
Ping,
}
fn get_job_update_sse_stream(
fn start_job_update_sse_stream(
opt_authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
db: DB,
@@ -6514,18 +6717,18 @@ fn get_job_update_sse_stream(
only_result: Option<bool>,
fast: Option<bool>,
no_logs: Option<bool>,
) -> impl futures::Stream<Item = JobUpdateSSEStream> {
let (tx, rx) = tokio::sync::mpsc::channel(32);
is_flow: Option<bool>,
tx: tokio::sync::mpsc::Sender<JobUpdateSSEStream>,
) -> () {
tokio::spawn(async move {
let mut log_offset = initial_log_offset;
let mut stream_offset = initial_stream_offset;
let mut last_update_hash: Option<String> = None;
let mut flow_stream_job_id = None;
// Send initial update immediately
let mut running = running;
let mut mem_peak = 0;
match get_job_update_data(
&opt_authed,
&opt_tokened,
@@ -6540,6 +6743,8 @@ fn get_job_update_sse_stream(
true,
only_result,
no_logs,
is_flow,
flow_stream_job_id,
)
.await
{
@@ -6566,6 +6771,9 @@ fn get_job_update_sse_stream(
update.stream_offset = None;
}
}
if update.flow_stream_job_id.is_some() {
flow_stream_job_id = update.flow_stream_job_id;
}
if tx.send(JobUpdateSSEStream::Update(update)).await.is_err() {
tracing::warn!("Failed to send initial job update for job {job_id}");
return;
@@ -6576,7 +6784,7 @@ fn get_job_update_sse_stream(
}
Err(e) => {
if tx
.send(JobUpdateSSEStream::Error(e.to_string()))
.send(JobUpdateSSEStream::Error { error: e.to_string() })
.await
.is_err()
{
@@ -6630,6 +6838,8 @@ fn get_job_update_sse_stream(
true,
only_result,
no_logs,
is_flow,
flow_stream_job_id,
)
.await
{
@@ -6665,6 +6875,13 @@ fn get_job_update_sse_stream(
update.stream_offset = None;
}
}
if update.flow_stream_job_id.is_some() {
if flow_stream_job_id.is_none() {
flow_stream_job_id = update.flow_stream_job_id;
} else {
update.flow_stream_job_id = None;
}
}
if let Some(new_mem_peak) = update.mem_peak {
if new_mem_peak != mem_peak {
mem_peak = new_mem_peak;
@@ -6693,8 +6910,29 @@ fn get_job_update_sse_stream(
}
}
});
}
tokio_stream::wrappers::ReceiverStream::new(rx)
async fn get_flow_stream_delta(
db: &DB,
flow_stream_job_id: Option<Uuid>,
stream_offset: Option<i32>,
) -> error::Result<Option<(Option<String>, Option<i32>)>> {
if let Some(job_id) = flow_stream_job_id {
let record = sqlx::query!(
"SELECT SUBSTR(rs.stream, $1) AS new_result_stream, CHAR_LENGTH(rs.stream) + 1 AS stream_offset FROM job_result_stream rs WHERE rs.job_id = $2",
stream_offset.unwrap_or(0),
job_id,
)
.fetch_optional(db)
.await?;
if let Some(record) = record {
Ok(Some((record.new_result_stream, record.stream_offset)))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
async fn get_job_update_data(
@@ -6711,6 +6949,8 @@ async fn get_job_update_data(
get_full_job_on_completion: bool,
only_result: Option<bool>,
no_logs: Option<bool>,
is_flow: Option<bool>,
flow_stream_job_id: Option<Uuid>,
) -> error::Result<JobUpdate> {
let tags = if log_view {
log_job_view(
@@ -6729,115 +6969,146 @@ async fn get_job_update_data(
None
};
let ignore_flow_stream_job_id = is_flow.is_some_and(|x| !x) || flow_stream_job_id.is_some();
if only_result.unwrap_or(false) {
let result = if let Some(tags) = tags {
let r =
sqlx::query!(
"SELECT result as \"result: sqlx::types::Json<Box<RawValue>>\", v2_job.tag,
v2_job_queue.running as \"running: Option<bool>\", SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\", CHAR_LENGTH(rs.stream) AS stream_offset
let (result, running, mut result_stream, mut new_stream_offset, new_flow_stream_job_id) =
if let Some(tags) = tags {
let r = sqlx::query!(
"SELECT
jc.result as \"result: sqlx::types::Json<Box<RawValue>>\",
v2_job.tag,
v2_job_queue.running as \"running: Option<bool>\",
SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\",
CHAR_LENGTH(rs.stream) AS stream_offset,
CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job
FROM v2_job
LEFT JOIN v2_job_queue USING (id)
LEFT JOIN v2_job_completed USING (id)
LEFT JOIN v2_job_completed jc USING (id)
LEFT JOIN v2_job_status js USING (id)
LEFT JOIN job_result_stream rs ON rs.job_id = $2
WHERE v2_job.id = $2 AND v2_job.workspace_id = $1",
w_id,
job_id,
stream_offset.unwrap_or(0),
)
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?;
if !tags.contains(&r.tag.as_str()) {
return Err(Error::NotAuthorized(format!(
"Job tag {} is not in the scope tags: {}",
r.tag,
tags.join(", ")
)));
}
let running = r.running.as_ref().map(|x| *x);
(
r.result.map(|x| x.0),
running,
r.result_stream.flatten(),
r.stream_offset,
w_id,
job_id,
stream_offset.unwrap_or(0),
ignore_flow_stream_job_id,
)
} else {
if running.is_some_and(|x| !x) {
let r = sqlx::query!(
.fetch_optional(db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?;
if !tags.contains(&r.tag.as_str()) {
return Err(Error::NotAuthorized(format!(
"Job tag {} is not in the scope tags: {}",
r.tag,
tags.join(", ")
)));
}
let running = r.running.as_ref().map(|x| *x);
(
r.result.map(|x| x.0),
running,
r.result_stream.flatten(),
r.stream_offset,
r.stream_job,
)
} else {
if running.is_some_and(|x| !x) {
let r = sqlx::query!(
"SELECT
COALESCE(jc.result, jc.result) as \"result: sqlx::types::Json<Box<RawValue>>\",
jq.running as \"running: Option<bool>\",
SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\",
CHAR_LENGTH(rs.stream) + 1 AS stream_offset
CHAR_LENGTH(rs.stream) + 1 AS stream_offset,
CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job
FROM (
SELECT $1::uuid as job_id, $2::text as workspace_id
) base
LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id
LEFT JOIN v2_job_queue jq ON jq.id = base.job_id AND jq.workspace_id = base.workspace_id
LEFT JOIN v2_job_status js ON js.id = base.job_id
LEFT JOIN job_result_stream rs ON rs.job_id = base.job_id
WHERE base.job_id = $1",
job_id,
w_id,
stream_offset.unwrap_or(0),
ignore_flow_stream_job_id,
).fetch_optional(db).await?;
if let Some(r) = r {
let running = r.running.as_ref().map(|x| *x);
(
r.result.map(|x| x.0),
running,
r.result_stream.flatten(),
r.stream_offset,
)
if let Some(r) = r {
let running = r.running.as_ref().map(|x| *x);
(
r.result.map(|x| x.0),
running,
r.result_stream.flatten(),
r.stream_offset,
r.stream_job,
)
} else {
(None, None, None, None, None)
}
} else {
(None, None, None, None)
}
} else {
let q = sqlx::query!(
let q = sqlx::query!(
"SELECT
COALESCE(jc.result, NULL) as \"result: sqlx::types::Json<Box<RawValue>>\",
SUBSTR(rs.stream, $3) AS \"result_stream: Option<String>\",
CHAR_LENGTH(rs.stream) + 1 AS stream_offset
CHAR_LENGTH(rs.stream) + 1 AS stream_offset,
COALESCE(js.flow_status, jc.flow_status) as \"flow_status: sqlx::types::Json<Box<RawValue>>\",
CASE WHEN $4 THEN NULL ELSE (COALESCE(js.flow_status, jc.flow_status)->>'stream_job')::uuid END as stream_job
FROM (
SELECT $2::uuid as job_id, $1::text as workspace_id
) base
LEFT JOIN v2_job_completed jc ON jc.id = base.job_id AND jc.workspace_id = base.workspace_id
LEFT JOIN v2_job_status js ON js.id = base.job_id
LEFT JOIN job_result_stream rs ON rs.job_id = base.job_id
WHERE base.job_id = $2",
w_id,
job_id,
stream_offset.unwrap_or(0),
ignore_flow_stream_job_id,
)
.fetch_optional(db)
.await?;
if let Some(r) = q {
(
r.result.map(|x| x.0),
running,
r.result_stream.flatten(),
r.stream_offset,
)
} else {
(None, None, None, None)
if let Some(r) = q {
(
r.result.map(|x| x.0),
running,
r.result_stream.flatten(),
r.stream_offset,
r.stream_job,
)
} else {
(None, None, None, None, None)
}
}
}
};
};
let flow_stream_job_id = flow_stream_job_id.or(new_flow_stream_job_id);
let flow_stream_delta =
get_flow_stream_delta(db, flow_stream_job_id, stream_offset).await?;
if let Some((flow_result_stream, flow_stream_offset)) = flow_stream_delta {
result_stream = flow_result_stream;
new_stream_offset = flow_stream_offset;
}
Ok(JobUpdate {
running: result.1,
completed: if result.0.is_some() { Some(true) } else { None },
running,
completed: if result.is_some() { Some(true) } else { None },
log_offset: None,
new_logs: None,
new_result_stream: result.2,
stream_offset: result.3,
new_result_stream: result_stream,
stream_offset: new_stream_offset,
mem_peak: None,
progress: None,
job: None,
flow_status: None,
workflow_as_code_status: None,
only_result: result.0,
only_result: result,
flow_stream_job_id,
})
} else {
let record = sqlx::query!(
let mut record = sqlx::query!(
"SELECT
c.id IS NOT NULL AS completed,
CASE
@@ -6848,6 +7119,7 @@ async fn get_job_update_data(
SUBSTR(rs.stream, $8) AS new_result_stream,
COALESCE(r.memory_peak, c.memory_peak) AS mem_peak,
COALESCE(c.flow_status, f.flow_status) AS \"flow_status: sqlx::types::Json<Box<RawValue>>\",
(COALESCE(c.flow_status, f.flow_status)->>'stream_job')::uuid AS stream_job,
COALESCE(c.workflow_as_code_status, f.workflow_as_code_status) AS \"workflow_as_code_status: sqlx::types::Json<Box<RawValue>>\",
CASE WHEN $7::BOOLEAN THEN NULL ELSE job_logs.log_offset + CHAR_LENGTH(job_logs.logs) + 1 END AS log_offset,
CHAR_LENGTH(rs.stream) + 1 AS stream_offset,
@@ -6891,6 +7163,16 @@ async fn get_job_update_data(
None
};
let flow_stream_job_id = flow_stream_job_id.or(record.stream_job);
let flow_stream_delta =
get_flow_stream_delta(db, flow_stream_job_id, stream_offset).await?;
if let Some((new_result_stream, stream_offset)) = flow_stream_delta {
record.new_result_stream = new_result_stream;
record.stream_offset = stream_offset;
}
Ok(JobUpdate {
running: record.running,
completed: record.completed,
@@ -6908,6 +7190,7 @@ async fn get_job_update_data(
.flow_status
.map(|x: sqlx::types::Json<Box<RawValue>>| x.0),
only_result: None,
flow_stream_job_id,
})
}
}
+5 -2
View File
@@ -484,21 +484,24 @@ pub fn check_route_access(
)))
}
const SCRIPT_JOBS: [&'static str; 6] = [
const SCRIPT_JOBS: [&'static str; 8] = [
"jobs/run/p",
"jobs/run/h",
"jobs/run_wait_result/p",
"jobs/run_wait_result/h",
"jobs/run/preview_bundle",
"jobs/run/preview",
"jobs/run_and_stream/p",
"jobs/run_and_stream/h",
];
const FLOW_JOBS: [&'static str; 5] = [
const FLOW_JOBS: [&'static str; 6] = [
"jobs/run/f",
"jobs/run_wait_result/f",
"jobs/run/preview_flow",
"jobs/restart/f",
"jobs/flow/resume",
"jobs/run_and_stream/f",
];
lazy_static::lazy_static! {
+14 -7
View File
@@ -32,6 +32,7 @@ use windmill_common::{
pub trait Listener: TriggerCrud + TriggerJobArgs {
type Consumer: Send;
type Extra: Send + Sync;
type ExtraState: Send + Sync;
//to use in next PR to add job trigger kind to eow
#[allow(unused)]
@@ -53,6 +54,7 @@ pub trait Listener: TriggerCrud + TriggerJobArgs {
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
extra: Option<&Self::ExtraState>,
);
async fn fetch_enabled_unlistened_triggers(
&self,
@@ -159,10 +161,15 @@ pub trait Listener: TriggerCrud + TriggerJobArgs {
Ok(captures)
}
async fn get_extra_state(&self) -> Option<Self::ExtraState> {
None
}
async fn cleanup(
&self,
_db: &DB,
_listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_extra: Option<&Self::ExtraState>,
) -> Result<()> {
Ok(())
}
@@ -568,14 +575,14 @@ async fn listening<T: Listener>(
let killpill_rx_get_consumer = killpill_rx.resubscribe();
let loop_ping_status = Arc::new(RwLock::new(None));
let extra_state = listener.get_extra_state().await;
tokio::select! {
biased;
_ = killpill_rx.recv() => {
let _ = listener.cleanup(&db, &listening_trigger).await;
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
}
_ = listener.loop_ping(&db, &listening_trigger, loop_ping_status.clone(), Some("Connecting...".to_string())) => {
let _ = listener.cleanup(&db, &listening_trigger).await;
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
}
consumer = {
listener.get_consumer(&db, &listening_trigger, loop_ping_status.clone(), killpill_rx_get_consumer)
@@ -583,18 +590,18 @@ async fn listening<T: Listener>(
tokio::select! {
biased;
_ = killpill_rx.recv() => {
let _ = listener.cleanup(&db, &listening_trigger).await;
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
return;
}
_ = listener.loop_ping(&db, &listening_trigger, loop_ping_status.clone(), None) => {
let _ = listener.cleanup(&db, &listening_trigger).await;
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
return;
}
_ = async {
match consumer {
Ok(Some(consumer)) => {
listener.update_ping_and_loop_ping_status(&db, &listening_trigger, loop_ping_status.clone(), None).await;
let _ = listener.consume(&db, consumer, &listening_trigger, loop_ping_status.clone(), killpill_rx_consumer).await;
let _ = listener.consume(&db, consumer, &listening_trigger, loop_ping_status.clone(), killpill_rx_consumer, extra_state.as_ref()).await;
tracing::debug!("Stopping consumer for trigger");
}
Err(error) => {
@@ -604,7 +611,7 @@ async fn listening<T: Listener>(
_ => {}
}
} => {
let _ = listener.cleanup(&db, &listening_trigger).await;
let _ = listener.cleanup(&db, &listening_trigger, extra_state.as_ref()).await;
return;
}
}
@@ -36,6 +36,7 @@ use crate::{
impl Listener for MqttTrigger {
type Consumer = MqttClientResult;
type Extra = ();
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Mqtt;
async fn get_consumer(
@@ -100,6 +101,7 @@ impl Listener for MqttTrigger {
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
tracing::info!(
"Starting to listen for MQTT trigger {}",
@@ -133,6 +133,7 @@ impl PostgresSimpleClient {
impl Listener for PostgresTrigger {
type Consumer = (CopyBothDuplex<Bytes>, LogicalReplicationSettings);
type Extra = ();
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Postgres;
async fn get_consumer(
@@ -209,6 +210,7 @@ impl Listener for PostgresTrigger {
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
_killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
let (logical_replication_stream, logical_replication_settings) = consumer;
pin_mut!(logical_replication_stream);
@@ -403,6 +405,7 @@ impl Listener for PostgresTrigger {
&self,
db: &DB,
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
_extra_state: Option<&Self::ExtraState>,
) -> Result<()> {
let authed = listening_trigger
.authed(db, &Self::TRIGGER_KIND.to_string())
@@ -164,6 +164,7 @@ impl Listener for WebsocketTrigger {
Response<Option<Vec<u8>>>,
);
type Extra = ReturnMessageChannels;
type ExtraState = ();
const JOB_TRIGGER_KIND: JobTriggerKind = JobTriggerKind::Websocket;
async fn get_consumer(
&self,
@@ -214,6 +215,7 @@ impl Listener for WebsocketTrigger {
listening_trigger: &ListeningTrigger<Self::TriggerConfig>,
err_message: Arc<RwLock<Option<String>>>,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
_extra_state: Option<&Self::ExtraState>,
) {
let WebsocketConfig { ref url, .. } = listening_trigger.trigger_config;
-9
View File
@@ -2131,7 +2131,6 @@ struct Runnable {
workspace: String,
endpoint_async: String,
endpoint_sync: String,
endpoint_openai_sync: String,
summary: String,
description: String,
schema: Option<serde_json::Value>,
@@ -2183,10 +2182,6 @@ async fn get_all_runnables(
"/w/{}/jobs/run_wait_result/f/{}",
&f.workspace, &f.path
),
endpoint_openai_sync: format!(
"/w/{}/jobs/openai_sync/f/{}",
&f.workspace, &f.path
),
summary: f.summary,
description: f.description,
schema: f.schema,
@@ -2212,10 +2207,6 @@ async fn get_all_runnables(
"/w/{}/jobs/run_wait_result/p/{}",
&s.workspace, &s.path
),
endpoint_openai_sync: format!(
"/w/{}/jobs/openai_sync/p/{}",
&s.workspace, &s.path
),
summary: s.summary,
description: s.description,
schema: s.schema,
+18 -3
View File
@@ -210,7 +210,12 @@ async fn get_variable(
} else if !value.is_empty() && decrypt_secret {
let _ = tx.commit().await;
let mc = build_crypt(&db, &w_id).await?;
Some(decrypt(&mc, value)?)
Some(decrypt(&mc, value).map_err(|e| {
Error::internal_err(format!(
"Error decrypting variable {}: {}",
variable.path, e
))
})?)
} else if q.include_encrypted.unwrap_or(false) {
Some(value)
} else {
@@ -837,7 +842,12 @@ pub async fn get_value_internal<'a, 'e, A: sqlx::Acquire<'e, Database = Postgres
return Err(Error::internal_err("Require oauth2 feature".to_string()));
} else if !value.is_empty() {
let mc = build_crypt(&db, &w_id).await?;
decrypt(&mc, value)?
decrypt(&mc, value).map_err(|e| {
Error::internal_err(format!(
"Error decrypting variable {}: {}",
variable.path, e
))
})?
} else {
"".to_string()
}
@@ -873,7 +883,12 @@ pub async fn get_variable_or_self(path: String, db: &DB, w_id: &str) -> Result<S
let mut value = record.value;
if record.is_secret {
let mc = build_crypt(db, w_id).await?;
value = decrypt(&mc, value)?;
value = decrypt(&mc, value).map_err(|e| {
Error::internal_err(format!(
"Error decrypting variable {}: {}",
path, e
))
})?;
}
Ok(value)
+19 -84
View File
@@ -36,7 +36,6 @@ use windmill_audit::ActionKind;
use windmill_common::db::UserDB;
use windmill_common::s3_helpers::LargeFileStorage;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::ExportableListableVariable;
use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
#[cfg(feature = "enterprise")]
@@ -924,6 +923,7 @@ async fn get_copilot_info(
default_model: None,
code_completion_model: None,
custom_prompts: None,
max_tokens_per_model: None,
}))
}
}
@@ -2342,7 +2342,6 @@ async fn clone_workspace_data(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
db: &DB,
) -> Result<()> {
// Clone workspace settings (merge with existing basic settings)
update_workspace_settings(tx, source_workspace_id, target_workspace_id).await?;
@@ -2363,7 +2362,7 @@ async fn clone_workspace_data(
clone_resources(tx, source_workspace_id, target_workspace_id).await?;
// Clone variables with re-encryption
clone_variables(tx, source_workspace_id, target_workspace_id, db).await?;
clone_variables(tx, source_workspace_id, target_workspace_id).await?;
// Clone scripts with new hashes
clone_scripts(tx, source_workspace_id, target_workspace_id).await?;
@@ -2391,6 +2390,15 @@ async fn update_workspace_settings(
source_workspace_id: &str,
target_workspace_id: &str,
) -> Result<()> {
sqlx::query!(
"INSERT INTO workspace_key (workspace_id, kind, key)
SELECT $2, kind, key FROM workspace_key WHERE workspace_id = $1",
source_workspace_id,
target_workspace_id,
)
.execute(&mut **tx)
.await?;
sqlx::query!(
r#"
UPDATE workspace_settings
@@ -2544,81 +2552,18 @@ async fn clone_variables(
tx: &mut Transaction<'_, Postgres>,
source_workspace_id: &str,
target_workspace_id: &str,
db: &DB,
) -> Result<()> {
// Get all variables from source workspace
let variables = sqlx::query_as!(
ExportableListableVariable,
"SELECT workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at
FROM variable
sqlx::query!(
"INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at)
SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at
FROM variable
WHERE workspace_id = $1",
source_workspace_id
source_workspace_id,
target_workspace_id,
)
.fetch_all(&mut **tx)
.execute(&mut **tx)
.await?;
if variables.is_empty() {
return Ok(());
}
// Get workspace keys from within the transaction
let source_key = sqlx::query_scalar!(
"SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud'",
source_workspace_id
)
.fetch_one(db)
.await?;
let target_key = sqlx::query_scalar!(
"SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud'",
target_workspace_id
)
.fetch_one(&mut **tx)
.await?;
// Build encryption keys manually
use windmill_common::variables::SECRET_SALT;
let source_crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", source_key, salt)
} else {
source_key
};
let target_crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", target_key, salt)
} else {
target_key
};
let source_mc = magic_crypt::new_magic_crypt!(source_crypt_key, 256);
let target_mc = magic_crypt::new_magic_crypt!(target_crypt_key, 256);
// Process each variable
for var in variables {
let final_value = if var.is_secret && var.value.is_some() {
// Decrypt with source key and re-encrypt with target key
let decrypted_value = decrypt(&source_mc, var.value.unwrap())?;
Some(encrypt(&target_mc, &decrypted_value))
} else {
var.value
};
sqlx::query!(
"INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
target_workspace_id,
var.path,
final_value,
var.is_secret,
var.description,
var.extra_perms,
var.account,
var.is_oauth,
var.expires_at,
)
.execute(&mut **tx)
.await?;
}
Ok(())
}
@@ -2984,16 +2929,6 @@ async fn create_workspace_fork(
)
.execute(&mut *tx)
.await?;
let key = rd_string(64);
sqlx::query!(
"INSERT INTO workspace_key
(workspace_id, kind, key)
VALUES ($1, 'cloud', $2)",
forked_id,
&key
)
.execute(&mut *tx)
.await?;
sqlx::query!(
"INSERT INTO usr
@@ -3008,7 +2943,7 @@ async fn create_workspace_fork(
.await?;
// Clone all data from the parent workspace using Rust implementation
clone_workspace_data(&mut tx, &nw.parent_workspace_id, &forked_id, &db).await?;
clone_workspace_data(&mut tx, &nw.parent_workspace_id, &forked_id).await?;
sqlx::query!(
"INSERT INTO workspace_invite (workspace_id, email, is_admin, operator)
@@ -508,7 +508,12 @@ pub(crate) async fn tarball_workspace(
&& var.value.is_some()
&& var.is_secret
{
var.value = Some(decrypt(&mc, var.value.unwrap())?);
var.value = Some(decrypt(&mc, var.value.unwrap()).map_err(|e| {
Error::internal_err(format!(
"Error decrypting variable {}: {}",
var.path, e
))
})?);
}
let var_str = &to_string_without_metadata(&var, false, None).unwrap();
archive
@@ -10,6 +10,9 @@ 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)]
#[serde(rename_all = "lowercase")]
pub enum AIProvider {
@@ -78,6 +81,39 @@ impl AIProvider {
pub fn is_anthropic(&self) -> bool {
matches!(self, AIProvider::Anthropic)
}
/// Check if this provider/URL combination represents Azure OpenAI
pub fn is_azure_openai(&self, base_url: &str) -> bool {
(matches!(self, AIProvider::OpenAI) && base_url != OPENAI_BASE_URL)
|| matches!(self, AIProvider::AzureOpenAI)
}
/// Build Azure OpenAI URL with deployment model path
pub fn build_azure_openai_url(base_url: &str, model: &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)
} else {
format!("{}/{}", base_url, path)
}
}
/// Extract model from request body (needed for Azure deployments)
pub fn extract_model_from_body(body: &[u8]) -> Result<String> {
#[derive(serde::Deserialize)]
struct ModelRequest {
model: String,
}
let model_request: ModelRequest = serde_json::from_slice(body).map_err(|e| {
Error::internal_err(format!("Failed to parse request body for model: {}", e))
})?;
Ok(model_request.model)
}
}
impl TryFrom<&str> for AIProvider {
@@ -43,6 +43,8 @@ pub struct FlowStatus {
pub approval_conditions: Option<ApprovalConditions>,
#[serde(skip_serializing_if = "Option::is_none")]
pub restarted_from: Option<RestartedFrom>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream_job: Option<Uuid>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
@@ -442,6 +444,7 @@ impl FlowStatus {
retry: RetryStatus { fail_count: 0, failed_jobs: vec![] },
restarted_from: None,
user_states: HashMap::new(),
stream_job: None,
}
}
+11 -7
View File
@@ -146,10 +146,10 @@ impl FlowValue {
.preprocessor_module
.as_deref()
.with_context(|| format!("no preprocessor module")),
Step::Step(i) => self
Step::Step { idx, .. } => self
.modules
.get(i)
.with_context(|| format!("no module found at index: {i}")),
.get(idx)
.with_context(|| format!("no module found at index: {idx}")),
Step::FailureStep => self
.failure_module
.as_deref()
@@ -162,7 +162,7 @@ impl FlowValue {
#[derive(Debug, Copy, Clone)]
pub enum Step {
Step(usize),
Step { idx: usize, len: usize },
PreprocessorStep,
FailureStep,
}
@@ -172,7 +172,7 @@ impl Step {
if step < 0 {
Step::PreprocessorStep
} else if (step as usize) < len {
Step::Step(step as usize)
Step::Step { idx: step as usize, len }
} else {
Step::FailureStep
}
@@ -180,13 +180,13 @@ impl Step {
pub fn get_step_index(&self) -> Option<usize> {
match self {
Step::Step(index) => Some(*index),
Step::Step { idx, .. } => Some(*idx),
_ => None,
}
}
pub fn is_index_step(&self) -> bool {
matches!(self, Step::Step(_))
matches!(self, Step::Step { .. })
}
pub fn is_preprocessor_step(&self) -> bool {
@@ -196,6 +196,10 @@ impl Step {
pub fn is_failure_step(&self) -> bool {
matches!(self, Step::FailureStep)
}
pub fn is_last_step(&self) -> bool {
matches!(self, Step::Step { idx, len } if *idx == len - 1)
}
}
#[derive(Default, Deserialize, Serialize, Debug, Clone)]
+10 -5
View File
@@ -1,5 +1,5 @@
use uuid::Uuid;
use crate::{error, DB};
use uuid::Uuid;
pub const STREAM_PREFIX: &str = "WM_STREAM: ";
@@ -14,9 +14,12 @@ pub fn extract_stream_from_logs(line: &str) -> Option<String> {
None
}
pub async fn append_result_stream_db(db: &DB, workspace_id: &str, job_id: &Uuid, nstream: &str) -> error::Result<()> {
pub async fn append_result_stream_db(
db: &DB,
workspace_id: &str,
job_id: &Uuid,
nstream: &str,
) -> error::Result<()> {
if !nstream.is_empty() {
sqlx::query!(
r#"
@@ -27,7 +30,9 @@ pub async fn append_result_stream_db(db: &DB, workspace_id: &str, job_id: &Uuid,
workspace_id,
job_id,
nstream,
).execute(db).await?;
)
.execute(db)
.await?;
}
Ok(())
}
+6 -1
View File
@@ -164,7 +164,12 @@ pub async fn get_secret_value_as_admin(
let value = variable.value;
if !value.is_empty() {
let mc = build_crypt(db, w_id).await?;
decrypt(&mc, value)?
decrypt(&mc, value).map_err(|e| {
crate::error::Error::internal_err(format!(
"Error decrypting variable {}: {}",
variable.path, e
))
})?
} else {
"".to_string()
}
+6 -1
View File
@@ -268,7 +268,12 @@ async fn transform_json_unchecked(
.await
.map_err(to_anyhow)?;
let mc = build_crypt(&db, &w_id).await?;
let variable = decrypt(&mc, variable)?;
let variable = decrypt(&mc, variable).map_err(|e| {
Error::internal_err(format!(
"Error decrypting variable {}: {}",
&s, e
))
})?;
serde_json::Value::String(variable)
}
s @ serde_json::Value::String(_) => s.clone(),
+5 -2
View File
@@ -1,6 +1,9 @@
use uuid::Uuid;
use windmill_common::{
error::{self, Error}, flows::Step, utils::WarnAfterExt, DB
error::{self, Error},
flows::Step,
utils::WarnAfterExt,
DB,
};
pub async fn update_flow_status_in_progress(
@@ -11,7 +14,7 @@ pub async fn update_flow_status_in_progress(
) -> error::Result<Step> {
let step = get_step_of_flow_status(db, flow).await?;
match step {
Step::Step(step) => {
Step::Step { idx: step, .. } => {
sqlx::query!(
"UPDATE v2_job_status SET
flow_status = jsonb_set(
+2
View File
@@ -4098,6 +4098,7 @@ pub async fn push<'c, 'd>(
}),
user_states,
preprocessor_module: None,
stream_job: None,
}
}
_ => {
@@ -4352,6 +4353,7 @@ pub async fn push<'c, 'd>(
}),
user_states,
preprocessor_module: None,
stream_job: None,
};
let value = flow_data.value();
let priority = value.priority;
+1
View File
@@ -67,6 +67,7 @@ serde.workspace = true
serde_json.workspace = true
futures.workspace = true
async-recursion.workspace = true
async-trait.workspace = true
anyhow.workspace = true
itertools.workspace = true
regex.workspace = true
@@ -0,0 +1,68 @@
use base64::Engine;
use futures;
use ulid;
use windmill_common::{client::AuthedClient, error::Error, s3_helpers::S3Object};
use windmill_queue::MiniPulledJob;
/// Upload image to S3 and return S3Object
pub async fn upload_image_to_s3(
base64_image: &str,
job: &MiniPulledJob,
client: &AuthedClient,
) -> Result<S3Object, Error> {
let image_bytes = base64::engine::general_purpose::STANDARD
.decode(base64_image)
.map_err(|e| Error::internal_err(format!("Failed to decode base64 image: {}", e)))?;
// Generate unique S3 key
let unique_id = ulid::Ulid::new().to_string();
let s3_key = format!("ai_images/{}/{}.png", job.id, unique_id);
// Create byte stream
let byte_stream = futures::stream::once(async move {
Ok::<_, std::convert::Infallible>(bytes::Bytes::from(image_bytes))
});
// Upload to S3
client
.upload_s3_file(
&job.workspace_id,
s3_key.clone(),
None, // storage - use default
byte_stream,
)
.await
.map_err(|e| Error::internal_err(format!("Failed to upload image to S3: {}", e)))?;
Ok(S3Object {
s3: s3_key,
storage: None,
filename: Some("generated_image.png".to_string()),
presigned: None,
})
}
/// Download an S3 image and convert it to a base64 data URL
pub async fn download_and_encode_s3_image(
image: &S3Object,
client: &AuthedClient,
workspace_id: &str,
) -> Result<(String, String), Error> {
// Download the image from S3
let image_bytes = client
.download_s3_file(workspace_id, &image.s3, image.storage.clone())
.await
.map_err(|e| Error::internal_err(format!("Failed to download S3 image: {}", e)))?;
// Encode as base64 data URL
let base64_data = base64::engine::general_purpose::STANDARD.encode(&image_bytes);
// Determine MIME type using mime_guess from file extension, with PNG as fallback
let mime_type = mime_guess::from_path(&image.s3).first();
let mime_type = mime_type
.as_ref()
.map(|mime| mime.essence_str())
.unwrap_or("image/png");
Ok((mime_type.to_string(), base64_data))
}
+7
View File
@@ -0,0 +1,7 @@
// AI executor module structure
// This module will contain all AI-related execution logic
pub mod image_handler;
pub mod providers;
pub mod query_builder;
pub mod types;
@@ -0,0 +1,276 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json;
use windmill_common::{ai_providers::AIProvider, client::AuthedClient, error::Error};
use crate::ai::{
image_handler::download_and_encode_s3_image,
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder},
types::*,
};
// Google AI/Gemini-specific types
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct GeminiInlineData {
#[serde(rename = "mimeType")]
pub mime_type: String,
pub data: String,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(untagged)]
pub enum GeminiPart {
Text { text: String },
InlineData { inline_data: GeminiInlineData },
FunctionCall { function_call: GeminiFunctionCall },
FunctionResponse { function_response: GeminiFunctionResponse },
}
#[derive(Serialize, Deserialize, Clone)]
pub struct GeminiFunctionCall {
pub name: String,
pub args: serde_json::Value,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct GeminiFunctionResponse {
pub name: String,
pub response: serde_json::Value,
}
#[derive(Serialize)]
pub struct GeminiContent {
pub parts: Vec<GeminiPart>,
}
#[derive(Serialize)]
pub struct GeminiImageRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub contents: Option<Vec<GeminiContent>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instances: Option<Vec<GeminiPredictContent>>,
}
#[derive(Serialize)]
pub struct GeminiPredictContent {
pub prompt: String,
}
#[derive(Deserialize)]
pub struct GeminiImageResponse {
#[serde(skip_serializing_if = "Option::is_none")]
pub candidates: Option<Vec<GeminiCandidate>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub predictions: Option<Vec<GeminiPredictCandidate>>,
}
#[derive(Deserialize)]
pub struct GeminiCandidate {
pub content: GeminiResponseContent,
}
#[derive(Deserialize)]
pub struct GeminiPredictCandidate {
#[serde(rename = "bytesBase64Encoded")]
pub bytes_base64_encoded: String, // base64 encoded image
}
#[derive(Deserialize)]
pub struct GeminiResponseContent {
pub parts: Vec<GeminiResponsePart>,
}
#[derive(Deserialize)]
pub struct GeminiResponsePart {
#[serde(skip_serializing_if = "Option::is_none")]
#[allow(dead_code)]
pub text: Option<String>,
#[serde(rename = "inlineData", skip_serializing_if = "Option::is_none")]
pub inline_data: Option<GeminiInlineData>,
#[serde(rename = "functionCall", skip_serializing_if = "Option::is_none")]
#[allow(dead_code)]
pub function_call: Option<GeminiFunctionCall>,
}
pub struct GoogleAIQueryBuilder;
impl GoogleAIQueryBuilder {
pub fn new() -> Self {
Self
}
async fn build_image_request(
&self,
args: &BuildRequestArgs<'_>,
client: &AuthedClient,
workspace_id: &str,
) -> Result<String, Error> {
let is_imagen = args.model.contains("imagen");
let request = if is_imagen {
// For Imagen models, use simple prompt format
GeminiImageRequest {
instances: Some(vec![GeminiPredictContent {
prompt: args.user_message.trim().to_string(),
}]),
contents: None,
}
} else {
// For Gemini models with image generation, build parts
let mut parts = vec![GeminiPart::Text { text: args.user_message.trim().to_string() }];
if let Some(system_prompt) = args.system_prompt {
parts.insert(
0,
GeminiPart::Text { text: format!("SYSTEM PROMPT: {}", system_prompt.trim()) },
);
}
// Add input images if provided
if let Some(images) = args.images {
for image in images.iter() {
if !image.s3.is_empty() {
let (mime_type, image_bytes) =
download_and_encode_s3_image(image, client, workspace_id).await?;
parts.push(GeminiPart::InlineData {
inline_data: GeminiInlineData {
mime_type: mime_type,
data: image_bytes,
},
});
}
}
}
GeminiImageRequest { instances: None, contents: Some(vec![GeminiContent { parts }]) }
};
serde_json::to_string(&request)
.map_err(|e| Error::internal_err(format!("Failed to serialize request: {}", e)))
}
}
#[async_trait]
impl QueryBuilder for GoogleAIQueryBuilder {
fn supports_tools_with_output_type(&self, output_type: &OutputType) -> bool {
// Google AI supports tools only for text output
matches!(output_type, OutputType::Text)
}
async fn build_request(
&self,
args: &BuildRequestArgs<'_>,
client: &AuthedClient,
workspace_id: &str,
) -> Result<String, Error> {
match args.output_type {
OutputType::Text => {
// For text output, use OpenAI-compatible format
let openai_builder = super::openai::OpenAIQueryBuilder::new(AIProvider::GoogleAI);
openai_builder
.build_request(args, client, workspace_id)
.await
}
OutputType::Image => self.build_image_request(args, client, workspace_id).await,
}
}
async fn parse_response(&self, response: reqwest::Response) -> Result<ParsedResponse, Error> {
let url = response.url().path();
// For chat completions (text), use OpenAI parser
if url.contains("/chat/completions") {
let openai_builder = super::openai::OpenAIQueryBuilder::new(AIProvider::GoogleAI);
return openai_builder.parse_response(response).await;
}
// Check if this is an image generation response
if url.contains(":predict") || url.contains(":generateContent") {
let response_text = response
.text()
.await
.map_err(|e| Error::internal_err(format!("Failed to read response text: {}", e)))?;
let gemini_response: GeminiImageResponse = serde_json::from_str(&response_text)
.map_err(|e| {
Error::internal_err(format!(
"Failed to parse Gemini response: {}. Raw response: {}",
e, response_text
))
})?;
// Find image data in response
let image_data = gemini_response
.candidates
.as_ref()
.and_then(|candidates| {
candidates.iter().find_map(|candidate| {
candidate
.content
.parts
.iter()
.find_map(|part| part.inline_data.as_ref().map(|data| &data.data))
})
})
.or_else(|| {
gemini_response
.predictions
.as_ref()
.and_then(|predictions| {
predictions
.iter()
.find_map(|prediction| Some(&prediction.bytes_base64_encoded))
})
});
if let Some(base64_image) = image_data {
Ok(ParsedResponse::Image { base64_data: base64_image.clone() })
} else {
Err(Error::internal_err(
"No image data received from Gemini".to_string(),
))
}
} else {
// This should not happen as we use OpenAI format for text
Err(Error::internal_err(
"Unexpected text response in Google AI parser".to_string(),
))
}
}
fn get_endpoint(&self, base_url: &str, model: &str, output_type: &OutputType) -> String {
match output_type {
OutputType::Text => format!("{}/chat/completions", base_url), // Use OpenAI-compatible endpoint
OutputType::Image => {
// For image generation, build the full URL with model name
let url_suffix = if model.contains("imagen") {
"predict"
} else {
"generateContent"
};
format!(
"https://generativelanguage.googleapis.com/v1beta/models/{}:{}",
model, url_suffix
)
}
}
}
fn get_auth_headers(
&self,
api_key: &str,
_base_url: &str,
output_type: &OutputType,
) -> Vec<(&'static str, String)> {
match output_type {
OutputType::Text => {
// For text output, use Bearer token (OpenAI-compatible)
vec![("Authorization", format!("Bearer {}", api_key))]
}
OutputType::Image => {
// For image generation, use Google API key header
vec![("x-goog-api-key", api_key.to_string())]
}
}
}
}
@@ -0,0 +1,3 @@
pub mod google_ai;
pub mod openai;
pub mod openrouter;
@@ -0,0 +1,362 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json;
use windmill_common::{ai_providers::AIProvider, client::AuthedClient, error::Error};
use crate::ai::{
image_handler::download_and_encode_s3_image,
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder},
types::*,
};
// OpenAI-specific types
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct OpenAIFunction {
pub name: String,
pub arguments: String,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
pub struct OpenAIToolCall {
pub id: String,
pub function: OpenAIFunction,
pub r#type: String,
}
#[derive(Deserialize)]
pub struct OpenAIChoice {
pub message: OpenAIMessage,
}
#[derive(Deserialize)]
pub struct OpenAIResponse {
pub choices: Vec<OpenAIChoice>,
}
#[derive(Serialize)]
pub struct ImageGenerationTool {
pub r#type: String,
pub quality: Option<String>,
pub background: Option<String>,
}
// Input content for image generation - supports both text and images
#[derive(Serialize, Clone, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ImageGenerationContent {
#[serde(rename = "input_text")]
InputText { text: String },
#[serde(rename = "input_image")]
InputImage { image_url: String },
}
#[derive(Serialize)]
pub struct ImageGenerationMessage {
pub role: String,
pub content: Vec<ImageGenerationContent>,
}
#[derive(Serialize)]
pub struct ImageGenerationRequest<'a> {
pub model: &'a str,
pub input: Vec<ImageGenerationMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub instructions: Option<&'a str>,
pub tools: Vec<ImageGenerationTool>,
}
#[derive(Deserialize)]
pub struct OpenAIImageResponse {
pub output: Vec<OpenAIImageOutput>,
}
#[derive(Deserialize)]
pub struct OpenAIImageOutput {
pub r#type: String, // Expected to be "image_generation_call"
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<String>, // Base64 encoded image, None if not completed
}
#[derive(Serialize)]
pub struct OpenAIRequest<'a> {
pub model: &'a str,
pub messages: &'a [OpenAIMessage],
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<&'a [ToolDef]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
}
pub struct OpenAIQueryBuilder {
provider_kind: AIProvider,
}
impl OpenAIQueryBuilder {
pub fn new(provider_kind: AIProvider) -> Self {
Self { provider_kind }
}
pub async fn prepare_messages_for_api(
&self,
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)
}
async fn build_text_request(
&self,
args: &BuildRequestArgs<'_>,
client: &AuthedClient,
workspace_id: &str,
) -> Result<String, Error> {
let prepared_messages = self
.prepare_messages_for_api(args.messages, client, workspace_id)
.await?;
// Check if we need to add response_format for structured output
let has_output_properties = args
.output_schema
.and_then(|schema| schema.properties.as_ref())
.map(|props| !props.is_empty())
.unwrap_or(false);
let response_format = if has_output_properties && args.output_schema.is_some() {
let schema = args.output_schema.unwrap();
let strict_schema = schema.clone().make_strict();
Some(ResponseFormat {
r#type: "json_schema".to_string(),
json_schema: JsonSchemaFormat {
name: "structured_output".to_string(),
schema: strict_schema,
strict: Some(true),
},
})
} else {
None
};
let request = OpenAIRequest {
model: args.model,
messages: &prepared_messages,
tools: args.tools,
temperature: args.temperature,
max_completion_tokens: args.max_tokens,
response_format,
};
serde_json::to_string(&request)
.map_err(|e| Error::internal_err(format!("Failed to serialize request: {}", e)))
}
async fn build_image_request(
&self,
args: &BuildRequestArgs<'_>,
client: &AuthedClient,
workspace_id: &str,
) -> Result<String, Error> {
// Build content array with text and optional image
let mut content =
vec![ImageGenerationContent::InputText { text: args.user_message.to_string() }];
// Add images if provided
if let Some(images) = args.images {
for image in images.iter() {
if !image.s3.is_empty() {
let (mime_type, image_bytes) =
download_and_encode_s3_image(image, client, workspace_id).await?;
content.push(ImageGenerationContent::InputImage {
image_url: format!("data:{};base64,{}", mime_type, image_bytes),
});
}
}
}
// Build the request with tools if provided
let tools = vec![ImageGenerationTool {
r#type: "image_generation".to_string(),
quality: Some("low".to_string()),
background: None,
}];
// TODO: OpenAI's image generation API doesn't support custom tools in the same way as chat completions
// This would require a different approach, potentially using chat completions with image output
// For now, we'll use the standard image generation without custom tools
let image_request = ImageGenerationRequest {
model: args.model,
input: vec![ImageGenerationMessage { role: "user".to_string(), content }],
instructions: args.system_prompt,
tools,
};
serde_json::to_string(&image_request)
.map_err(|e| Error::internal_err(format!("Failed to serialize request: {}", e)))
}
}
#[async_trait]
impl QueryBuilder for OpenAIQueryBuilder {
fn supports_tools_with_output_type(&self, _output_type: &OutputType) -> bool {
// OpenAI supports tools for both text and image output
true
}
async fn build_request(
&self,
args: &BuildRequestArgs<'_>,
client: &AuthedClient,
workspace_id: &str,
) -> Result<String, Error> {
match args.output_type {
OutputType::Text => self.build_text_request(args, client, workspace_id).await,
OutputType::Image => self.build_image_request(args, client, workspace_id).await,
}
}
async fn parse_response(&self, response: reqwest::Response) -> Result<ParsedResponse, Error> {
// Check if this is an image response
let url = response.url().path();
if url.contains("/responses") {
// Parse image generation response
let response_text = response
.text()
.await
.map_err(|e| Error::internal_err(format!("Failed to read response text: {}", e)))?;
let image_response: OpenAIImageResponse = serde_json::from_str(&response_text)
.map_err(|e| {
Error::internal_err(format!(
"Failed to parse OpenAI image response: {}. Raw response: {}",
e, response_text
))
})?;
// Find the first completed image generation output
let image_generation_call = image_response
.output
.iter()
.find(|output| {
output.r#type == "image_generation_call" && output.status == "completed"
})
.and_then(|output| output.result.as_ref());
if let Some(base64_image) = image_generation_call {
Ok(ParsedResponse::Image { base64_data: base64_image.clone() })
} else {
Err(Error::internal_err(
"No completed image output received from OpenAI".to_string(),
))
}
} else {
// Parse text/chat completion response
let openai_response: OpenAIResponse = response
.json()
.await
.map_err(|e| Error::internal_err(format!("Failed to parse response: {}", e)))?;
let first_choice = openai_response
.choices
.into_iter()
.next()
.ok_or_else(|| Error::internal_err("No response from API"))?;
Ok(ParsedResponse::Text {
content: first_choice.message.content.map(|c| match c {
OpenAIContent::Text(text) => text,
OpenAIContent::Parts(parts) => {
// Extract text from parts
parts
.into_iter()
.filter_map(|part| match part {
ContentPart::Text { text } => Some(text),
_ => None,
})
.collect::<Vec<_>>()
.join(" ")
}
}),
tool_calls: first_choice.message.tool_calls.unwrap_or_default(),
})
}
}
fn get_endpoint(&self, base_url: &str, model: &str, output_type: &OutputType) -> String {
let path = match output_type {
OutputType::Text => "chat/completions",
OutputType::Image => "responses",
};
if self.provider_kind.is_azure_openai(base_url) {
AIProvider::build_azure_openai_url(base_url, model, path)
} else {
format!("{}/{}", base_url, path)
}
}
fn get_auth_headers(
&self,
api_key: &str,
base_url: &str,
_output_type: &OutputType,
) -> Vec<(&'static str, String)> {
if self.provider_kind.is_azure_openai(base_url) {
vec![("api-key", api_key.to_string())]
} else {
vec![("Authorization", format!("Bearer {}", api_key))]
}
}
}
@@ -0,0 +1,203 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json;
use windmill_common::{ai_providers::AIProvider, client::AuthedClient, error::Error};
use crate::ai::{
providers::openai::{OpenAIQueryBuilder, OpenAIResponse},
query_builder::{BuildRequestArgs, ParsedResponse, QueryBuilder},
types::*,
};
// OpenRouter-specific types
#[derive(Serialize)]
pub struct OpenRouterChatRequest<'a> {
pub model: &'a str,
pub messages: &'a [OpenAIMessage],
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<&'a [ToolDef]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_completion_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response_format: Option<ResponseFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub modalities: Option<Vec<&'a str>>,
}
#[derive(Deserialize)]
pub struct OpenRouterImageResponse {
pub choices: Vec<OpenRouterImageChoice>,
}
#[derive(Deserialize)]
pub struct OpenRouterImageChoice {
pub message: OpenRouterImageResponseMessage,
}
#[derive(Deserialize)]
pub struct OpenRouterImageResponseMessage {
#[serde(skip_serializing_if = "Option::is_none")]
pub images: Option<Vec<OpenRouterImageData>>,
}
#[derive(Deserialize)]
pub struct OpenRouterImageData {
pub image_url: OpenRouterImageUrl,
}
#[derive(Deserialize)]
pub struct OpenRouterImageUrl {
pub url: String, // data:image/png;base64,... format
}
pub struct OpenRouterQueryBuilder {
// OpenRouter uses OpenAI-compatible API, so we delegate most work to OpenAI builder
openai_builder: OpenAIQueryBuilder,
}
impl OpenRouterQueryBuilder {
pub fn new() -> Self {
Self { openai_builder: OpenAIQueryBuilder::new(AIProvider::OpenRouter) }
}
}
#[async_trait]
impl QueryBuilder for OpenRouterQueryBuilder {
fn supports_tools_with_output_type(&self, _output_type: &OutputType) -> bool {
// OpenRouter supports tools for both text and image output (via OpenAI-compatible API)
true
}
async fn build_request(
&self,
args: &BuildRequestArgs<'_>,
client: &AuthedClient,
workspace_id: &str,
) -> Result<String, Error> {
match args.output_type {
OutputType::Text => {
// For text, use standard OpenAI format without modalities
self.openai_builder
.build_request(args, client, workspace_id)
.await
}
OutputType::Image => {
// For image generation, we need to add modalities field
// First, prepare the messages using the OpenAI builder's logic
let openai_builder = &self.openai_builder;
let prepared_messages = openai_builder
.prepare_messages_for_api(args.messages, client, workspace_id)
.await?;
// Check if we need to add response_format for structured output
let has_output_properties = args
.output_schema
.and_then(|schema| schema.properties.as_ref())
.map(|props| !props.is_empty())
.unwrap_or(false);
let response_format = if has_output_properties && args.output_schema.is_some() {
let schema = args.output_schema.unwrap();
let strict_schema = schema.clone().make_strict();
Some(ResponseFormat {
r#type: "json_schema".to_string(),
json_schema: JsonSchemaFormat {
name: "structured_output".to_string(),
schema: strict_schema,
strict: Some(true),
},
})
} else {
None
};
// Build OpenRouter-specific request with modalities
let request = OpenRouterChatRequest {
model: args.model,
messages: &prepared_messages,
tools: args.tools,
temperature: args.temperature,
max_completion_tokens: args.max_tokens,
response_format,
modalities: Some(vec!["image", "text"]),
};
serde_json::to_string(&request)
.map_err(|e| Error::internal_err(format!("Failed to serialize request: {}", e)))
}
}
}
async fn parse_response(&self, response: reqwest::Response) -> Result<ParsedResponse, Error> {
let response_text = response
.text()
.await
.map_err(|e| Error::internal_err(format!("Failed to read response text: {}", e)))?;
// First try to parse as OpenRouter image response
if let Ok(image_response) = serde_json::from_str::<OpenRouterImageResponse>(&response_text)
{
// Extract base64 image from the first choice
let image_url = image_response
.choices
.get(0)
.and_then(|choice| choice.message.images.as_ref())
.and_then(|images| images.get(0))
.map(|image| &image.image_url.url);
if let Some(data_url) = image_url {
// Extract base64 data from data URL format: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
if let Some(base64_start) = data_url.find("base64,") {
let base64_data = &data_url[base64_start + 7..]; // Skip "base64," prefix
return Ok(ParsedResponse::Image { base64_data: base64_data.to_string() });
}
}
}
// If not an image response or parsing failed, try as regular OpenAI response
let openai_response: OpenAIResponse =
serde_json::from_str(&response_text).map_err(|e| {
Error::internal_err(format!(
"Failed to parse response: {}. Raw response: {}",
e, response_text
))
})?;
let first_choice = openai_response
.choices
.into_iter()
.next()
.ok_or_else(|| Error::internal_err("No response from API"))?;
Ok(ParsedResponse::Text {
content: first_choice.message.content.map(|c| match c {
OpenAIContent::Text(text) => text,
OpenAIContent::Parts(parts) => parts
.into_iter()
.filter_map(|part| match part {
ContentPart::Text { text } => Some(text),
_ => None,
})
.collect::<Vec<_>>()
.join(" "),
}),
tool_calls: first_choice.message.tool_calls.unwrap_or_default(),
})
}
fn get_endpoint(&self, base_url: &str, _model: &str, _output_type: &OutputType) -> String {
// OpenRouter uses the same endpoint for both text and image generation
format!("{}/chat/completions", base_url)
}
fn get_auth_headers(
&self,
api_key: &str,
_base_url: &str,
_output_type: &OutputType,
) -> Vec<(&'static str, String)> {
vec![("Authorization", format!("Bearer {}", api_key))]
}
}
@@ -0,0 +1,71 @@
use async_trait::async_trait;
use windmill_common::{client::AuthedClient, error::Error, s3_helpers::S3Object};
use crate::ai::{
providers::{
google_ai::GoogleAIQueryBuilder,
openai::{OpenAIQueryBuilder, OpenAIToolCall},
openrouter::OpenRouterQueryBuilder,
},
types::*,
};
/// Arguments for building an AI request
pub struct BuildRequestArgs<'a> {
pub messages: &'a [OpenAIMessage],
pub tools: Option<&'a [ToolDef]>,
pub model: &'a str,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
pub output_schema: Option<&'a OpenAPISchema>,
pub output_type: &'a OutputType,
pub system_prompt: Option<&'a str>,
pub user_message: &'a str,
pub images: Option<&'a [S3Object]>,
}
/// Response from AI provider
pub enum ParsedResponse {
Text { content: Option<String>, tool_calls: Vec<OpenAIToolCall> },
Image { base64_data: String },
}
/// Trait for building provider-specific AI requests
#[async_trait]
pub trait QueryBuilder: Send + Sync {
/// Check if this provider supports tools with the given output type
fn supports_tools_with_output_type(&self, output_type: &OutputType) -> bool;
/// Build the request body for the provider
async fn build_request(
&self,
args: &BuildRequestArgs<'_>,
client: &AuthedClient,
workspace_id: &str,
) -> Result<String, Error>;
/// Parse the response from the provider
async fn parse_response(&self, response: reqwest::Response) -> Result<ParsedResponse, Error>;
/// Get the API endpoint for this provider
fn get_endpoint(&self, base_url: &str, model: &str, output_type: &OutputType) -> String;
/// Get the authentication headers for this provider
fn get_auth_headers(
&self,
api_key: &str,
base_url: &str,
output_type: &OutputType,
) -> Vec<(&'static str, String)>;
}
/// Factory function to create the appropriate query builder for a provider
pub fn create_query_builder(provider: &ProviderWithResource) -> Box<dyn QueryBuilder> {
use windmill_common::ai_providers::AIProvider;
match provider.kind {
AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new()),
AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()),
_ => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())), // Pass provider kind for Azure handling
}
}
+368
View File
@@ -0,0 +1,368 @@
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::collections::HashMap;
use windmill_common::{
ai_providers::AIProvider, db::DB, error::Error, flow_status::AgentAction, flows::FlowModule,
s3_helpers::S3Object,
};
use windmill_parser::Typ;
use crate::ai::providers::openai::OpenAIToolCall;
// Shared types used across multiple providers
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ContentPart {
Text {
text: String,
},
#[serde(rename = "image_url")]
ImageUrl {
image_url: ImageUrlData,
},
#[serde(rename = "s3_object")]
S3Object {
s3_object: S3Object,
},
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ImageUrlData {
pub url: String, // data:image/png;base64,... or https://...
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(untagged)]
pub enum OpenAIContent {
Text(String),
Parts(Vec<ContentPart>),
}
#[derive(Deserialize, Serialize, Clone, Default, Debug)]
pub struct OpenAIMessage {
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<OpenAIContent>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<OpenAIToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[serde(skip_serializing)]
pub agent_action: Option<AgentAction>,
}
/// same as OpenAIMessage but with agent_action field included in the serialization
#[derive(Serialize)]
pub struct Message<'a> {
#[serde(flatten)]
pub message: &'a OpenAIMessage,
#[serde(skip_serializing_if = "Option::is_none")]
pub agent_action: Option<&'a AgentAction>,
}
#[derive(Serialize, Clone, Debug)]
pub struct ResponseFormat {
pub r#type: String,
pub json_schema: JsonSchemaFormat,
}
#[derive(Serialize, Clone, Debug)]
pub struct JsonSchemaFormat {
pub name: String,
pub schema: OpenAPISchema,
#[serde(skip_serializing_if = "Option::is_none")]
pub strict: Option<bool>,
}
#[derive(Serialize, Clone, Debug)]
pub struct ToolDefFunction {
pub name: String,
pub description: Option<String>,
pub parameters: Box<RawValue>,
}
#[derive(Serialize, Clone, Debug)]
pub struct ToolDef {
pub r#type: String,
pub function: ToolDefFunction,
}
pub struct Tool {
pub module: FlowModule,
pub def: ToolDef,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum OutputType {
Text,
Image,
}
impl Default for OutputType {
fn default() -> Self {
OutputType::Text
}
}
#[derive(Deserialize, Debug)]
pub struct AIAgentArgs {
pub provider: ProviderWithResource,
pub system_prompt: Option<String>,
pub user_message: String,
pub temperature: Option<f32>,
pub max_completion_tokens: Option<u32>,
pub output_schema: Option<OpenAPISchema>,
pub output_type: Option<OutputType>,
pub user_images: Option<Vec<S3Object>>,
}
#[derive(Deserialize, Debug)]
pub struct ProviderResource {
#[serde(alias = "apiKey")]
pub api_key: String,
#[serde(alias = "baseUrl")]
pub base_url: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct ProviderWithResource {
pub kind: AIProvider,
pub resource: ProviderResource,
pub model: String,
}
impl ProviderWithResource {
pub fn get_api_key(&self) -> &str {
&self.resource.api_key
}
pub fn get_model(&self) -> &str {
&self.model
}
pub async fn get_base_url(&self, db: &DB) -> Result<String, Error> {
self.kind
.get_base_url(self.resource.base_url.clone(), db)
.await
}
}
#[derive(Serialize)]
pub struct AIAgentResult<'a> {
pub output: Box<RawValue>,
pub messages: Vec<Message<'a>>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(untagged)]
pub enum SchemaType {
Single(String),
Multiple(Vec<String>),
}
impl Default for SchemaType {
fn default() -> Self {
SchemaType::Single("object".to_string())
}
}
#[derive(Serialize, Deserialize, Default, Clone, Debug)]
pub struct OpenAPISchema {
#[serde(skip_serializing_if = "Option::is_none")]
pub r#type: Option<SchemaType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub items: Option<Box<OpenAPISchema>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub properties: Option<HashMap<String, Box<OpenAPISchema>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub required: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none", rename = "oneOf")]
pub one_of: Option<Vec<Box<OpenAPISchema>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub r#enum: Option<Vec<String>>,
#[serde(
skip_serializing_if = "Option::is_none",
rename = "additionalProperties"
)]
pub additional_properties: Option<bool>,
}
impl OpenAPISchema {
pub fn from_str(typ: &str) -> Self {
OpenAPISchema { r#type: Some(SchemaType::Single(typ.to_string())), ..Default::default() }
}
pub fn from_str_with_enum(typ: &str, enu: &Option<Vec<String>>) -> Self {
OpenAPISchema {
r#type: Some(SchemaType::Single(typ.to_string())),
r#enum: enu.clone(),
..Default::default()
}
}
pub fn datetime() -> Self {
Self {
r#type: Some(SchemaType::Single("string".to_string())),
format: Some("date-time".to_string()),
..Default::default()
}
}
pub fn from_typ(typ: &Typ) -> Self {
match typ {
Typ::Str(enu) => Self::from_str_with_enum("string", enu),
Typ::Int => Self::from_str("integer"),
Typ::Float => Self::from_str("number"),
Typ::Bool => Self::from_str("boolean"),
Typ::Bytes => Self::from_str("string"),
Typ::Datetime => Self::datetime(),
Typ::Resource(_) => Self::from_str("string"),
Typ::Email => Self::from_str("string"),
Typ::Sql => Self::from_str("string"),
Typ::DynSelect(_) => Self::from_str("string"),
Typ::DynMultiselect(_) => Self::from_str("string"),
Typ::List(typ) => OpenAPISchema {
r#type: Some(SchemaType::Single("array".to_string())),
items: Some(Box::new(Self::from_typ(typ))),
..Default::default()
},
Typ::Object(typ) => OpenAPISchema {
r#type: Some(SchemaType::Single("object".to_string())),
items: None,
properties: typ.props.as_ref().map(|props| {
props
.iter()
.map(|prop| (prop.key.clone(), Box::new(Self::from_typ(&prop.typ))))
.collect()
}),
required: typ
.props
.as_ref()
.map(|props| props.iter().map(|prop| prop.key.clone()).collect()),
..Default::default()
},
Typ::OneOf(variants) => OpenAPISchema {
r#type: Some(SchemaType::Single("object".to_string())),
one_of: Some(
variants
.iter()
.map(|variant| {
let schema = OpenAPISchema {
r#type: Some(SchemaType::Single("object".to_string())),
properties: Some(
variant
.properties
.iter()
.map(|prop| {
(
prop.key.clone(),
Box::new(
if prop.key == "label" || prop.key == "kind" {
Self::from_str_with_enum(
"string",
&Some(vec![variant.label.clone()]),
)
} else {
Self::from_typ(&prop.typ)
},
),
)
})
.collect(),
),
required: Some(
variant
.properties
.iter()
.map(|prop| prop.key.clone())
.collect(),
),
..Default::default()
};
Box::new(schema)
})
.collect(),
),
..Default::default()
},
Typ::Unknown => Self::from_str("object"),
}
}
/// Makes this schema compatible with OpenAI's strict mode by:
/// - Adding additionalProperties: false to all object types
/// - Making non-required properties nullable
/// - Ensuring all properties are in the required array
pub fn make_strict(mut self) -> Self {
// Handle this schema if it's an object type
if let Some(SchemaType::Single(ref type_str)) = self.r#type {
if type_str == "object" {
// Set additionalProperties to false
self.additional_properties = Some(false);
if let Some(properties) = self.properties.as_mut() {
// Get original required fields
let original_required = self.required.as_ref();
if let Some(required) = original_required {
// Update properties to make non-required fields nullable
for (key, prop) in properties.iter_mut() {
let mut new_prop = (**prop).clone();
// Make non-required fields nullable
if !required.contains(key) {
new_prop = new_prop.make_nullable();
}
// Recursively make nested schemas strict
new_prop = new_prop.make_strict();
*prop = Box::new(new_prop);
}
}
// All properties must be in required array for strict mode
self.required = Some(properties.keys().cloned().collect());
}
}
}
// Recursively process nested schemas
if let Some(ref mut items) = self.items {
**items = items.as_ref().clone().make_strict();
}
if let Some(ref mut one_of) = self.one_of {
*one_of = one_of
.iter()
.map(|schema| Box::new(schema.as_ref().clone().make_strict()))
.collect();
}
self
}
/// Makes this property nullable by converting its type to a union with null
pub fn make_nullable(mut self) -> Self {
match self.r#type.take() {
Some(SchemaType::Single(type_str)) => {
if type_str != "null" {
self.r#type = Some(SchemaType::Multiple(vec![type_str, "null".into()]));
} else {
self.r#type = Some(SchemaType::Single("null".into()));
}
}
Some(SchemaType::Multiple(mut types)) => {
if !types.iter().any(|t| t == "null") {
types.push("null".into());
}
self.r#type = Some(SchemaType::Multiple(types));
}
None => {
self.r#type = Some(SchemaType::Single("null".into()));
}
}
self
}
}
File diff suppressed because it is too large Load Diff
@@ -93,6 +93,7 @@ async fn clone_repo(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -112,7 +113,8 @@ async fn clone_repo(
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let checkout_cmd_child = start_child_process(checkout_cmd, GIT_PATH.as_str(), false).await?;
let checkout_cmd_child =
start_child_process(checkout_cmd, GIT_PATH.as_str(), false).await?;
handle_child(
job_id,
conn,
@@ -127,6 +129,7 @@ async fn clone_repo(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
}
@@ -232,6 +235,7 @@ async fn clone_repo_without_history(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -249,7 +253,8 @@ async fn clone_repo_without_history(
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let add_remote_cmd_child = start_child_process(add_remote_cmd, GIT_PATH.as_str(), false).await?;
let add_remote_cmd_child =
start_child_process(add_remote_cmd, GIT_PATH.as_str(), false).await?;
handle_child(
job_id,
conn,
@@ -264,6 +269,7 @@ async fn clone_repo_without_history(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -296,6 +302,7 @@ async fn clone_repo_without_history(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -328,6 +335,7 @@ async fn clone_repo_without_history(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -462,6 +470,7 @@ pub async fn install_galaxy_collections(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -484,7 +493,8 @@ pub async fn install_galaxy_collections(
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = start_child_process(galaxy_collections_cmd, ANSIBLE_GALAXY_PATH.as_str(), false).await?;
let child =
start_child_process(galaxy_collections_cmd, ANSIBLE_GALAXY_PATH.as_str(), false).await?;
handle_child(
job_id,
conn,
@@ -499,6 +509,7 @@ pub async fn install_galaxy_collections(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -1133,6 +1144,7 @@ fi
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
read_and_check_result(job_dir).await
@@ -231,6 +231,7 @@ exit $exit_status
true,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -647,6 +648,7 @@ pub async fn handle_powershell_job(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
}
@@ -871,6 +873,7 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"",
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -18,6 +18,7 @@ use crate::{
common::{
create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file,
read_file_content, read_result, start_child_process, write_file_binary, OccupancyMetrics,
StreamNotifier,
},
handle_child::handle_child,
BUNFIG_INSTALL_SCOPES, BUN_BUNDLE_CACHE_DIR, BUN_CACHE_DIR, BUN_NO_CACHE, BUN_PATH,
@@ -168,6 +169,7 @@ pub async fn gen_bun_lockfile(
false,
occupancy_metrics,
None,
None,
)
.await?;
} else {
@@ -374,6 +376,7 @@ pub async fn install_bun_lockfile(
false,
occupancy_metrics,
None,
None,
)
.await?;
} else {
@@ -546,6 +549,7 @@ pub async fn generate_wrapper_mjs(
false,
occupancy_metrics,
None,
None,
)
.await?;
fs::rename(
@@ -597,6 +601,7 @@ pub async fn generate_bun_bundle(
false,
occupancy_metrics,
None,
None,
)
.await?;
} else {
@@ -1308,6 +1313,8 @@ try {{
append_logs(&job.id, &job.workspace_id, format!("{init_logs}\n"), conn).await;
let stream_notifier = StreamNotifier::new(conn, job);
let result = crate::js_eval::eval_fetch_timeout(
env_code,
inner_content.clone(),
@@ -1323,6 +1330,7 @@ try {{
&job.workspace_id,
false,
occupancy_metrics,
stream_notifier,
)
.await?;
tracing::info!(
@@ -1465,6 +1473,8 @@ try {{
.await?
};
let stream_notifier = StreamNotifier::new(conn, job);
let handle_result = handle_child(
&job.id,
conn,
@@ -1479,6 +1489,7 @@ try {{
false,
&mut Some(occupancy_metrics),
None,
stream_notifier,
)
.await?;
+151 -2
View File
@@ -14,6 +14,7 @@ use sqlx::{Pool, Postgres};
use tokio::process::Command;
use tokio::{fs::File, io::AsyncReadExt};
use windmill_common::flows::Step;
#[cfg(feature = "parquet")]
use windmill_common::s3_helpers::{
get_etag_or_empty, LargeFileStorage, ObjectStoreResource, S3Object,
@@ -32,8 +33,10 @@ use windmill_common::{
use anyhow::{anyhow, Result};
use windmill_parser_sql::{s3_mode_extension, S3ModeArgs, S3ModeFormat};
use windmill_queue::flow_status::get_step_of_flow_status;
use windmill_queue::MiniPulledJob;
use std::collections::HashSet;
use std::path::Path;
use std::{collections::HashMap, sync::Arc, time::Duration};
@@ -261,7 +264,11 @@ pub async fn transform_json_value(
)
.await?;
decrypt(&mc, encrypted.to_string()).and_then(|x| {
serde_json::from_str(&x).map_err(|e| Error::internal_err(e.to_string()))
serde_json::from_str(&x).map_err(|e| {
Error::internal_err(format!(
"Failed to decrypt '$encrypted:' value: {e}"
))
})
})
}
Connection::Http(_) => {
@@ -1061,6 +1068,149 @@ pub fn get_root_job_id(job: &MiniPulledJob) -> uuid::Uuid {
.unwrap_or(job.id)
}
#[derive(Clone)]
pub struct StreamNotifier {
db: DB,
job_id: uuid::Uuid,
parent_job: uuid::Uuid,
root_job: uuid::Uuid,
}
#[async_recursion]
async fn check_if_nested_step_is_last(
db: &DB,
parent_job: Uuid,
parent_of_parent_job: Option<Uuid>,
root_job: Uuid,
visited: Option<HashSet<Uuid>>,
) -> error::Result<bool> {
// Initialize or use the provided visited set for cycle detection
let mut visited = visited.unwrap_or_else(HashSet::new);
// Check for cycles - if we've already visited this job, return false to break the recursion
if !visited.insert(parent_job) {
return Ok(false);
}
// get parent of parent job to get step of parent job
let parent_of_parent_job = parent_of_parent_job.or(sqlx::query_scalar!(
"SELECT parent_job FROM v2_job WHERE id = $1",
parent_job
)
.fetch_one(db)
.await?);
if let Some(parent_of_parent_job) = parent_of_parent_job {
// Check for cycles again with the parent_of_parent_job
if !visited.insert(parent_of_parent_job) {
return Ok(false);
}
let r = sqlx::query!(
r#"SELECT
(flow_status->'step')::integer as step,
jsonb_array_length(flow_status->'modules') as len,
flow_status->'modules'->-1->>'branch_chosen' IS NOT NULL as is_branch_one,
parent_job as ppp_job
FROM v2_job
LEFT JOIN v2_job_status USING (id)
WHERE v2_job.id = $1"#,
parent_of_parent_job
)
.fetch_one(db)
.await
.map_err(|e| Error::internal_err(format!("fetching step flow status: {e:#}")))?;
if let Some(step) = r.step {
let step = Step::from_i32_and_len(step, r.len.unwrap_or(0) as usize);
// if parent job is last and a branch one and
// - root_job is equal to parent of parent job, return true
// - root job is not equal to parent of parent job, recursively check if the parent of parent job is a branch one and last
if step.is_last_step() && r.is_branch_one.unwrap_or(false) {
if parent_of_parent_job == root_job {
return Ok(true);
} else {
return check_if_nested_step_is_last(
db,
parent_of_parent_job,
r.ppp_job,
root_job,
Some(visited),
)
.await;
}
}
}
}
Ok(false)
}
impl StreamNotifier {
pub fn new(conn: &Connection, job: &MiniPulledJob) -> Option<Self> {
let root_job = get_root_job_id(job);
if job.is_flow_step() && job.parent_job.is_some() {
match conn {
Connection::Sql(db) => Some(Self {
db: db.clone(),
parent_job: job.parent_job.unwrap(),
job_id: job.id,
root_job,
}),
Connection::Http(_) => {
tracing::warn!(
"Flow job streaming is only supported for workers connected to a database"
);
None
}
}
} else {
None
}
}
async fn update_flow_status_with_stream_job_inner(
db: DB,
parent_job: Uuid,
job_id: Uuid,
root_job: Uuid,
) -> Result<(), Error> {
let step = get_step_of_flow_status(&db, parent_job).await?;
if step.is_last_step()
&& (parent_job == root_job
|| check_if_nested_step_is_last(&db, parent_job, None, root_job, None).await?)
{
sqlx::query!(r#"
UPDATE v2_job_status
SET flow_status = jsonb_set(flow_status, array['stream_job'], to_jsonb($1::UUID::TEXT))
WHERE id = $2"#,
job_id,
root_job
)
.execute(&db)
.await?;
}
Ok(())
}
pub fn update_flow_status_with_stream_job(&self) -> () {
let db = self.db.clone();
let parent_job = self.parent_job;
let job_id = self.job_id;
let root_job = self.root_job;
tokio::spawn(async move {
if let Err(err) =
Self::update_flow_status_with_stream_job_inner(db, parent_job, job_id, root_job)
.await
{
tracing::error!("Could not notify about stream job {}: {err:#?}", parent_job);
}
});
}
}
#[derive(Clone)]
pub struct S3ModeWorkerData {
pub client: AuthedClient,
@@ -1120,4 +1270,3 @@ pub fn s3_mode_args_to_worker_data(
workspace_id: job.workspace_id.clone(),
}
}
@@ -129,6 +129,7 @@ pub async fn generate_nuget_lockfile(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -386,6 +387,7 @@ async fn build_cs_proj(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
append_logs(job_id, w_id, "\n\n", conn).await;
@@ -643,6 +645,7 @@ pub async fn handle_csharp_job(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
read_result(job_dir, None).await
+6 -1
View File
@@ -8,7 +8,7 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
create_args_and_out_file, get_reserved_variables, parse_npm_config, read_file, read_result,
start_child_process, OccupancyMetrics,
start_child_process, OccupancyMetrics, StreamNotifier,
},
handle_child::handle_child,
DENO_CACHE_DIR, DENO_PATH, DISABLE_NSJAIL, HOME_ENV, NPM_CONFIG_REGISTRY, PATH_ENV, TZ_ENV,
@@ -161,6 +161,7 @@ pub async fn generate_deno_lock(
false,
occupancy_metrics,
None,
None,
)
.await?;
} else {
@@ -417,6 +418,9 @@ try {{
.stderr(Stdio::piped());
start_child_process(deno_cmd, DENO_PATH.as_str(), false).await?
};
let stream_notifier = StreamNotifier::new(conn, job);
// logs.push_str(format!("prepare: {:?}\n", start.elapsed().as_micros()).as_str());
// start = Instant::now();
let handle_result = handle_child(
@@ -433,6 +437,7 @@ try {{
false,
&mut Some(occupancy_metrics),
None,
stream_notifier,
)
.await?;
// logs.push_str(format!("execute: {:?}\n", start.elapsed().as_millis()).as_str());
@@ -274,6 +274,7 @@ func Run(req Req) (interface{{}}, error){{
false,
&mut Some(occupation_metrics),
None,
None,
)
.await?;
@@ -422,6 +423,7 @@ func Run(req Req) (interface{{}}, error){{
false,
&mut Some(occupation_metrics),
None,
None,
)
.await?;
@@ -507,6 +509,7 @@ pub async fn install_go_dependencies(
false,
&mut Some(occupation_metrics),
None,
None,
)
.await?;
@@ -622,6 +625,7 @@ pub async fn install_go_dependencies(
false,
&mut Some(occupation_metrics),
None,
None,
)
.await?;
+16 -2
View File
@@ -29,7 +29,7 @@ use windmill_queue::{append_logs, CanceledBy};
use std::os::unix::process::ExitStatusExt;
use std::process::ExitStatus;
use std::sync::atomic::AtomicU32;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::{io, panic, time::Duration};
@@ -52,7 +52,7 @@ use futures::{
stream, StreamExt,
};
use crate::common::{resolve_job_timeout, OccupancyMetrics};
use crate::common::{resolve_job_timeout, OccupancyMetrics, StreamNotifier};
use crate::job_logger::{append_job_logs, append_result_stream, append_with_limit};
use crate::job_logger_oss::process_streaming_log_lines;
use crate::worker_utils::{ping_job_status, update_worker_ping_from_job};
@@ -114,6 +114,7 @@ pub async fn handle_child(
occupancy_metrics: &mut Option<&mut OccupancyMetrics>,
// Do not print logs to output, but instead save to string.
pipe_stdout: Option<&mut String>,
stream_notifier: Option<StreamNotifier>,
) -> error::Result<HandleChildResult> {
let start = Instant::now();
@@ -315,6 +316,7 @@ pub async fn handle_child(
&mut rx2,
child_name,
&mut stream_result,
stream_notifier,
)
.instrument(trace_span!("child_lines"));
@@ -354,6 +356,7 @@ pub async fn write_lines(
rx2: &mut broadcast::Receiver<()>,
child_name: &str,
stream_result: &mut Vec<String>,
stream_notifier: Option<StreamNotifier>,
) {
let max_log_size = if *CLOUD_HOSTED {
MAX_RESULT_SIZE
@@ -384,6 +387,7 @@ pub async fn write_lines(
let mut pipe_stdout = pipe_stdout;
let is_stream = Arc::new(AtomicBool::new(false));
while let Some(line) = output.by_ref().next().await {
let do_write_ = do_write.shared();
@@ -410,6 +414,7 @@ pub async fn write_lines(
let job_id = job_id.clone();
let mut nstream = String::new();
while let Some(line) = read_lines.next().await {
match line {
Ok(line) => {
@@ -479,8 +484,17 @@ pub async fn write_lines(
let w_id = w_id.to_string();
let job_id = job_id.clone();
let pg_log_total_size = pg_log_total_size.clone();
let stream_notifier = stream_notifier.clone();
let is_stream = is_stream.clone();
(do_write, write_result) = tokio::spawn(async move {
if !nstream.is_empty() {
if let Some(stream_notifier) = stream_notifier {
if !is_stream.load(Ordering::SeqCst) {
is_stream.store(true, Ordering::SeqCst);
stream_notifier.update_flow_status_with_stream_job();
}
}
if let Err(err) = append_result_stream(&conn, &w_id, &job_id, &nstream).await {
tracing::error!(
"Unable to send result stream for job {job_id}. Error was: {:?}",
@@ -534,6 +534,7 @@ async fn compile<'a>(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -718,6 +719,7 @@ async fn run<'a>(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
Ok(())
+11 -1
View File
@@ -48,7 +48,7 @@ use windmill_common::worker::{write_file, TMP_DIR};
use windmill_common::flow_status::JobResult;
use windmill_queue::CanceledBy;
use crate::common::OccupancyMetrics;
use crate::common::{OccupancyMetrics, StreamNotifier};
use windmill_common::client::AuthedClient;
#[cfg(feature = "deno_core")]
@@ -769,6 +769,7 @@ pub async fn eval_fetch_timeout(
_w_id: &str,
_load_client: bool,
_occupation_metrics: &mut OccupancyMetrics,
_stream_notifier: Option<StreamNotifier>,
) -> anyhow::Result<Box<RawValue>> {
use serde_json::value::to_raw_value;
Ok(to_raw_value("require deno_core").unwrap())
@@ -790,6 +791,7 @@ pub async fn eval_fetch_timeout(
w_id: &str,
load_client: bool,
occupation_metrics: &mut OccupancyMetrics,
stream_notifier: Option<StreamNotifier>,
) -> windmill_common::error::Result<Box<RawValue>> {
let (sender, mut receiver) = oneshot::channel::<IsolateHandle>();
let (append_logs_sender, mut append_logs_receiver) = mpsc::unbounded_channel::<String>();
@@ -940,10 +942,18 @@ pub async fn eval_fetch_timeout(
}
let handle = tokio::spawn(async move {
let mut result_stream = String::new();
let mut is_stream = false;
while let Some(log) = log_receiver.recv().await {
use windmill_common::result_stream::extract_stream_from_logs;
if let Some(stream) = extract_stream_from_logs(&log.trim_end_matches("\n")) {
if let Some(sn) = stream_notifier.as_ref() {
if !is_stream {
is_stream = true;
sn.update_flow_status_with_stream_job();
}
}
result_stream.push_str(&stream);
if let Err(e) = result_stream_sender.send(stream) {
tracing::error!("failed to send result stream: {e}");
+1
View File
@@ -16,6 +16,7 @@ mod java_executor;
#[cfg(feature = "ruby")]
mod ruby_executor;
mod ai;
mod ai_executor;
mod bun_executor;
pub mod common;
@@ -340,6 +340,7 @@ async fn run<'a>(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
Ok(())
@@ -106,6 +106,7 @@ pub async fn composer_install(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -343,6 +344,7 @@ try {{
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
read_result(job_dir, None).await
@@ -121,7 +121,7 @@ use windmill_common::s3_helpers::OBJECT_STORE_SETTINGS;
use crate::{
common::{
create_args_and_out_file, get_reserved_variables, read_file, read_result,
start_child_process, OccupancyMetrics,
start_child_process, OccupancyMetrics, StreamNotifier,
},
handle_child::handle_child,
worker_utils::ping_job_status,
@@ -386,6 +386,7 @@ pub async fn uv_pip_compile(
false,
occupancy_metrics,
None,
None,
)
.await
.map_err(|e| {
@@ -864,6 +865,8 @@ mount {{
start_child_process(python_cmd, &python_path, false).await?
};
let stream_notifier = StreamNotifier::new(conn, job);
let handle_result = handle_child(
&job.id,
conn,
@@ -878,6 +881,7 @@ mount {{
false,
&mut Some(occupancy_metrics),
None,
stream_notifier,
)
.await?;
@@ -655,6 +655,7 @@ impl PyV {
false,
occupancy_metrics,
None,
None,
)
.await?;
Ok(())
@@ -420,6 +420,7 @@ Your Gemfile syntax will continue to work as-is."
&mut None,
// Some(&mut stdout),
None,
None,
)
.await?;
@@ -871,6 +872,7 @@ mount {{
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
Ok(())
+6 -1
View File
@@ -175,7 +175,8 @@ pub async fn generate_cargo_lockfile(
std::env::var("TMP").unwrap_or_else(|_| "C:\\tmp".to_string()),
);
}
let gen_lockfile_process = start_child_process(gen_lockfile_cmd, CARGO_PATH.as_str(), false).await?;
let gen_lockfile_process =
start_child_process(gen_lockfile_cmd, CARGO_PATH.as_str(), false).await?;
handle_child(
job_id,
conn,
@@ -190,6 +191,7 @@ pub async fn generate_cargo_lockfile(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
@@ -298,6 +300,7 @@ async fn get_build_dir(
false,
&mut None,
None,
None,
)
.await
}
@@ -410,6 +413,7 @@ pub async fn build_rust_crate(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
append_logs(&job.id, &job.workspace_id, "\n\n", conn).await;
@@ -599,6 +603,7 @@ pub async fn handle_rust_job(
false,
&mut Some(occupancy_metrics),
None,
None,
)
.await?;
read_result(job_dir, None).await
@@ -159,6 +159,7 @@ pub async fn par_install_language_dependencies_all_at_once<
false,
&mut None,
pipe_stdout,
None,
)
.await
{
@@ -587,6 +588,7 @@ async fn try_install_one_detached<'a, T: Clone + std::marker::Send + Sync + 'a +
false,
&mut None,
None,
None,
)
.await
{
+4
View File
@@ -101,6 +101,7 @@ use tokio::{
use rand::Rng;
use crate::ai_executor::handle_ai_agent_job;
use crate::common::StreamNotifier;
use crate::{
agent_workers::{queue_init_job, queue_periodic_job},
bash_executor::{handle_bash_job, handle_powershell_job},
@@ -2229,6 +2230,8 @@ async fn do_nativets(
job.args.as_ref()
};
let stream_notifier = StreamNotifier::new(conn, job);
Ok(eval_fetch_timeout(
env_code,
code.clone(),
@@ -2244,6 +2247,7 @@ async fn do_nativets(
&job.workspace_id,
true,
occupancy_metrics,
stream_notifier,
)
.await?)
}
+10 -8
View File
@@ -328,7 +328,7 @@ pub async fn update_flow_status_after_job_completion_internal(
let module_step = Step::from_i32_and_len(old_status.step, old_status.modules.len());
let current_module = match module_step {
Step::Step(i) => flow_value.modules.get(i),
Step::Step { idx: i, .. } => flow_value.modules.get(i),
_ => None,
};
@@ -342,7 +342,7 @@ pub async fn update_flow_status_after_job_completion_internal(
.as_ref()
.ok_or_else(|| Error::internal_err(format!("preprocessor module not found")))?,
Step::FailureStep => &old_status.failure_module.module_status,
Step::Step(i) => old_status
Step::Step { idx: i, .. } => old_status
.modules
.get(i as usize)
.ok_or_else(|| Error::internal_err(format!("module {i} not found")))?,
@@ -1984,7 +1984,7 @@ async fn push_next_flow_job(
tracing::info!(id = %flow_job.id, root_id = %job_root, step = ?step, "pushing next flow job");
let mut status_module = match step {
Step::Step(i) => status
Step::Step { idx: i, .. } => status
.modules
.get(i)
.cloned()
@@ -2035,7 +2035,7 @@ async fn push_next_flow_job(
})));
}
if matches!(step, Step::Step(0)) {
if matches!(step, Step::Step { idx: 0, .. }) {
if !flow_job.is_flow_step() && flow_job.schedule_path().is_some() {
let schedule_path = flow_job.schedule_path();
let no_flow_overlap = sqlx::query_scalar!(
@@ -2125,7 +2125,7 @@ async fn push_next_flow_job(
let arc_last_job_result = if status_module.is_failure() {
// if job is being retried, pass the result of its previous failure
last_job_result.unwrap_or_else(|| Arc::new(to_raw_value(&json!("{}"))))
} else if matches!(step, Step::Step(0)) || step.is_preprocessor_step() {
} else if matches!(step, Step::Step { idx: 0, .. }) || step.is_preprocessor_step() {
// if it's the first job executed in the flow, pass the flow args
Arc::new(to_raw_value(&flow_job.args))
} else {
@@ -2444,7 +2444,9 @@ async fn push_next_flow_job(
let current_id = &module.id;
let mut previous_id = match step {
Step::Step(i) if i >= 1 => flow.modules.get(i - 1).map(|m| m.id.clone()).unwrap(),
Step::Step { idx: i, .. } if i >= 1 => {
flow.modules.get(i - 1).map(|m| m.id.clone()).unwrap()
}
_ => String::new(),
};
@@ -2458,7 +2460,7 @@ async fn push_next_flow_job(
) {
None
} else {
let sleep_input_transform = if let Step::Step(i) = step {
let sleep_input_transform = if let Step::Step { idx: i, .. } = step {
i.checked_sub(1)
.and_then(|i| flow.modules.get(i))
.and_then(|m| m.sleep.clone())
@@ -3272,7 +3274,7 @@ async fn push_next_flow_job(
.warn_after_seconds(3)
.await?;
}
Step::Step(i) => {
Step::Step { idx: i, .. } => {
sqlx::query!(
"UPDATE v2_job_status SET
flow_status = JSONB_SET(
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.543.0";
export const VERSION = "v1.544.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -68,7 +68,7 @@ export {
// }
// });
export const VERSION = "1.543.0";
export const VERSION = "1.544.1";
export const WM_FORK_PREFIX = "wm-fork";
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.543.0",
"version": "1.544.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.543.0",
"version": "1.544.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.543.0",
"version": "1.544.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -18,6 +18,7 @@
import { twMerge } from 'tailwind-merge'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import { untrack } from 'svelte'
import { fly } from 'svelte/transition'
interface Props {
aiId?: string | undefined
@@ -33,6 +34,7 @@
customWidth?: number | undefined
customMenu?: boolean
class?: string | undefined
enableFlyTransition?: boolean
buttonReplacement?: import('svelte').Snippet
menu?: import('svelte').Snippet
}
@@ -51,6 +53,7 @@
customWidth = undefined,
customMenu = false,
class: classNames = undefined,
enableFlyTransition = false,
buttonReplacement,
menu
}: Props = $props()
@@ -148,7 +151,12 @@
</button>
{#if open && !hidePopup}
<div use:melt={$menuEl} data-menu class="z-[6000] transition-all duration-100">
<div
use:melt={$menuEl}
data-menu
class="z-[6000] transition-all duration-100"
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
>
{#if customMenu}
{@render menu?.()}
{:else}
@@ -186,7 +186,7 @@
let index = 0
let hasChanged = false
for (let k of properties) {
if (schema.properties[k].type === 'object' && schema.properties[k].properties) {
if (schema.properties[k]?.type === 'object' && schema.properties[k].properties) {
hasChanged = hasChanged || alignOrderWithProperties(schema.properties[k])
}
if (!norder.includes(k)) {
@@ -220,7 +220,7 @@
function computeSelected(property: any) {
if (!opened) return ''
if (property.type !== 'object') return property.type
if (property?.type !== 'object') return property?.type
if (property.format === 'resource-s3_object') return 'S3'
if (property.format?.startsWith('dynselect-')) return 'dynselect'
if (property.format?.startsWith('dynmultiselect-')) return 'dynmultiselect'
@@ -344,7 +344,7 @@
Object.entries(schema?.properties ?? {})
.filter(([_, property]) => {
const props = property as any
return props.type === 'object' && DynamicInput.isDynInputFormat(props.format)
return props?.type === 'object' && DynamicInput.isDynInputFormat(props.format)
})
.map(([fieldName, _]) => fieldName.replace(/\s+/g, '_'))
)
@@ -652,7 +652,7 @@
{#if typeof args == 'object' && schema?.properties[argName]}
<PropertyEditor
bind:description={schema.properties[argName].description}
type={schema.properties[argName].type}
type={schema.properties[argName]?.type}
bind:oneOf={schema.properties[argName].oneOf}
bind:pattern={schema.properties[argName].pattern}
bind:enum_={schema.properties[argName].enum}
@@ -795,7 +795,7 @@
{itemPicker}
bind:nullable={schema.properties[argName].nullable}
bind:disabled={schema.properties[argName].disabled}
type={schema.properties[argName].type}
type={schema.properties[argName]?.type}
bind:oneOf={schema.properties[argName].oneOf}
bind:format={schema.properties[argName].format}
contentEncoding={schema.properties[argName].contentEncoding}
@@ -1538,25 +1538,26 @@
<div class="p-2 text-tertiary text-sm italic">Empty flow</div>
{/if}
</div>
<div
class="{selected != 'logs' ? 'hidden' : ''} mx-auto"
bind:clientHeight={tabsHeight.logsHeight}
style="min-height: {minTabHeight}px"
>
<FlowLogViewerWrapper
{job}
{localModuleStates}
{localDurationStatuses}
{workspaceId}
{render}
{onSelectedIteration}
{globalIterationBounds}
loadPreviousIterations={(key, amount) => {
loadPreviousIters(key, amount)
}}
/>
</div>
{#if selected == 'assets' && render}
{#if selected == 'logs' && render}
<div
class="mx-auto"
bind:clientHeight={tabsHeight.logsHeight}
style="min-height: {minTabHeight}px"
>
<FlowLogViewerWrapper
{job}
{localModuleStates}
{localDurationStatuses}
{workspaceId}
{render}
{onSelectedIteration}
{globalIterationBounds}
loadPreviousIterations={(key, amount) => {
loadPreviousIters(key, amount)
}}
/>
</div>
{:else if selected == 'assets' && render}
<div
class="p-2"
bind:clientHeight={tabsHeight.assetsHeight}
@@ -5,7 +5,7 @@
import WaitTimeWarning from './common/waitTimeWarning/WaitTimeWarning.svelte'
import type { GlobalIterationBounds } from './graph'
import { TimelineCompute } from '$lib/timelineCompute.svelte'
import { onMount } from 'svelte'
import { onMount, untrack } from 'svelte'
import OnChange from './common/OnChange.svelte'
interface Props {
@@ -55,6 +55,13 @@
export function reset() {
timelineCompute?.reset()
}
$effect(() => {
flowDone
untrack(() => {
timelineCompute?.setFlowDone(flowDone)
})
})
</script>
<OnChange
@@ -70,7 +77,7 @@
><div></div>
<div class="col-span-11 pt-1 px-2 flex text-2xs text-secondary justify-between"
><div>{min ? displayDate(new Date(min), true) : ''}</div>{#if max && min}<div
class="hidden lg:block">{msToSec(max - min)}s</div
class="hidden lg:block">{msToSec(max - min, 1)}s</div
>
{/if}<div class="flex gap-1 items-center font-mono"
>{max ? displayDate(new Date(max), true) : ''}{#if !max && min}{#if now}
@@ -11,6 +11,7 @@
import type { Schema } from '$lib/common'
import type { InputCat } from '$lib/utils'
import { createEventDispatcher, getContext, untrack } from 'svelte'
import { computeShow } from '$lib/utils'
import ArgInput from './ArgInput.svelte'
import FieldHeader from './FieldHeader.svelte'
@@ -36,6 +37,7 @@
import { twMerge } from 'tailwind-merge'
import FlowPlugConnect from './FlowPlugConnect.svelte'
import { deepEqual } from 'fast-equals'
import S3ArrayHelperButton from './S3ArrayHelperButton.svelte'
interface Props {
schema: Schema | { properties?: Record<string, any>; required?: string[] }
@@ -57,6 +59,7 @@
hideHelpButton?: boolean
class?: string
editor?: SimpleEditor | undefined
otherArgs?: Record<string, InputTransform>
}
let {
@@ -78,7 +81,8 @@
enableAi = false,
hideHelpButton = false,
class: className = '',
editor = $bindable(undefined)
editor = $bindable(undefined),
otherArgs = {}
}: Props = $props()
let monaco: SimpleEditor | undefined = $state(undefined)
@@ -86,6 +90,8 @@
let argInput: ArgInput | undefined = $state(undefined)
let focusedPrev = false
let hidden = $state(false)
const variableMatch = (value: string): RegExpMatchArray | null =>
value.match(/^variable\('([^']+)'\)$/)
const resourceMatch = (value: string): RegExpMatchArray | null =>
@@ -235,6 +241,51 @@
return inputCat === 'string' || inputCat === 'sql' || inputCat == 'yaml'
}
function appendPathToArrayExpr(currentExpr: string | undefined, path: string) {
const trimmedExpr = currentExpr?.trim() || ''
let newExpr = trimmedExpr
if (trimmedExpr.startsWith('[') && trimmedExpr.endsWith(']')) {
// Parse existing array and append new item
const innerContent = trimmedExpr.slice(1, -1).trim()
if (innerContent) {
newExpr = `[${innerContent}, ${path}]`
} else {
newExpr = `[${path}]`
}
} else {
// Create new array with single item
newExpr = `[${path}]`
}
arg.expr = newExpr
arg.type = 'javascript'
// Update Monaco editor after setting the expression
tick().then(() => {
monaco?.setCode(newExpr)
})
// Dispatch change
dispatch('change', { argName, arg })
}
async function switchToJsAndConnect(onPath: (path: string) => void) {
// Switch to JavaScript mode
propertyType = 'javascript'
arg.type = 'javascript'
arg.expr = arg.expr || '[]'
arg.value = undefined
// Wait for the component to re-render and Monaco to be available
await tick()
// Activate connect mode
focusProp?.(argName, 'connect', (path) => {
onPath(path)
return true
})
}
function connectProperty(rawValue: string) {
// Extract path from variable('x') or resource('x') format
const varMatch = variableMatch(rawValue)
@@ -258,6 +309,47 @@
}
}
function handleFieldVisibility(
schema: Schema | any,
arg: InputTransform | any,
otherArgs: Record<string, any>
) {
const schemaProperty = schema?.properties?.[argName]
if (schemaProperty?.showExpr) {
// Build args object with current field value and other context
const currentValue = propertyType === 'static' ? arg?.value : arg?.expr
// Convert otherArgs from InputTransform objects to their actual values
const contextArgs = {
[argName]: currentValue
}
// Extract values from InputTransform objects in otherArgs
Object.keys(otherArgs ?? {}).forEach((key) => {
const otherArg = otherArgs[key]
const otherArgValue = otherArg.type === 'static' ? otherArg.value : otherArg.expr
contextArgs[key] = otherArgValue
})
const shouldShow = computeShow(argName, schemaProperty.showExpr, contextArgs)
if (shouldShow) {
hidden = false
} else if (!hidden) {
hidden = true
// Clear the arg value when hidden (following SchemaForm pattern)
if (arg) {
arg.value = undefined
arg.expr = undefined
}
// Make sure validation passes when hidden
inputCheck = true
}
} else {
// No showExpr, always show
hidden = false
}
}
function onFocus() {
focused = true
if (isStaticTemplate(inputCat)) {
@@ -358,9 +450,21 @@
$effect(() => {
schema?.properties?.[argName]?.default && untrack(() => setDefaultCode())
})
$effect.pre(() => {
// Monitor changes that affect field visibility
JSON.stringify(schema)
JSON.stringify(arg)
JSON.stringify(otherArgs)
untrack(() => handleFieldVisibility(schema, arg, otherArgs))
})
let connecting = $derived(
$propPickerConfig?.propName == argName && $propPickerConfig?.insertionMode == 'connect'
)
let shouldShowS3ArrayHelper = $derived(
inputCat === 'list' &&
['s3object', 's3_object'].includes(schema?.properties?.[argName]?.items?.resourceType)
)
</script>
{#if arg != undefined}
@@ -463,6 +567,7 @@
on:selected={(e) => {
if (e.detail == propertyType) return
const staticTemplate = isStaticTemplate(inputCat)
if (e.detail === 'javascript') {
if (arg.expr == undefined) {
arg.expr = getDefaultExpr(
@@ -635,6 +740,14 @@
bind:title={schema.properties[argName].title}
bind:placeholder={schema.properties[argName].placeholder}
/>
{#if shouldShowS3ArrayHelper}
<S3ArrayHelperButton
{connecting}
onClick={() =>
switchToJsAndConnect((path) => appendPathToArrayExpr(arg.expr, path))}
/>
{/if}
{:else if arg.expr != undefined}
<div class="border mt-2">
<SimpleEditor
@@ -664,6 +777,18 @@
{#if !hideHelpButton}
<DynamicInputHelpBox />
{/if}
{#if shouldShowS3ArrayHelper}
<S3ArrayHelperButton
{connecting}
onClick={() =>
focusProp?.(argName, 'connect', (path) => {
appendPathToArrayExpr(arg.expr, path)
return true
})}
/>
{/if}
<div class="mb-2"></div>
{:else}
Not recognized input type {argName} ({arg.expr}, {propertyType})
@@ -116,6 +116,9 @@
{noDynamicToggle}
{pickableProperties}
{enableAi}
otherArgs={Object.fromEntries(
Object.entries(args ?? {}).filter(([key]) => key !== argName)
)}
/>
</ResizeTransitionWrapper>
{/if}
@@ -669,6 +669,12 @@
if (resultStreamOffset) {
params.set('stream_offset', resultStreamOffset.toString())
}
if (job) {
params.set(
'is_flow',
(job.job_kind === 'flow' || job.job_kind === 'flowpreview').toString()
)
}
const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${id}?${params.toString()}`
@@ -0,0 +1,20 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { Plug } from 'lucide-svelte'
interface Props {
label?: string
onClick: () => void
connecting?: boolean
}
let { label = 'Add object from an expression', onClick, connecting = false }: Props = $props()
</script>
{#if !connecting}
<div class="mt-2 mb-2">
<Button variant="border" color="light" size="xs" startIcon={{ icon: Plug }} onclick={onClick}>
{label}
</Button>
</div>
{/if}
@@ -2,6 +2,7 @@
import Portal from '$lib/components/Portal.svelte'
import { clickOutside } from '$lib/utils'
import { createFloatingActions, type ComputeConfig } from 'svelte-floating-ui'
import { fly } from 'svelte/transition'
interface Props {
floatingConfig?: ComputeConfig
@@ -58,6 +59,7 @@
class="border rounded-lg shadow-lg bg-surface z5000"
style="position:absolute"
use:floatingContent
transition:fly={{ duration: 100, y: -16 }}
>
<!-- svelte-ignore event_directive_deprecated -->
<div
@@ -8,7 +8,7 @@
import type ShareModal from '$lib/components/ShareModal.svelte'
import { ScriptService, type Script, DraftService } from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { hubBaseUrlStore, userStore, workspaceStore } from '$lib/stores'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
@@ -34,13 +34,15 @@
Pen,
Share,
Trash,
History
History,
Globe2
} from 'lucide-svelte'
import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte'
import { Drawer, DrawerContent } from '..'
import NoMainFuncBadge from '$lib/components/NoMainFuncBadge.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { getDeployUiSettings } from '$lib/components/home/deploy_ui'
import { scriptToHubUrl } from '$lib/hub'
interface Props {
script: Script & { canWrite: boolean; use_codebase: boolean }
@@ -300,6 +302,25 @@
copyToClipboard(script.path)
}
},
{
displayName: 'Publish to Hub',
icon: Globe2,
action: () => {
window.open(
scriptToHubUrl(
script.content,
script.summary,
script.description ?? '',
script.kind,
script.language,
script.schema,
script.lock ?? '',
$hubBaseUrlStore
).toString(),
'_blank'
)
}
},
{
displayName: script.archived ? 'Unarchive' : 'Archive',
icon: Archive,
@@ -2,6 +2,7 @@
import { createEventDispatcher } from 'svelte'
import { Button, Badge } from '../common'
import Modal from '../common/modal/Modal.svelte'
import Portal from '../Portal.svelte'
export let open = false
@@ -10,30 +11,32 @@
const dispatch = createEventDispatcher()
</script>
<Modal
bind:open
on:confirmed={() => {
open = false
dispatch('confirmed')
}}
on:canceled
title="Windmill AI wants to add the following inputs to the flow:"
>
<ul class=" list-disc pl-5">
{#each inputs as input}
<li>{input}</li>
{/each}
</ul>
<Button
slot="actions"
on:click={() => {
<Portal>
<Modal
bind:open
on:confirmed={() => {
open = false
dispatch('confirmed')
}}
color="light"
size="sm"
on:canceled
title="Windmill AI wants to add the following inputs to the flow:"
>
<span class="inline-flex gap-2">Add <Badge color="dark-green">Enter</Badge></span>
</Button>
</Modal>
<ul class=" list-disc pl-5">
{#each inputs as input}
<li>{input}</li>
{/each}
</ul>
<Button
slot="actions"
on:click={() => {
open = false
dispatch('confirmed')
}}
color="light"
size="sm"
>
<span class="inline-flex gap-2">Add <Badge color="dark-green">Enter</Badge></span>
</Button>
</Modal>
</Portal>
@@ -84,7 +84,9 @@
}
})
const isLastMessageTool = $derived(messages.length > 0 && messages[messages.length - 1].role === 'tool')
const isLastMessageTool = $derived(
messages.length > 0 && messages[messages.length - 1].role === 'tool'
)
</script>
<div class="flex flex-col h-full">
@@ -26,7 +26,7 @@
<div class="text-tertiary text-xs flex flex-row items-center font-normal gap-0.5">
<span class={`truncate ${multipleModels ? '' : 'pr-2'}`}>{providerModel.model}</span>
{#if multipleModels}
<div class="shrink-0 pr-1">
<div class="shrink-0">
<ChevronDown size={16} />
</div>
{/if}
+6 -2
View File
@@ -1,5 +1,6 @@
import type { AIProvider, AIProviderModel } from '$lib/gen'
import {
copilotInfo,
getCurrentModel,
workspaceStore,
type DBSchema,
@@ -157,7 +158,7 @@ export async function fetchAvailableModels(
return data?.data.map((m) => m.id) ?? []
}
function getModelMaxTokens(provider: AIProvider, model: string) {
export function getModelMaxTokens(provider: AIProvider, model: string) {
if (model.startsWith('gpt-5')) {
return 128000
} else if ((provider === 'azure_openai' || provider === 'openai') && model.startsWith('o')) {
@@ -196,7 +197,10 @@ function getModelSpecificConfig(
modelProvider: AIProviderModel,
tools?: OpenAI.Chat.Completions.ChatCompletionTool[]
) {
const maxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model)
const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model)
const modelKey = `${modelProvider.provider}:${modelProvider.model}`
const customMaxTokensStore = get(copilotInfo)?.maxTokensPerModel
const maxTokens = customMaxTokensStore?.[modelKey] ?? defaultMaxTokens
if (
(modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai') &&
(modelProvider.model.startsWith('o') || modelProvider.model.startsWith('gpt-5'))
+28 -20
View File
@@ -62,50 +62,58 @@ export async function loadSchemaFromModule(module: FlowModule): Promise<{
type: 'object',
format: 'ai-provider'
},
output_type: {
type: 'string',
description:
'The type of output the AI agent will generate (text or image). Image output requires a configured workspace S3 storage, will ignore tools, and only works with OpenAI, Google AI and OpenRouter gemini-image-preview model.',
enum: ['text', 'image'],
default: 'text'
},
user_message: {
type: 'string'
type: 'string',
description: 'The message to give as input to the AI agent.'
},
system_prompt: {
type: 'string'
type: 'string',
description: 'The system prompt to give as input to the AI agent.'
},
image: {
type: 'object',
description: 'Image to send to the AI agent (optional)',
format: 'resource-s3_object'
user_images: {
type: 'array',
description:
'Array of images to give as input to the AI agent. Requires a configured workspace S3 storage.',
items: {
type: 'object' as const,
resourceType: 's3object'
}
},
max_completion_tokens: {
type: 'number'
type: 'number',
description: 'The maximum number of output tokens.'
},
temperature: {
type: 'number',
description:
'Controls randomness in text generation. Range: 0.0 (deterministic) to 2.0 (random).'
},
output_type: {
type: 'string',
description:
'The type of output the AI agent will generate (text or image). Image output will ignore tools, and only works with OpenAI, Google AI and OpenRouter gemini-image-preview model.',
enum: ['text', 'image'],
default: 'text'
'Controls randomness in text generation. Range: 0.0 (deterministic) to 2.0 (random).',
showExpr: "fields.output_type === 'text'"
},
output_schema: {
type: 'object',
description:
'JSON schema that the AI agent will follow for its response format (only used if output_type is text)',
format: 'json-schema'
format: 'json-schema',
showExpr: "fields.output_type === 'text'"
}
},
required: ['provider', 'model', 'user_message'],
required: ['provider', 'user_message', 'output_type'],
type: 'object',
order: [
'provider',
'model',
'output_type',
'user_message',
'system_prompt',
'image',
'user_images',
'max_completion_tokens',
'temperature',
'output_type',
'output_schema'
]
}
@@ -6,8 +6,6 @@
import ToggleHubWorkspaceQuick from '$lib/components/ToggleHubWorkspaceQuick.svelte'
import TopLevelNode from '../pickers/TopLevelNode.svelte'
// import type { Writable } from 'svelte/store'
const dispatch = createEventDispatcher()
interface Props {
stop?: boolean
@@ -32,26 +30,14 @@
$state(kind)
let preFilter: 'all' | 'workspace' | 'hub' = $state('all')
let loading = $state(false)
let small = $state(false)
let small = $derived(kind === 'preprocessor' || kind === 'failure')
let width = $state(0)
let height = $state(0)
let displayPath = $derived(width > 650 || height > 400)
$effect(() => {
small = kind === 'preprocessor' || kind === 'failure'
})
</script>
<!-- <Menu transitionDuration={0} pointerDown bind:show={open} noMinW {placement} let:close> -->
<!-- {floatingConfig}
floatingClasses="mt-2"
containerClasses="border rounded-lg shadow-lg bg-surface"
noTransition
shouldUsePortal={true} -->
<div
id="flow-editor-insert-module"
class="flex flex-col h-[400px] {small
@@ -1,11 +1,34 @@
<script module lang="ts">
let listHubIntegrationsCached = createCache(
(params: { kind: HubScriptKind & string }) => IntegrationService.listHubIntegrations(params),
{ initial: { kind: 'script' } }
)
let listHubScriptsCached = createCache(
async ({
filter,
kind,
appFilter
}: {
filter: string
kind: HubScriptKind & string
appFilter: string | undefined
}) =>
filter.length > 0
? await ScriptService.queryHubScripts({ text: filter, limit: 40, kind })
: ((await ScriptService.getTopHubScripts({ limit: 40, kind, app: appFilter })).asks ?? []),
{ initial: { filter: '', kind: 'script', appFilter: undefined } }
)
</script>
<script lang="ts">
import { createEventDispatcher, untrack } from 'svelte'
import { Skeleton } from '$lib/components/common'
import { classNames } from '$lib/utils'
import { classNames, createCache } from '$lib/utils'
import { APP_TO_ICON_COMPONENT } from '$lib/components/icons'
import { IntegrationService, ScriptService, type HubScriptKind } from '$lib/gen'
import { Circle } from 'lucide-svelte'
import Popover from '$lib/components/Popover.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
let hubNotAvailable = $state(false)
@@ -45,11 +68,7 @@
async function getAllApps(filterKind: typeof kind) {
try {
hubNotAvailable = false
allApps = (
await IntegrationService.listHubIntegrations({
kind: filterKind
})
).map((x) => x.name)
allApps = (await listHubIntegrationsCached({ kind: filterKind })).map((x) => x.name)
apps = allApps
} catch (err) {
console.error('Hub is not available')
@@ -59,34 +78,21 @@
}
}
let startTs = 0
async function applyFilter(
filter: string,
filterKind: typeof kind,
appFilter: string | undefined
) {
try {
loading = true
hubNotAvailable = false
const ts = Date.now()
startTs = ts
await new Promise((resolved, rejected) => setTimeout(resolved, 200))
if (ts < startTs) return
const scripts =
filter.length > 0
? await ScriptService.queryHubScripts({
text: `${filter}`,
limit: 40,
kind: filterKind
})
: ((
await ScriptService.getTopHubScripts({
limit: 40,
kind: filterKind,
app: appFilter
})
).asks ?? [])
let hubScriptsFilteredPromise = usePromise(
() => listHubScriptsCached({ appFilter, filter, kind }),
{ loadInit: false }
)
$effect(() => {
;[filter, kind, appFilter]
hubScriptsFilteredPromise.refresh()
})
$effect(() => {
// TODO: these should be derived ...
loading = hubScriptsFilteredPromise.status === 'loading'
hubNotAvailable = !!hubScriptsFilteredPromise.error
const scripts = hubScriptsFilteredPromise.value
untrack(() => {
if (!scripts) return
const mappedItems = scripts.map(
(x: {
summary: string
@@ -108,18 +114,8 @@
}
items = appFilter ? mappedItems.filter((x) => x.app === appFilter) : mappedItems
if (ts === startTs) {
loading = false
}
hubNotAvailable = false
} catch (err) {
hubNotAvailable = true
console.error('Hub not available')
loading = false
}
}
})
})
function onKeyDown(e: KeyboardEvent) {
if (
@@ -134,12 +130,6 @@
dispatch('pickScript', item)
}
}
$effect(() => {
;[filter, kind, appFilter]
untrack(() => {
applyFilter(filter, kind, appFilter)
})
})
$effect(() => {
kind
untrack(() => {
@@ -1,13 +1,33 @@
<script module lang="ts">
let loadItemsCached = createCache(
({
workspace,
kind,
isTemplate
}: {
workspace?: string
kind?: string
isTemplate?: boolean
}) =>
workspace
? kind == 'flow'
? FlowService.listFlows({ workspace })
: ScriptService.listScripts({ workspace, kinds: kind, isTemplate })
: undefined
)
</script>
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { createEventDispatcher, untrack } from 'svelte'
import { FlowService, ScriptService } from '$lib/gen'
import SearchItems from '$lib/components/SearchItems.svelte'
import { Skeleton } from '$lib/components/common'
import { emptyString } from '$lib/utils'
import { createCache, emptyString } from '$lib/utils'
import { Code2 } from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import Popover from '$lib/components/Popover.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
type Item = {
path: string
@@ -16,21 +36,13 @@
hash?: string
}
let items: Item[] | undefined = $state(undefined)
let items = usePromise(
async () => await loadItemsCached({ workspace: $workspaceStore!, kind, isTemplate }),
{ loadInit: false }
)
let filteredItems: (Item & { marked?: string })[] | undefined = $state(undefined)
async function loadItems(): Promise<void> {
items =
kind == 'flow'
? await FlowService.listFlows({ workspace: $workspaceStore! })
: await ScriptService.listScripts({
workspace: $workspaceStore!,
kinds: kind,
isTemplate
})
}
interface Props {
kind?: 'script' | 'trigger' | 'approval' | 'failure' | 'flow' | 'preprocessor'
isTemplate?: boolean | undefined
@@ -76,7 +88,7 @@
}
}
$effect(() => {
$workspaceStore && kind && untrack(() => loadItems())
$workspaceStore && kind && untrack(() => items.refresh())
})
$effect(() => {
if ($workspaceStore) {
@@ -106,7 +118,7 @@
<SearchItems
{filter}
{items}
items={items.value}
bind:filteredItems
f={(x) => (emptyString(x.summary) ? x.path : x.summary + ' (' + x.path + ')')}
/>
@@ -158,6 +158,7 @@
<div class="flex flex-row items-center justify-center w-full h-full">
{#if showInput}
<Popover
enableFlyTransition
floatingConfig={{
placement: 'bottom',
gutter: 0,
@@ -200,6 +201,7 @@
{/if}
<Popover
enableFlyTransition
floatingConfig={{
placement: 'bottom',
gutter: 0,
@@ -8,7 +8,7 @@
<script lang="ts">
import { createPopover, createSync, melt } from '@melt-ui/svelte'
import { fade } from 'svelte/transition'
import { fly } from 'svelte/transition'
import { X, Minimize2, Maximize2 } from 'lucide-svelte'
import type { Placement } from '@floating-ui/core'
import { debounce, pointerDownOutside } from '$lib/utils'
@@ -38,6 +38,7 @@
export let documentationLink: string | undefined = undefined
export let disableFocusTrap: boolean = false
export let escapeBehavior: EscapeBehaviorType = 'close'
export let enableFlyTransition: boolean = false
let fullScreen = false
const dispatch = createEventDispatcher()
@@ -168,7 +169,7 @@
}}
on:mouseleave={debounceClose}
use:melt={$content}
transition:fade={{ duration: 0 }}
transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }}
class={twMerge(
'relative border rounded-md bg-surface shadow-lg',
fullScreen
@@ -118,6 +118,7 @@
{/snippet}
<DropdownV2
enableFlyTransition
bind:this={dropdown}
items={addTriggerItems}
{placement}
@@ -83,6 +83,7 @@
cloud_subscription_id?: string
create_update_subscription_id?: string
auto_acknowledge_msg: boolean
ack_deadline?: number
}
let {
@@ -97,6 +98,7 @@
subscription_mode = $bindable('create_update'),
base_endpoint = $bindable(getBaseUrl()),
auto_acknowledge_msg = $bindable(true),
ack_deadline = $bindable(),
path = '',
showTestingBadge = false,
cloud_subscription_id = $bindable(''),

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