mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-09 00:04:10 +00:00
Merge remote-tracking branch 'origin/main' into di/flow-input-redesign
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+22
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
-70
@@ -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"
|
||||
}
|
||||
+13
-7
@@ -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"
|
||||
}
|
||||
-48
@@ -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"
|
||||
}
|
||||
+16
-3
@@ -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"
|
||||
}
|
||||
+55
@@ -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"
|
||||
}
|
||||
+10
-3
@@ -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"
|
||||
}
|
||||
-22
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
Generated
+91
-71
@@ -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
@@ -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 @@
|
||||
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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -415,6 +415,7 @@ pub struct Tokened {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OptTokened {
|
||||
#[allow(dead_code)]
|
||||
pub token: Option<String>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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! {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -68,7 +68,7 @@ export {
|
||||
// }
|
||||
// });
|
||||
|
||||
export const VERSION = "1.543.0";
|
||||
export const VERSION = "1.544.1";
|
||||
|
||||
export const WM_FORK_PREFIX = "wm-fork";
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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,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}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user