Merge branch 'main' into dieri/triggers-queue-mode

This commit is contained in:
dieriba
2025-11-09 11:14:36 +01:00
84 changed files with 1477 additions and 625 deletions
+2 -1
View File
@@ -40,11 +40,12 @@ jobs:
with:
github-token: ${{ steps.app.outputs.token }}
script: |
const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Starting sqlx update...'
body: `Starting sqlx update...\n\n[View workflow run](${runUrl})`
})
- name: Checkout repository
+14 -18
View File
@@ -49,14 +49,14 @@ jobs:
- **Bug Fixes**: PRs with titles starting with "fix:", "bug:", or containing "fix", "resolve", "patch"
- **Other**: All remaining PRs (improvements, refactors, docs, chores, etc.)
4. **Gather Details**: For each merged PR, include:
4. **Gather Details**: For each feature and bug fix merged PR, include:
- Full PR title (NO truncation, NO links)
- Author (extract login from author.login in JSON)
- Brief summary: Use `gh pr view <number> --json body` to get PR description, then extract first paragraph or key points (1-2 sentences max)
5. **Character Limit Enforcement**:
- The final summary MUST be under 6000 characters
- If the summary exceeds 6000 characters, truncate PR descriptions (NOT titles) and add at the end: "_and X more PRs_" where X is the count of omitted PRs
- The final summary MUST be under 5000 characters
- If the summary exceeds 5000 characters, truncate PR descriptions (NOT titles) and add at the end: "_and X more PRs_" where X is the count of omitted PRs
6. **Save Summary to Markdown File**: Write the summary to a file for webhook delivery:
- Save the complete formatted markdown to: `summary.md`
@@ -65,23 +65,19 @@ jobs:
## Output Format:
```markdown
#### 📈 Weekly overview
### 📈 Weekly overview
- **Total merged**: X
- **Features**: Y
- **Bug Fixes**: Z
- **Other**: W
#### ✨ Features (Y)
**[Full PR Title]** by @username - [brief impact description]
**[Full PR Title]** by @username - [brief impact description]
### ✨ Features (Y)
- **[Full PR Title]** by @username - [brief impact description]
- **[Full PR Title]** by @username - [brief impact description]
#### 🐛 Bug Fixes (Z)
**[Full PR Title]** by @username - [brief impact description]
**[Full PR Title]** by @username - [brief impact description]
#### 🔧 Other (W)
• **[Full PR Title]** by @username - [brief impact description]
• **[Full PR Title]** by @username - [brief impact description]
### 🐛 Bug Fixes (Z)
- **[Full PR Title]** by @username - [brief impact description]
- **[Full PR Title]** by @username - [brief impact description]
_and X more PRs_
```
@@ -89,9 +85,9 @@ jobs:
## Important Notes:
- **CRITICAL**: ONLY include PRs with state "merged" from the last 7 days
- **CRITICAL**: EXCLUDE all PRs with titles starting with "chore: release" or "chore(release)"
- **CRITICAL**: Total character count MUST be under 6000 characters
- Only use #### markdown headers for major sections and emoji indicators
- Use bullet points (•) for individual PR entries - more compact than paragraphs
- **CRITICAL**: Total character count MUST be under 5000 characters
- Count the number of "Other" PRs but do not include a section for them in the output
- Only use ### markdown headers for major sections and emoji indicators
- NO links to PRs
- NO merged date in output
- NEVER truncate PR titles - show full titles
@@ -100,7 +96,7 @@ jobs:
- If a PR has no description, write "(No description provided)"
- Extract meaningful summary from PR body - look for the first paragraph or key bullet points
- Parse JSON responses carefully using `jq` or similar tools
- If summary exceeds 6000 chars, shorten PR descriptions and add "_and X more PRs_" at the end
- If summary exceeds 5000 chars, shorten PR descriptions and add "_and X more PRs_" at the end
- Count PRs in each category and display in both overview and section headers
## Saving the Markdown Output:
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## [1.573.5](https://github.com/windmill-labs/windmill/compare/v1.573.4...v1.573.5) (2025-11-07)
### Bug Fixes
* dep jobs improvements ([#7081](https://github.com/windmill-labs/windmill/issues/7081)) ([ad43680](https://github.com/windmill-labs/windmill/commit/ad43680bb979f80db3bdb182ccb5bc61ca385ceb))
* modal action in App AgGrid ([#7085](https://github.com/windmill-labs/windmill/issues/7085)) ([b07c35f](https://github.com/windmill-labs/windmill/commit/b07c35fe1c9dbc4955ce47766ef92bd5cfdac4c8))
## [1.573.4](https://github.com/windmill-labs/windmill/compare/v1.573.3...v1.573.4) (2025-11-07)
### Bug Fixes
* **bun:** support relative imports without .ts but starting with /u/ or /f/ ([9e3ea76](https://github.com/windmill-labs/windmill/commit/9e3ea7657568d6561abcdfd23cc8a210432e4050))
* create git branch right before creating the workspace fork to catch errors and have a coherent fork point ([#7073](https://github.com/windmill-labs/windmill/issues/7073)) ([192fecc](https://github.com/windmill-labs/windmill/commit/192fecc86f54cf2a69ec7dddc466139b40933ac9))
* improve priority for child jobs ran from api ([5f7e0b7](https://github.com/windmill-labs/windmill/commit/5f7e0b7244e27533d9a8d1551e8affeb17eb94c0))
* make it impossible to have non linear script history even in edge condition ([3bda6da](https://github.com/windmill-labs/windmill/commit/3bda6da1683589180cad0780dd87b391a04851f8))
## [1.573.3](https://github.com/windmill-labs/windmill/compare/v1.573.2...v1.573.3) (2025-11-06)
@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT \n CASE \n WHEN flow_version.id IS NOT NULL THEN\n (flow_version.value -> 'flow_env' -> $3) #> $4\n ELSE\n (root_job.raw_flow -> 'flow_env' -> $3) #> $4\n END AS \"flow_env: sqlx::types::Json<Box<RawValue>>\"\n FROM \n v2_job current_job\n JOIN \n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE \n current_job.id = $1 AND \n current_job.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flow_env: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "0c0f3909b80c35210fc64c685905308621f9135c2c45a2fa0531ea750387da1f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2 FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
null
]
},
"hash": "1fc43faf3a7ea15be82b1666273836901cc6ba17c7d5e194b50b9204f264de27"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages, created_at, updated_at)\n VALUES ($1, $2, $3, $4, NOW(), NOW())\n ON CONFLICT (workspace_id, conversation_id, step_id)\n DO UPDATE SET\n messages = EXCLUDED.messages,\n updated_at = NOW()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Uuid",
"Varchar",
"Jsonb"
]
},
"nullable": []
},
"hash": "38baa6cf7d1c2532d38486a01e6d27ac1a58540a51e97dec5613e9bc0791e890"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT messages FROM ai_agent_memory\n WHERE workspace_id = $1 AND conversation_id = $2 AND step_id = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "messages",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "6a1005b8ae5326c5d5955534e9228b6bd524253971ae09cbe100c0d6d42e6cdc"
}
@@ -18,8 +18,8 @@
"Left": []
},
"nullable": [
true,
false
false,
true
]
},
"hash": "b3dbdfb50ee8118bdaed3164b210cb549a34b96554ae1872355b90304f5dcb76"
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM ai_agent_memory\n WHERE workspace_id = $1 AND conversation_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Uuid"
]
},
"nullable": []
},
"hash": "d1d9cb0a7043760564171f481dd73f04eaa2c7d5c1ddc0501bc70c3fb34a07be"
}
+33 -33
View File
@@ -788,9 +788,9 @@ dependencies = [
[[package]]
name = "aws-credential-types"
version = "1.2.8"
version = "1.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf26925f4a5b59eb76722b63c2892b1d70d06fa053c72e4a100ec308c1d47bc"
checksum = "86590e57ea40121d47d3f2e131bfd873dea15d78dc2f4604f4734537ad9e56c4"
dependencies = [
"aws-smithy-async",
"aws-smithy-runtime-api",
@@ -1945,9 +1945,9 @@ dependencies = [
[[package]]
name = "cc"
version = "1.2.44"
version = "1.2.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3"
checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -10296,9 +10296,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.41"
version = "1.0.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
dependencies = [
"proc-macro2",
]
@@ -15137,7 +15137,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"aws-sdk-config",
@@ -15197,7 +15197,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"argon2",
@@ -15317,7 +15317,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -15332,7 +15332,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"chrono",
"lazy_static",
@@ -15346,7 +15346,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"axum",
@@ -15365,7 +15365,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"async-recursion",
@@ -15450,7 +15450,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"regex",
"serde",
@@ -15465,7 +15465,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"bytes",
@@ -15489,7 +15489,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -15501,7 +15501,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -15510,7 +15510,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"lazy_static",
@@ -15522,7 +15522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"serde_json",
@@ -15534,7 +15534,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"gosyn",
@@ -15546,7 +15546,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"lazy_static",
@@ -15558,7 +15558,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"serde_json",
@@ -15570,7 +15570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"nu-parser",
@@ -15581,7 +15581,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15592,7 +15592,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -15604,7 +15604,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"async-recursion",
@@ -15627,7 +15627,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"lazy_static",
@@ -15641,7 +15641,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -15658,7 +15658,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"lazy_static",
@@ -15672,7 +15672,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"lazy_static",
@@ -15690,7 +15690,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"serde",
@@ -15701,7 +15701,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"async-recursion",
@@ -15735,7 +15735,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"wasm-bindgen",
"wasm-bindgen-test",
@@ -15745,7 +15745,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.573.3"
version = "1.573.5"
dependencies = [
"anyhow",
"async-once-cell",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.573.3"
version = "1.573.5"
authors.workspace = true
edition.workspace = true
@@ -33,7 +33,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.573.3"
version = "1.573.5"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
ab6116235f7869cef306996059f8663f7e831b8a
5b7afe50da442441747e7a8f6ef461c96faa9dc2
@@ -0,0 +1,4 @@
-- Add down migration script here
-- Drop table
DROP TABLE IF EXISTS ai_agent_memory;
@@ -0,0 +1,16 @@
-- Add up migration script here
-- Create ai_agent_memory table for storing AI agent step memory when S3 is unavailable
CREATE TABLE ai_agent_memory (
workspace_id VARCHAR(50) NOT NULL,
conversation_id UUID NOT NULL,
step_id VARCHAR(255) NOT NULL,
messages JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
PRIMARY KEY (workspace_id, conversation_id, step_id)
);
-- Grant permissions
GRANT ALL ON ai_agent_memory TO windmill_admin;
GRANT ALL ON ai_agent_memory TO windmill_user;
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.573.3
version: 1.573.5
title: Windmill API
contact:
@@ -11,7 +11,7 @@ use uuid::Uuid;
use crate::db::ApiAuthed;
use windmill_common::{
db::UserDB,
db::{UserDB, DB},
error::{JsonResult, Result},
flow_conversations::MessageType,
utils::{not_found_if_none, paginate, Pagination},
@@ -154,6 +154,7 @@ pub async fn get_or_create_conversation_with_id(
async fn delete_conversation(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
Path((w_id, conversation_id)): Path<(String, Uuid)>,
) -> Result<String> {
let mut tx = user_db.clone().begin(&authed).await?;
@@ -185,10 +186,14 @@ async fn delete_conversation(
// Delete associated memory in background (non-blocking cleanup)
let w_id_clone = w_id.clone();
let db_clone = db.clone();
tokio::spawn(async move {
if let Err(e) =
windmill_worker::memory_oss::delete_conversation_memory(&w_id_clone, conversation_id)
.await
if let Err(e) = windmill_worker::memory_oss::delete_conversation_memory(
&db_clone,
&w_id_clone,
conversation_id,
)
.await
{
tracing::error!(
"Failed to delete memory for conversation {} in workspace {}: {:?}",
+1
View File
@@ -1634,6 +1634,7 @@ mod tests {
early_return: None,
concurrency_key: None,
chat_input_enabled: None,
flow_env: None,
debounce_key: None,
debounce_delay_s: None,
};
+60 -1
View File
@@ -281,6 +281,10 @@ pub fn workspaced_service() -> Router {
"/result_by_id/:job_id/:node_id",
get(get_result_by_id).layer(cors.clone()),
)
.route(
"/flow_env_by_flow_job_id/:flow_job_id/:var_name",
get(get_flow_env_by_flow_job_id).layer(cors.clone()),
)
.route("/run/dependencies", post(run_dependencies_job))
.route("/run/flow_dependencies", post(run_flow_dependencies_job))
.route(
@@ -376,6 +380,59 @@ async fn get_root_job(
Ok(Json(res))
}
async fn get_flow_env_by_flow_job_id(
authed: ApiAuthed,
tokened: Tokened,
Extension(db): Extension<DB>,
Path((w_id, flow_job_id, var_name)): Path<(String, Uuid, String)>,
Query(JsonPath { json_path, .. }): Query<JsonPath>,
) -> windmill_common::error::JsonResult<Box<JsonRawValue>> {
let flow_env = sqlx::query_scalar!(
r#"
SELECT
CASE
WHEN flow_version.id IS NOT NULL THEN
(flow_version.value -> 'flow_env' -> $3) #> $4
ELSE
(root_job.raw_flow -> 'flow_env' -> $3) #> $4
END AS "flow_env: sqlx::types::Json<Box<RawValue>>"
FROM
v2_job current_job
JOIN
v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)
AND root_job.workspace_id = current_job.workspace_id
LEFT JOIN
flow_version ON flow_version.id = root_job.runnable_id
AND flow_version.path = root_job.runnable_path
AND flow_version.workspace_id = root_job.workspace_id
WHERE
current_job.id = $1 AND
current_job.workspace_id = $2"#,
flow_job_id,
w_id,
var_name,
json_path
.as_ref()
.map(|x| x.split(".").collect::<Vec<_>>())
.unwrap_or_default() as Vec<&str>,
)
.fetch_optional(&db)
.await?
.map(|r| r.map(|x| x.0))
.flatten()
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
log_job_view(
&db,
Some(&authed),
Some(&tokened.token),
&w_id,
&flow_job_id,
)
.await?;
Ok(Json(flow_env))
}
async fn compute_root_job_for_flow(db: &DB, w_id: &str, job_id: Uuid) -> error::Result<String> {
let root_job = sqlx::query_scalar!(
r#"SELECT COALESCE(root_job, flow_innermost_root_job, parent_job, id) as "root_job!" FROM v2_job WHERE id = $1 AND workspace_id = $2"#,
@@ -3769,6 +3826,7 @@ async fn batch_rerun_handle_job(
batch_rerun_compute_js_expression(expr.clone(), job.clone()).await?,
);
}
InputTransform::Ai => {}
}
}
@@ -4226,7 +4284,8 @@ pub async fn run_script_by_path_inner(
tag,
timeout,
None,
None,
// If the job has a parent job, set priority to 2 as it may be ran synchronously and block a current worker until being executed. Flow steps have a priority of 1 so this is higher.
if run_query.parent_job.is_some() || run_query.root_job.is_some() { Some(2) } else { None },
push_authed.as_ref(),
false,
None,
+3 -1
View File
@@ -607,8 +607,10 @@ async fn create_script_internal<'c>(
Ok(None)
}
(Some(p_hash), o) => {
// Lock the parent row to prevent concurrent updates with the same parent_hash
// This ensures linear lineage - only one script can have a given parent at a time
if sqlx::query_scalar!(
"SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2",
"SELECT 1 FROM script WHERE hash = $1 AND workspace_id = $2 FOR UPDATE",
p_hash.0,
&w_id
)
+69 -50
View File
@@ -49,14 +49,7 @@ impl AuthedClient {
"{}/api/w/{}/oidc/token/{}",
self.base_internal_url, self.workspace, audience
);
let response = self.get(&url, vec![]).await?;
match response.status().as_u16() {
200u16 => Ok(response
.json::<String>()
.await
.context("decoding oidc token as json string")?),
_ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())),
}
make_basic_get_request(self, &url, None, Some("decoding oidc token as json string")).await
}
pub async fn get_resource_value<T: DeserializeOwned>(&self, path: &str) -> anyhow::Result<T> {
@@ -64,14 +57,7 @@ impl AuthedClient {
"{}/api/w/{}/resources/get_value/{}",
self.base_internal_url, self.workspace, path
);
let response = self.get(&url, vec![]).await?;
match response.status().as_u16() {
200u16 => Ok(response
.json::<T>()
.await
.context("decoding resource value as json")?),
_ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())),
}
make_basic_get_request(self, &url, None, Some("decoding resource value as json")).await
}
pub async fn get_variable_value(&self, path: &str) -> anyhow::Result<String> {
@@ -79,14 +65,7 @@ impl AuthedClient {
"{}/api/w/{}/variables/get_value/{}",
self.base_internal_url, self.workspace, path
);
let response = self.get(&url, vec![]).await?;
match response.status().as_u16() {
200u16 => Ok(response
.json::<String>()
.await
.context("decoding variable value as json")?),
_ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())),
}
make_basic_get_request(self, &url, None, Some("decoding variable value as json")).await
}
pub async fn get_resource_value_interpolated<T: DeserializeOwned>(
@@ -121,19 +100,34 @@ impl AuthedClient {
"{}/api/w/{}/jobs_u/completed/get_result/{}",
self.base_internal_url, self.workspace, path
);
let query = if let Some(json_path) = json_path {
vec![("json_path", json_path)]
} else {
vec![]
};
let response = self.get(&url, query).await?;
match response.status().as_u16() {
200u16 => Ok(response
.json::<T>()
.await
.context("decoding completed job result as json")?),
_ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())),
}
let query = query_from_json_path(json_path);
make_basic_get_request(
self,
&url,
Some(query),
Some("decoding completed job result as json"),
)
.await
}
pub async fn get_flow_env_by_flow_job_id<T: DeserializeOwned>(
&self,
root_job_id: &str,
var_name: &str,
json_path: Option<String>,
) -> anyhow::Result<T> {
let url = format!(
"{}/api/w/{}/jobs/flow_env_by_flow_job_id/{}/{}",
self.base_internal_url, self.workspace, root_job_id, var_name
);
let query = query_from_json_path(json_path);
make_basic_get_request(
self,
&url,
Some(query),
Some("decoding flow env variable as json"),
)
.await
}
pub async fn get_result_by_id<T: DeserializeOwned>(
@@ -146,19 +140,14 @@ impl AuthedClient {
"{}/api/w/{}/jobs/result_by_id/{}/{}",
self.base_internal_url, self.workspace, flow_job_id, node_id
);
let query = if let Some(json_path) = json_path {
vec![("json_path", json_path)]
} else {
vec![]
};
let response = self.get(&url, query).await?;
match response.status().as_u16() {
200u16 => Ok(response
.json::<T>()
.await
.context("decoding result by id as json")?),
_ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())),
}
let query = query_from_json_path(json_path);
make_basic_get_request(
self,
&url,
Some(query),
Some("decoding result by id as json"),
)
.await
}
pub async fn upload_s3_file<S>(
@@ -245,3 +234,33 @@ impl AuthedClient {
}
}
}
#[inline]
fn query_from_json_path(json_path: Option<String>) -> Vec<(&'static str, String)> {
json_path
.map(|json_path| vec![("json_path", json_path)])
.unwrap_or_else(|| Vec::new())
}
#[inline]
async fn make_basic_get_request<T: DeserializeOwned>(
client: &AuthedClient,
url: &str,
query: Option<Vec<(&'static str, String)>>,
context: Option<&'static str>,
) -> anyhow::Result<T> {
let response = client
.get(&url, query.unwrap_or_else(|| Vec::new()))
.await?;
match response.status().as_u16() {
200u16 => {
let json_body = response
.json::<T>()
.await
.context(context.unwrap_or("error decoding body as json"))?;
Ok(json_body)
}
_ => Err(anyhow::anyhow!(response.text().await.unwrap_or_default())),
}
}
+5 -2
View File
@@ -192,6 +192,8 @@ pub struct FlowValue {
pub priority: Option<i16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chat_input_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_env: Option<HashMap<String, Box<RawValue>>>
}
impl FlowValue {
@@ -643,6 +645,7 @@ pub enum InputTransform {
#[serde(default = "default_empty_string")]
expr: String,
},
Ai,
}
impl InputTransform {
@@ -661,6 +664,7 @@ impl TryFrom<UntaggedInputTransform> for InputTransform {
let input_transform = match value.type_.as_str() {
"static" => InputTransform::new_static_value(value.value.unwrap_or_else(default_null)),
"javascript" => InputTransform::new_javascript_expr(&value.expr.unwrap_or_default()),
"ai" => InputTransform::Ai,
other => {
return Err(anyhow::anyhow!(
"got value: {other} for field `type`, expected value: `static` or `javascript`"
@@ -816,8 +820,7 @@ pub struct McpToolValue {
pub exclude_tools: Vec<String>,
}
fn is_none_or_empty_vec<T>(expr: &Option<Vec<T>>) -> bool
{
fn is_none_or_empty_vec<T>(expr: &Option<Vec<T>>) -> bool {
expr.is_none() || expr.as_ref().unwrap().is_empty()
}
+1
View File
@@ -53,6 +53,7 @@ pub mod job_metrics;
pub mod job_s3_helpers_ee;
#[cfg(feature = "parquet")]
pub mod job_s3_helpers_oss;
pub mod lockfiles;
#[cfg(feature = "private")]
pub mod git_sync_ee;
+17
View File
@@ -0,0 +1,17 @@
use crate::scripts::ScriptLang;
pub const LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT: &str = "# from requirements.txt";
pub fn is_generated_from_raw_requirements(
lang: &Option<ScriptLang>,
lock: &Option<String>,
) -> bool {
(lang.is_some_and(|v| v == ScriptLang::Bun)
&& lock
.as_ref()
.is_some_and(|v| v.contains("generatedFromPackageJson")))
|| (lang.is_some_and(|v| v == ScriptLang::Python3)
&& lock
.as_ref()
.is_some_and(|v| v.starts_with(LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT)))
}
+22 -8
View File
@@ -732,18 +732,32 @@ pub fn hash_script(ns: &NewScript) -> i64 {
dh.finish() as i64
}
pub struct ClonedScript {
pub old_script: NewScript,
pub new_hash: i64,
}
pub async fn clone_script<'c>(
base_hash: ScriptHash,
w_id: &str,
deployment_message: Option<String>,
tx: &mut sqlx::Transaction<'c, sqlx::Postgres>,
) -> crate::error::Result<i64> {
let s =
sqlx::query_as::<_, Script>("SELECT * FROM script WHERE hash = $1 AND workspace_id = $2")
.bind(base_hash.0)
.bind(w_id)
.fetch_one(&mut **tx)
.await?;
) -> crate::error::Result<ClonedScript> {
let s = sqlx::query_as::<_, Script>(
"SELECT * FROM script WHERE hash = $1 AND workspace_id = $2 AND archived = false FOR UPDATE",
)
.bind(base_hash.0)
.bind(w_id)
.fetch_optional(&mut **tx)
.await?;
let s = if let Some(s) = s {
s
} else {
return Err(crate::error::Error::NotFound(format!(
"Non-archived script with hash {} not found",
base_hash.0
)));
};
let ns = NewScript {
path: s.path.clone(),
@@ -819,5 +833,5 @@ pub async fn clone_script<'c>(
.execute(&mut **tx)
.await?;
Ok(new_hash)
Ok(ClonedScript { old_script: ns, new_hash })
}
+6 -3
View File
@@ -401,8 +401,7 @@ pub async fn get_reserved_variables(
value,
description: "Custom workspace environment variable".to_string(),
is_custom: true,
})
).collect()
})).collect()
}
async fn get_cached_workspace_envs(conn: &Connection, w_id: &str) -> Vec<(String, String)> {
@@ -439,7 +438,11 @@ async fn get_cached_workspace_envs(conn: &Connection, w_id: &str) -> Vec<(String
custom_envs
}
pub async fn get_variable_or_self(path: String, db: &DB, w_id: &str) -> crate::error::Result<String> {
pub async fn get_variable_or_self(
path: String,
db: &DB,
w_id: &str,
) -> crate::error::Result<String> {
if !path.starts_with("$var:") {
return Ok(path);
}
-1
View File
@@ -498,7 +498,6 @@ pub async fn store_pull_query(wc: &WorkerConfig) {
pub const TMP_DIR: &str = "/tmp/windmill";
pub const TMP_LOGS_DIR: &str = concatcp!(TMP_DIR, "/logs");
pub const TMP_MEMORY_DIR: &str = concatcp!(TMP_DIR, "/memory");
pub const HUB_CACHE_DIR: &str = concatcp!(ROOT_CACHE_DIR, "hub");
+10 -3
View File
@@ -35,6 +35,7 @@ use windmill_common::add_time;
use windmill_common::auth::JobPerms;
#[cfg(feature = "benchmark")]
use windmill_common::bench::BenchmarkIter;
use windmill_common::lockfiles::is_generated_from_raw_requirements;
use windmill_common::jobs::{JobTriggerKind, EMAIL_ERROR_HANDLER_USER_EMAIL};
use windmill_common::utils::{configure_client, now_from_db};
use windmill_common::worker::{Connection, MIN_VERSION_SUPPORTS_DEBOUNCING, SCRIPT_TOKEN_EXPIRY};
@@ -4328,6 +4329,7 @@ pub async fn push<'c, 'd>(
skip_expr: None,
preprocessor_module: None,
chat_input_enabled: None,
flow_env: None,
};
// this is a new flow being pushed, flow_status is set to flow_value:
let flow_status: FlowStatus = FlowStatus::new(&flow_value);
@@ -5660,15 +5662,20 @@ pub async fn preprocess_dependency_job(job: &mut PulledJob, db: &DB) -> error::R
args.insert("base_hash".to_owned(), to_raw_value(&*base_hash))
});
let new_hash = windmill_common::scripts::clone_script(
let cloned_script = windmill_common::scripts::clone_script(
base_hash,
&job.workspace_id,
deployment_message,
&mut tx,
)
.await?;
new_hash
if is_generated_from_raw_requirements(&Some(cloned_script.old_script.language), &cloned_script.old_script.lock.map(|v| v.to_string())) {
return Err(Error::BadRequest(format!(
"Script at path {} is generated from raw requirements, not overriding",
job.runnable_path()
)));
}
cloned_script.new_hash
}
JobKind::FlowDependencies => {
sqlx::query_scalar!(
+6 -1
View File
@@ -32,7 +32,12 @@ const p = {
const imports = transpiler.scanImports(code);
for (const imp of imports) {
if (imp.kind == "import-statement") {
if (imp.path.startsWith(".") && !imp.path.endsWith(".ts")) {
if (
(imp.path.startsWith(".") ||
imp.path.startsWith("/u/") ||
imp.path.startsWith("/f/")) &&
!imp.path.endsWith(".ts")
) {
code = code.replaceAll(imp.path, imp.path + ".ts");
}
}
+10 -3
View File
@@ -322,16 +322,23 @@ async fn execute_windmill_tool(
// Evaluate each input transform and merge with AI-provided args
for (key, transform) in input_transforms.iter() {
// We skip static empty / null values, those are the one the AI will fill in
if let InputTransform::Static { value } = transform {
let val = value.get().trim();
if val.is_empty() || val == "null" {
match transform {
InputTransform::Static { value } => {
let val = value.get().trim();
if val.is_empty() || val == "null" {
continue;
}
}
InputTransform::Ai => {
continue;
}
_ => (),
}
let result = evaluate_input_transform::<Box<RawValue>>(
transform,
last_result.clone(),
flow_inputs.clone(),
None,
Some(ctx.client),
ctx.id_context.as_ref(),
)
+1
View File
@@ -78,6 +78,7 @@ pub fn filter_schema_by_input_transforms(
!val.is_empty() && val != "null"
}
InputTransform::Javascript { expr } => !expr.trim().is_empty(),
InputTransform::Ai => false,
};
if is_completed {
Some(key.clone())
+2 -1
View File
@@ -405,7 +405,7 @@ pub async fn run_agent(
.and_then(|fs| fs.memory_id)
{
// Read messages from memory
match read_from_memory(&job.workspace_id, memory_id, step_id).await {
match read_from_memory(db, &job.workspace_id, memory_id, step_id).await {
Ok(Some(loaded_messages)) => {
// Take the last n messages
let start_idx = loaded_messages.len().saturating_sub(context_length);
@@ -856,6 +856,7 @@ pub async fn run_agent(
if let Some(memory_id) = flow_context.flow_status.and_then(|fs| fs.memory_id) {
if let Err(e) = write_to_memory(
db,
&job.workspace_id,
memory_id,
step_id,
-1
View File
@@ -1068,7 +1068,6 @@ pub fn build_http_client(timeout_duration: std::time::Duration) -> error::Result
}
pub fn get_root_job_id(job: &MiniPulledJob) -> uuid::Uuid {
// fallback to flow_innermost_root_job and parent_job as root_job is not set if equal to innermost root job or parent job
job.root_job
.or(job.flow_innermost_root_job)
.or(job.parent_job)
+151 -44
View File
@@ -165,10 +165,98 @@ impl NetPermissions for PermissionsContainer {
#[cfg(feature = "deno_core")]
pub struct OptAuthedClient(Option<AuthedClient>);
const FLOW_INPUT_PREFIX: &'static str = "flow_input";
const ENV_KEY_PREFIX: &'static str = "flow_env";
const DOT_PATTERN: &'static str = ".";
const START_BRACKET_PATTERN: &'static str = "[\"";
const END_BRACKET_PATTERN: &'static str = "\"]";
fn try_exact_property_access(
expr: &str,
flow_input: Option<&mappable_rc::Marc<HashMap<String, Box<RawValue>>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
) -> Option<Box<RawValue>> {
let obj = if expr.starts_with(FLOW_INPUT_PREFIX) {
Some((
FLOW_INPUT_PREFIX,
flow_input.as_ref().map(|obj| obj.as_ref()),
))
} else if expr.starts_with(ENV_KEY_PREFIX) {
Some((ENV_KEY_PREFIX, flow_env))
} else {
None
};
if let Some((prefix, obj)) = obj {
let access_pattern_pos = prefix.len();
let suffix = &expr[access_pattern_pos..];
let maybe_key_name = if suffix.starts_with(DOT_PATTERN) {
let key_name_pos = DOT_PATTERN.len();
Some(&expr[key_name_pos..])
} else if suffix.starts_with(START_BRACKET_PATTERN) {
let key_name_pos = START_BRACKET_PATTERN.len();
let suffix = &suffix[key_name_pos..];
let flow_arg_name = suffix
.ends_with(END_BRACKET_PATTERN)
.then(|| {
let start_key_name_pos = access_pattern_pos + key_name_pos;
let end_key_name_pos = expr.len() - END_BRACKET_PATTERN.len();
&expr[start_key_name_pos..end_key_name_pos]
})
.filter(|s| s.len() > 0);
flow_arg_name
} else {
None
};
if let Some(key_name) = maybe_key_name {
if let Some(key_value) = obj.and_then(|obj| obj.get(key_name)) {
return Some(key_value.clone());
}
}
}
None
}
async fn handle_full_regex(
captures: regex::Captures<'_>,
authed_client: &AuthedClient,
by_id: &IdContext,
) -> anyhow::Result<Box<RawValue>> {
let obj_name = captures.get(1).unwrap().as_str();
let obj_key = captures.get(2).unwrap().as_str();
let idx_o = captures.get(3).map(|y| y.as_str());
let rest = captures.get(4).map(|y| y.as_str());
let query = if let Some(idx) = idx_o {
match rest {
Some(rest) => Some(format!("{}{}", idx, rest)),
None => Some(idx.to_string()),
}
} else {
rest.map(|x| x.trim_start_matches('.').to_string())
};
let result = if obj_name == "results" {
authed_client
.get_result_by_id(&by_id.flow_job.to_string(), obj_key, query)
.await
} else if obj_name == "flow_env" {
authed_client
.get_flow_env_by_flow_job_id(&by_id.flow_job.to_string(), obj_key, query)
.await
} else {
unreachable!();
};
return result;
}
pub async fn eval_timeout(
expr: String,
transform_context: HashMap<String, Arc<Box<RawValue>>>,
flow_input: Option<mappable_rc::Marc<HashMap<String, Box<RawValue>>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
authed_client: Option<&AuthedClient>,
by_id: Option<&IdContext>,
#[allow(unused_variables)] ctx: Option<Vec<(String, String)>>,
@@ -180,21 +268,13 @@ pub async fn eval_timeout(
expr,
transform_context
);
for (k, v) in transform_context.iter() {
if k == &expr {
return Ok(v.as_ref().clone());
}
if let Some(value) = transform_context.get(&expr) {
return Ok(value.as_ref().to_owned());
}
if expr.starts_with("flow_input.") || expr.starts_with("flow_input[") {
if let Some(ref flow_input) = flow_input {
for (k, v) in flow_input.iter() {
if &format!("flow_input.{k}") == &expr || &format!("flow_input[\"{k}\"]") == &expr {
// tracing::error!("FLOW_INPUT");
return Ok(v.clone());
}
}
}
if let Some(value) = try_exact_property_access(&expr, flow_input.as_ref(), flow_env) {
return Ok(value);
}
let p_ids = by_id.map(|x| {
@@ -219,24 +299,8 @@ pub async fn eval_timeout(
}
if let (Some(by_id), Some(authed_client)) = (by_id, authed_client) {
if let Some((id, idx_o, rest)) = RE_FULL.captures(&expr).map(|x| {
(
x.get(1).unwrap().as_str(),
x.get(2).map(|y| y.as_str()),
x.get(3).map(|y| y.as_str()),
)
}) {
let query = if let Some(idx) = idx_o {
match rest {
Some(rest) => Some(format!("{}{}", idx, rest)),
None => Some(idx.to_string()),
}
} else {
rest.map(|x| x.trim_start_matches('.').to_string())
};
return authed_client
.get_result_by_id(&by_id.flow_job.to_string(), id, query)
.await;
if let Some(captures) = RE_FULL.captures(&expr) {
return handle_full_regex(captures, authed_client, by_id).await;
}
}
@@ -271,8 +335,8 @@ pub async fn eval_timeout(
if by_id.is_some() && authed_client.is_some() {
ops.push(op_get_result());
ops.push(op_get_id());
ops.push(op_get_flow_env());
}
let ext = Extension { name: "js_eval", ops: ops.into(), ..Default::default() };
let exts = vec![ext];
// Use our snapshot to provision our new runtime
@@ -307,13 +371,15 @@ pub async fn eval_timeout(
let mut client = authed_client.clone();
if let Some(client) = client.as_mut() {
client.force_client = Some(
configure_client(reqwest::ClientBuilder::new()
.user_agent("windmill/beta")
.danger_accept_invalid_certs(
std::env::var("ACCEPT_INVALID_CERTS").is_ok(),
))
.build()
.unwrap(),
configure_client(
reqwest::ClientBuilder::new()
.user_agent("windmill/beta")
.danger_accept_invalid_certs(
std::env::var("ACCEPT_INVALID_CERTS").is_ok(),
),
)
.build()
.unwrap(),
);
}
op_state.put(OptAuthedClient(client));
@@ -323,7 +389,7 @@ pub async fn eval_timeout(
.into_iter()
.filter(|(a, _)| context_keys.contains(a))
.collect(),
})
});
}
sender
@@ -379,10 +445,12 @@ fn replace_with_await(expr: String, fn_name: &str) -> String {
}
lazy_static! {
static ref RE: Regex =
Regex::new(r#"(?m)(?P<r>results(?:\?)?(?:(?:\.[a-zA-Z_0-9]+)|(?:\[\".*?\"\])))"#).unwrap();
static ref RE_FULL: Regex =
Regex::new(r"(?m)^results(?:\?)?\.([a-zA-Z_0-9]+)(?:\[(\d+)\])?((?:\.[a-zA-Z_0-9]+)+)?$")
Regex::new(r#"(?m)(?P<r>(?:results|flow_env)(?:\?)?(?:(?:\.[a-zA-Z_0-9]+)|(?:\[\".*?\"\])))"#)
.unwrap();
static ref RE_FULL: Regex = Regex::new(
r"(?m)^(results|flow_env)(?:\?)?\.([a-zA-Z_0-9]+)(?:\[(\d+)\])?((?:\.[a-zA-Z_0-9]+)+)?$"
)
.unwrap();
static ref RE_PROXY: Regex =
Regex::new(r"^(https?)://(([^:@\s]+):([^:@\s]+)@)?([^:@\s]+)(:(\d+))?$").unwrap();
}
@@ -453,6 +521,17 @@ const results = new Proxy({{}}, {{
}}
}});
async function flow_env_by_var_name(var_name) {{
let root_job_id = "{}";
return JSON.parse(await Deno.core.ops.op_get_flow_env(root_job_id, var_name, null));
}}
const flow_env = new Proxy({{}}, {{
get: function(target, name, receiver) {{
return flow_env_by_var_name(name);
}}
}});
"#,
by_id
.steps_results
@@ -469,6 +548,7 @@ const results = new Proxy({{}}, {{
.join(","),
by_id.previous_id,
by_id.flow_job,
by_id.flow_job
)
} else {
String::new()
@@ -641,6 +721,33 @@ async fn op_resource(
}
}
#[cfg(feature = "deno_core")]
#[op2(async)]
#[string]
async fn op_get_flow_env(
op_state: Rc<RefCell<OpState>>,
#[string] root_job_id: String,
#[string] var_name: String,
#[string] json_path: Option<String>,
) -> Result<Option<String>, deno_error::JsErrorBox> {
let client = op_state.borrow().borrow::<OptAuthedClient>().0.clone();
if let Some(client) = client {
client
.get_flow_env_by_flow_job_id::<Option<Box<RawValue>>>(
&root_job_id,
&var_name,
json_path,
)
.await
.map(|value| value.map(|val| val.get().to_string()))
.map_err(|e| deno_error::JsErrorBox::generic(e.to_string()))
} else {
Err(deno_error::JsErrorBox::generic(
"No client found in op state",
))
}
}
#[cfg(feature = "deno_core")]
pub struct TransformContext {
pub envs: HashMap<String, Arc<Box<RawValue>>>,
@@ -1318,7 +1425,7 @@ multiline template`";
op_state.put(TransformContext { flow_input: None, envs: env.clone() })
}
let res = eval_timeout(code.to_string(), env, None, None, None, None).await?;
let res = eval_timeout(code.to_string(), env, None, None, None, None, None).await?;
assert_eq!(res.get(), "2");
Ok(())
}
+97 -46
View File
@@ -1,72 +1,123 @@
use crate::ai::types::OpenAIMessage;
use std::path::PathBuf;
use tokio::{fs, io::AsyncWriteExt};
use uuid::Uuid;
use windmill_common::worker::TMP_MEMORY_DIR;
use windmill_common::{db::DB, error::Error};
/// Get the file path for storing memory for a specific AI agent step
pub fn path_for(workspace_id: &str, conversation_id: Uuid, step_id: &str) -> PathBuf {
PathBuf::from(TMP_MEMORY_DIR)
.join(workspace_id)
.join(conversation_id.to_string())
.join(format!("{step_id}.json"))
}
pub const MAX_MEMORY_SIZE_BYTES: usize = 100_000; // 100KB per memory entry in database
/// Read messages from disk storage
pub async fn read_from_disk(
/// Read AI agent memory from database
pub async fn read_from_db(
db: &DB,
workspace_id: &str,
conversation_id: Uuid,
step_id: &str,
) -> anyhow::Result<Option<Vec<OpenAIMessage>>> {
let path = path_for(workspace_id, conversation_id, step_id);
if !fs::try_exists(&path).await? {
return Ok(None);
}
) -> Result<Option<Vec<OpenAIMessage>>, Error> {
let result = sqlx::query!(
"SELECT messages FROM ai_agent_memory
WHERE workspace_id = $1 AND conversation_id = $2 AND step_id = $3",
workspace_id,
conversation_id,
step_id
)
.fetch_optional(db)
.await?;
let bytes = fs::read(&path).await?;
let messages: Vec<OpenAIMessage> = serde_json::from_slice(&bytes)?;
Ok(Some(messages))
match result {
Some(row) => {
let messages: Vec<OpenAIMessage> = serde_json::from_value(row.messages)?;
Ok(Some(messages))
}
None => Ok(None),
}
}
/// Write messages to disk storage
pub async fn write_to_disk(
/// Write AI agent memory to database with size checking and truncation
pub async fn write_to_db(
db: &DB,
workspace_id: &str,
conversation_id: Uuid,
step_id: &str,
messages: &[OpenAIMessage],
) -> anyhow::Result<()> {
let path = path_for(workspace_id, conversation_id, step_id);
// Ensure parent directories exist
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).await?;
) -> Result<(), Error> {
if messages.is_empty() {
return Ok(());
}
// Write atomically using a temporary file
let tmp = path.with_extension("json.tmp");
let mut f = fs::File::create(&tmp).await?;
f.write_all(&serde_json::to_vec(messages)?).await?;
f.flush().await?;
drop(f);
// Serialize messages and check size
let mut messages_to_store = messages.to_vec();
let mut json_value = serde_json::to_value(&messages_to_store)?;
let size_bytes = json_value.to_string().len();
// Atomic rename
fs::rename(tmp, &path).await?;
// Truncate if necessary
if size_bytes > MAX_MEMORY_SIZE_BYTES {
tracing::warn!(
"Memory size ({} bytes) exceeds limit ({} bytes) for workspace={} conversation={} step={}. Truncating messages. Use S3 storage in workspace settings to store full conversation history.",
size_bytes,
MAX_MEMORY_SIZE_BYTES,
workspace_id,
conversation_id,
step_id
);
messages_to_store = truncate_messages(messages, MAX_MEMORY_SIZE_BYTES)?;
json_value = serde_json::to_value(&messages_to_store)?;
}
// Insert or update using UPSERT
sqlx::query!(
"INSERT INTO ai_agent_memory (workspace_id, conversation_id, step_id, messages, created_at, updated_at)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (workspace_id, conversation_id, step_id)
DO UPDATE SET
messages = EXCLUDED.messages,
updated_at = NOW()",
workspace_id,
conversation_id,
step_id,
json_value
)
.execute(db)
.await?;
Ok(())
}
/// Delete all memory for a conversation from disk storage
pub async fn delete_conversation_from_disk(
/// Delete all memory for a conversation from database
pub async fn delete_conversation_from_db(
db: &DB,
workspace_id: &str,
conversation_id: Uuid,
) -> anyhow::Result<()> {
let conversation_path = PathBuf::from(TMP_MEMORY_DIR)
.join(workspace_id)
.join(conversation_id.to_string());
if fs::try_exists(&conversation_path).await? {
fs::remove_dir_all(&conversation_path).await?;
}
) -> Result<(), Error> {
sqlx::query!(
"DELETE FROM ai_agent_memory
WHERE workspace_id = $1 AND conversation_id = $2",
workspace_id,
conversation_id
)
.execute(db)
.await?;
Ok(())
}
/// Truncate messages to fit within the size limit
fn truncate_messages(
messages: &[OpenAIMessage],
max_size_bytes: usize,
) -> Result<Vec<OpenAIMessage>, Error> {
let mut result = messages.to_vec();
// Keep removing oldest messages until we're under the threshold
while !result.is_empty() {
let test_json = serde_json::to_value(&result)?;
let test_size = test_json.to_string().len();
if test_size <= max_size_bytes {
break;
}
// Remove the first (oldest) message
result.remove(0);
}
Ok(result)
}
+16 -7
View File
@@ -3,23 +3,27 @@
pub use crate::memory_ee::*;
#[cfg(not(all(feature = "private", feature = "enterprise")))]
use {crate::ai::types::OpenAIMessage, crate::memory_common, uuid::Uuid};
use {crate::ai::types::OpenAIMessage, crate::memory_common, uuid::Uuid, windmill_common::db::DB};
/// Read AI agent memory from storage
/// In OSS: always reads from disk
/// In OSS: always reads from database
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn read_from_memory(
db: &DB,
workspace_id: &str,
conversation_id: Uuid,
step_id: &str,
) -> anyhow::Result<Option<Vec<OpenAIMessage>>> {
memory_common::read_from_disk(workspace_id, conversation_id, step_id).await
memory_common::read_from_db(db, workspace_id, conversation_id, step_id)
.await
.map_err(|e| anyhow::anyhow!("Database read failed: {e:?}"))
}
/// Write AI agent memory to storage
/// In OSS: always writes to disk
/// In OSS: always writes to database
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn write_to_memory(
db: &DB,
workspace_id: &str,
conversation_id: Uuid,
step_id: &str,
@@ -29,15 +33,20 @@ pub async fn write_to_memory(
return Ok(());
}
memory_common::write_to_disk(workspace_id, conversation_id, step_id, messages).await
memory_common::write_to_db(db, workspace_id, conversation_id, step_id, messages)
.await
.map_err(|e| anyhow::anyhow!("Database write failed: {e:?}"))
}
/// Delete all memory for a conversation from storage
/// In OSS: always deletes from disk
/// In OSS: always deletes from database
#[cfg(not(all(feature = "private", feature = "enterprise")))]
pub async fn delete_conversation_memory(
db: &DB,
workspace_id: &str,
conversation_id: Uuid,
) -> anyhow::Result<()> {
memory_common::delete_conversation_from_disk(workspace_id, conversation_id).await
memory_common::delete_conversation_from_db(db, workspace_id, conversation_id)
.await
.map_err(|e| anyhow::anyhow!("Database delete failed: {e:?}"))
}
@@ -12,6 +12,7 @@ use tokio::{fs::DirBuilder, process::Command, sync::RwLock};
use uuid::Uuid;
use windmill_common::{
error::{self, Error},
lockfiles::LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT,
worker::Connection,
};
@@ -22,7 +23,6 @@ use crate::{
common::{start_child_process, OccupancyMetrics},
handle_child::handle_child,
python_executor::{PYTHON_PATH, UV_PATH},
worker_lockfiles::LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT,
HOME_ENV, INSTANCE_PYTHON_VERSION, PATH_ENV, PROXY_ENVS, PY_INSTALL_DIR, WIN_ENVS,
};
@@ -5,15 +5,12 @@ use windmill_common::{
cache,
error::{Error, Result},
flows::{FlowModuleValue, FlowValue},
scripts::ScriptLang,
};
use std::collections::HashSet;
use crate::worker_lockfiles::{
extract_relative_imports, is_generated_from_raw_requirements,
LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT,
};
use crate::worker_lockfiles::extract_relative_imports;
use windmill_common::lockfiles::is_generated_from_raw_requirements;
// TODO: To be removed in future versions
lazy_static::lazy_static! {
@@ -295,16 +292,7 @@ SELECT importer_node_id, imported_path
let mut dmap = ScopedDependencyMap::fetch(w_id, &r.path, "script", db).await?;
let mut tx = db.begin().await?;
if (smd.language.is_some_and(|v| v == ScriptLang::Bun)
&& sd
.lock
.as_ref()
.is_some_and(|v| v.contains("generatedFromPackageJson")))
|| (smd.language.is_some_and(|v| v == ScriptLang::Python3)
&& sd.lock.as_ref().is_some_and(|v| {
v.starts_with(LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT)
}))
{
if is_generated_from_raw_requirements(&smd.language, &sd.lock) {
// if the lock file is generated from a package.json/requirements.txt, we need to clear the dependency map
// because we do not want to have dependencies be recomputed automatically. Empty relative imports passed
// to update_script_dependency_map will clear the dependency map.
@@ -350,7 +338,7 @@ SELECT importer_node_id, imported_path
match fmv {
// Since we fetched from flow_version it is safe to assume all inline scripts are in form of RawScript.
FlowModuleValue::RawScript { content, language, lock ,.. } => {
if !is_generated_from_raw_requirements(Some(*language), lock) {
if !is_generated_from_raw_requirements(&Some(*language), lock) {
to_process.push((
extract_relative_imports(
content,
+2 -2
View File
@@ -877,7 +877,7 @@ pub fn start_interactive_worker_shell(
token,
precomputed_agent_info: precomputed_bundle,
} = extract_job_and_perms(job, &conn).await;
let authed_client = AuthedClient::new(
base_internal_url.to_owned(),
job.workspace_id.clone(),
@@ -886,7 +886,7 @@ pub fn start_interactive_worker_shell(
);
let arc_job = Arc::new(job);
let _ = handle_queued_job(
arc_job.clone(),
raw_code,
+70 -18
View File
@@ -245,6 +245,7 @@ async fn evaluate_stop_after_all_iters_if(
let stop_early_after_all_iters = compute_bool_from_expr(
&stop_after_all_iters_if.expr,
Marc::new(args),
None,
iters_result.clone(),
None,
None,
@@ -427,17 +428,20 @@ pub async fn update_flow_status_after_job_completion_internal(
)
.fetch_one(db)
.await;
let args =
args.map(|flow_args| flow_args.map(|flow_args| flow_args.0).unwrap_or_default());
args
}));
let from_result_to_args =
|args: &Result<Option<Json<HashMap<String, Box<RawValue>>>>, sqlx::Error>| {
let args = args.as_ref().map_err(|e| {
Error::internal_err(format!("retrieval of args from state: {e:#}"))
})?;
Ok::<_, Error>(args.clone().unwrap_or_default().0)
};
let from_result_to_args = |args: &Result<HashMap<String, Box<RawValue>>, sqlx::Error>| {
let args = args
.as_ref()
.map_err(|e| Error::internal_err(format!("retrieval of args from state: {e:#}")))?;
Ok::<_, Error>(args.clone())
};
let (mut stop_early, mut stop_early_err_msg, mut skip_if_stop_early, continue_on_error) =
if stop_early_override.is_some()
@@ -469,9 +473,11 @@ pub async fn update_flow_status_after_job_completion_internal(
_ => None,
};
let args = from_result_to_args(args.as_ref().await.get_ref())?;
compute_bool_from_expr(
&expr,
Marc::new(args),
None,
result.clone(),
all_iters,
None,
@@ -736,6 +742,7 @@ pub async fn update_flow_status_after_job_completion_internal(
&mut stop_early_err_msg,
&mut nresult,
args,
)
.await?;
}
@@ -924,6 +931,7 @@ pub async fn update_flow_status_after_job_completion_internal(
.and_then(|x| x.stop_after_all_iters_if.as_ref())
{
let args = from_result_to_args(args.as_ref().await.get_ref())?;
evaluate_stop_after_all_iters_if(
db,
stop_after_all_iters_if,
@@ -1016,6 +1024,7 @@ pub async fn update_flow_status_after_job_completion_internal(
&old_status.retry,
result.clone(),
Marc::new(args),
None,
Some(client),
)
.await?
@@ -1334,6 +1343,7 @@ pub async fn update_flow_status_after_job_completion_internal(
&old_status.retry,
result.clone(),
Marc::new(args),
None,
Some(client),
)
.await?
@@ -1824,6 +1834,7 @@ async fn evaluate_retry(
status: &RetryStatus,
result: Arc<Box<RawValue>>,
flow_args: Marc<HashMap<String, Box<RawValue>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
client: Option<&AuthedClient>,
) -> anyhow::Result<Option<(u32, Duration)>> {
if status.fail_count > MAX_RETRY_ATTEMPTS {
@@ -1834,6 +1845,7 @@ async fn evaluate_retry(
let should_retry = compute_bool_from_expr(
&retry_if.expr,
flow_args,
flow_env,
result,
None,
None,
@@ -1857,6 +1869,7 @@ async fn evaluate_retry(
async fn compute_bool_from_expr(
expr: &str,
flow_args: Marc<HashMap<String, Box<RawValue>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
result: Arc<Box<RawValue>>,
all_iters: Option<Arc<Box<RawValue>>>,
by_id: Option<&IdContext>,
@@ -1881,6 +1894,7 @@ async fn compute_bool_from_expr(
format!("Boolean({expr})"),
context,
Some(flow_args),
flow_env,
client,
by_id,
ctx,
@@ -1906,15 +1920,13 @@ pub async fn evaluate_input_transform<T>(
transform: &InputTransform,
last_result: Arc<Box<RawValue>>,
flow_args: Option<Marc<HashMap<String, Box<RawValue>>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
authed_client: Option<&AuthedClient>,
by_id: Option<&IdContext>,
) -> error::Result<T>
where
T: for<'de> serde::Deserialize<'de> + Send,
T: for<'de> serde::Deserialize<'de> + Send + Default,
{
let mut context = HashMap::with_capacity(2);
context.insert("result".to_string(), last_result.clone());
context.insert("previous_result".to_string(), last_result.clone());
match transform {
InputTransform::Static { value } => serde_json::from_str(value.get()).map_err(|e| {
Error::ExecutionErr(format!(
@@ -1923,10 +1935,14 @@ where
))
}),
InputTransform::Javascript { expr } => {
let mut context = HashMap::with_capacity(2);
context.insert("result".to_string(), last_result.clone());
context.insert("previous_result".to_string(), last_result.clone());
let result = eval_timeout(
expr.to_string(),
context,
flow_args,
flow_env,
authed_client,
by_id,
None,
@@ -1947,6 +1963,7 @@ where
))
})
}
InputTransform::Ai => Ok(T::default()),
}
}
@@ -1954,6 +1971,7 @@ where
#[instrument(level = "trace", skip_all)]
async fn transform_input(
flow_args: Marc<HashMap<String, Box<RawValue>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
last_result: Arc<Box<RawValue>>,
input_transforms: &HashMap<String, InputTransform>,
resumes: Arc<Box<RawValue>>,
@@ -1995,6 +2013,7 @@ async fn transform_input(
expr.to_string(),
env.clone(),
Some(flow_args.clone()),
flow_env,
Some(client),
Some(by_id),
None,
@@ -2007,6 +2026,7 @@ async fn transform_input(
})?;
mapped.insert(key.to_string(), v);
}
InputTransform::Ai => (),
}
}
@@ -2067,6 +2087,7 @@ pub async fn handle_flow(
);
}
}
let mut rec = PushNextFlowJobRec { flow_job: flow_job, status: status };
loop {
let PushNextFlowJobRec { flow_job, status } = rec;
@@ -2202,13 +2223,14 @@ async fn push_next_flow_job(
// tracing::error!("status_module: {status_module:#?}");
let fj: mappable_rc::Marc<MiniPulledJob> = flow_job.clone().into();
let arc_flow_job_args: Marc<HashMap<String, Box<RawValue>>> = Marc::map(fj, |x| {
if let Some(args) = &x.args {
&args.0
} else {
&EHM
}
});
let arc_flow_job_args: Marc<HashMap<String, Box<RawValue>>> =
Marc::map(fj, |x: &MiniPulledJob| {
if let Some(args) = &x.args {
&args.0
} else {
&EHM
}
});
// if this is an empty module without preprocessor of if the module has already been completed, successfully, update the parent flow
if (flow.modules.is_empty() && !step.is_preprocessor_step())
@@ -2298,6 +2320,7 @@ async fn push_next_flow_job(
let skip = compute_bool_from_expr(
&skip_expr,
arc_flow_job_args.clone(),
flow.flow_env.as_ref(),
Arc::new(to_raw_value(&json!("{}"))),
None,
None,
@@ -2419,6 +2442,7 @@ async fn push_next_flow_job(
expr.to_string(),
context,
Some(arc_flow_job_args.clone()),
flow.flow_env.as_ref(),
None,
None,
None
@@ -2441,6 +2465,9 @@ async fn push_next_flow_job(
)));
}
}
InputTransform::Ai => {
user_groups_required = Vec::new();
}
}
} else {
user_groups_required = Vec::new();
@@ -2676,6 +2703,7 @@ async fn push_next_flow_job(
&input_transform,
arc_last_job_result.clone(),
Some(arc_flow_job_args.clone()),
flow.flow_env.as_ref(),
Some(client),
None,
)
@@ -2713,6 +2741,7 @@ async fn push_next_flow_job(
&status.retry,
arc_last_job_result.clone(),
arc_flow_job_args.clone(),
flow.flow_env.as_ref(),
Some(client),
)
.await?
@@ -2802,6 +2831,7 @@ async fn push_next_flow_job(
compute_bool_from_expr(
&skip_if.expr,
arc_flow_job_args.clone(),
flow.flow_env.as_ref(),
arc_last_job_result.clone(),
None,
Some(&idcontext),
@@ -2893,6 +2923,7 @@ async fn push_next_flow_job(
};
transform_input(
arc_flow_job_args.clone(),
flow.flow_env.as_ref(),
arc_last_job_result.clone(),
input_transforms,
resumes.clone(),
@@ -2919,6 +2950,7 @@ async fn push_next_flow_job(
let next_flow_transform = compute_next_flow_transform(
arc_flow_job_args.clone(),
arc_last_job_result.clone(),
flow.flow_env.as_ref(),
&flow_job,
&flow,
transform_context,
@@ -3067,6 +3099,7 @@ async fn push_next_flow_job(
.await?;
let ti = transform_input(
Marc::new(args),
flow.flow_env.as_ref(),
arc_last_job_result.clone(),
input_transforms,
resumes.clone(),
@@ -3117,6 +3150,7 @@ async fn push_next_flow_job(
.await?;
let ti = transform_input(
Marc::new(hm),
flow.flow_env.as_ref(),
arc_last_job_result.clone(),
input_transforms,
resumes.clone(),
@@ -3220,6 +3254,7 @@ async fn push_next_flow_job(
timeout_transform,
arc_last_job_result.clone(),
Some(arc_flow_job_args.clone()),
flow.flow_env.as_ref(),
Some(client),
Some(&ctx),
)
@@ -3299,6 +3334,7 @@ async fn push_next_flow_job(
parallelism_transform,
arc_last_job_result.clone(),
Some(arc_flow_job_args.clone()),
flow.flow_env.as_ref(),
Some(client),
Some(&ctx),
)
@@ -3756,6 +3792,7 @@ pub fn get_path(flow_job: &MiniPulledJob, status: &FlowStatus, module: &FlowModu
async fn compute_next_flow_transform(
arc_flow_job_args: Marc<HashMap<String, Box<RawValue>>>,
arc_last_job_result: Arc<Box<RawValue>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
flow_job: &MiniPulledJob,
flow: &FlowValue,
by_id: Option<IdContext>,
@@ -3974,6 +4011,7 @@ async fn compute_next_flow_transform(
resume,
approvers,
arc_flow_job_args,
flow_env,
client,
&parallel,
)
@@ -4069,6 +4107,7 @@ async fn compute_next_flow_transform(
let pred = compute_bool_from_expr(
&b.expr,
arc_flow_job_args.clone(),
flow.flow_env.as_ref(),
arc_last_job_result.clone(),
None,
Some(&idcontext),
@@ -4333,6 +4372,7 @@ async fn next_forloop_status(
resume: Arc<Box<RawValue>>,
approvers: Arc<Box<RawValue>>,
arc_flow_job_args: Marc<HashMap<String, Box<RawValue>>>,
flow_env: Option<&HashMap<String, Box<RawValue>>>,
client: &AuthedClient,
parallel: &bool,
) -> Result<ForLoopStatus, Error> {
@@ -4348,6 +4388,11 @@ async fn next_forloop_status(
/* Iterator is an InputTransform, evaluate it into an array. */
let itered_raw = match iterator {
InputTransform::Static { value } => to_raw_value(value),
InputTransform::Ai => {
return Err(Error::ExecutionErr(format!(
"AI input transform not supported for iterator"
)))?
}
InputTransform::Javascript { expr } => {
let mut context = HashMap::with_capacity(5);
context.insert("result".to_string(), arc_last_job_result.clone());
@@ -4360,6 +4405,7 @@ async fn next_forloop_status(
expr.to_string(),
context,
Some(arc_flow_job_args),
flow_env,
Some(client),
Some(&by_id),
None,
@@ -4423,12 +4469,18 @@ async fn next_forloop_status(
expr.to_string(),
context,
Some(arc_flow_job_args),
flow_env,
Some(client),
Some(&by_id),
None,
)
.await?
}
InputTransform::Ai => {
return Err(Error::ExecutionErr(format!(
"AI input transform not supported for iterator"
)))?
}
};
serde_json::from_str::<Vec<Box<RawValue>>>(itered_raw.get()).map_err(
|not_array| {
@@ -20,6 +20,7 @@ use windmill_common::error::Error;
use windmill_common::error::Result;
use windmill_common::flows::{FlowModule, FlowModuleValue, FlowNodeId};
use windmill_common::jobs::JobPayload;
use windmill_common::lockfiles::is_generated_from_raw_requirements;
use windmill_common::scripts::ScriptHash;
use windmill_common::utils::WarnAfterExt;
#[cfg(feature = "python")]
@@ -373,15 +374,7 @@ pub async fn process_relative_imports(
db,
)
.await?;
if (script_lang.is_some_and(|v| v == ScriptLang::Bun)
&& lock
.as_ref()
.is_some_and(|v| v.contains("generatedFromPackageJson")))
|| (script_lang.is_some_and(|v| v == ScriptLang::Python3)
&& lock
.as_ref()
.is_some_and(|v| v.starts_with(LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT)))
{
if is_generated_from_raw_requirements(script_lang, &lock) {
// if the lock file is generated from a package.json/requirements.txt, we need to clear the dependency map
// because we do not want to have dependencies be recomputed automatically. Empty relative imports passed
// to update_script_dependency_map will clear the dependency map.
@@ -445,17 +438,6 @@ pub async fn process_relative_imports(
Ok(())
}
pub fn is_generated_from_raw_requirements(lang: Option<ScriptLang>, lock: &Option<String>) -> bool {
(lang.is_some_and(|v| v == ScriptLang::Bun)
&& lock
.as_ref()
.is_some_and(|v| v.contains("generatedFromPackageJson")))
|| (lang.is_some_and(|v| v == ScriptLang::Python3)
&& lock
.as_ref()
.is_some_and(|v| v.starts_with(LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT)))
}
pub async fn trigger_dependents_to_recompute_dependencies(
w_id: &str,
script_path: &str,
@@ -1450,7 +1432,7 @@ async fn lock_modules<'c>(
if let Some(locks_to_reload) = locks_to_reload {
if !locks_to_reload.contains(&e.id) {
if !is_generated_from_raw_requirements(Some(language), &lock) {
if !is_generated_from_raw_requirements(&Some(language), &lock) {
let relative_imports = get_imports();
tx = dependency_map
.patch(relative_imports.clone(), e.id.clone(), tx)
@@ -1463,7 +1445,7 @@ async fn lock_modules<'c>(
if lock.as_ref().is_some_and(|x| !x.trim().is_empty()) {
let skip_creating_new_lock = skip_creating_new_lock(&language, &content);
if skip_creating_new_lock {
if !is_generated_from_raw_requirements(Some(language), &lock) {
if !is_generated_from_raw_requirements(&Some(language), &lock) {
let relative_imports = get_imports();
tx = dependency_map
.patch(relative_imports.clone(), e.id.clone(), tx)
@@ -2582,8 +2564,6 @@ async fn ansible_dep(
serde_json::to_string(&ansible_lockfile).map_err(|e| e.into())
}
pub const LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT: &str = "# from requirements.txt";
async fn capture_dependency_job(
job_id: &Uuid,
job_language: &ScriptLang,
@@ -2672,7 +2652,11 @@ async fn capture_dependency_job(
.await
.map(|res| {
if raw_deps {
format!("{}\n{}", LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT, res)
format!(
"{}\n{}",
windmill_common::lockfiles::LOCKFILE_GENERATED_FROM_REQUIREMENTS_TXT,
res
)
} else {
res
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.573.3";
export const VERSION = "v1.573.5";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -68,7 +68,7 @@ export {
// }
// });
export const VERSION = "1.573.3";
export const VERSION = "1.573.5";
export const WM_FORK_PREFIX = "wm-fork";
-2
View File
@@ -73,8 +73,6 @@ services:
- /var/run/docker.sock:/var/run/docker.sock
- worker_dependency_cache:/tmp/windmill/cache
- worker_logs:/tmp/windmill/logs
# for AI agent memory
- worker_memory:/tmp/windmill/memory
logging: *default-logging
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.573.3",
"version": "1.573.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.573.3",
"version": "1.573.5",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.573.3",
"version": "1.573.5",
"scripts": {
"dev": "vite dev",
"build": "vite build",
+1
View File
@@ -50,6 +50,7 @@ export interface SchemaProperty {
oneOf?: SchemaProperty[]
originalType?: string
disabled?: boolean
'x-no-s3-storage-workspace-warning'?: string
}
export interface ModalSchemaProperty {
+12 -1
View File
@@ -14,7 +14,7 @@
import { DollarSign, Plus, X, Check, Loader2, ExternalLink } from 'lucide-svelte'
import { createEventDispatcher, onDestroy, onMount, tick, untrack } from 'svelte'
import { fade } from 'svelte/transition'
import { Button, SecondsInput } from './common'
import { Alert, Button, SecondsInput } from './common'
import FieldHeader from './FieldHeader.svelte'
import type ItemPicker from './ItemPicker.svelte'
import ObjectResourceInput from './ObjectResourceInput.svelte'
@@ -126,6 +126,7 @@
| undefined)
| undefined
workspace?: string | undefined
s3StorageConfigured?: boolean
actions?: import('svelte').Snippet
innerBottomSnippet?: import('svelte').Snippet
fieldHeaderActions?: import('svelte').Snippet
@@ -185,6 +186,7 @@
appPath = undefined,
computeS3ForceViewerPolicies = undefined,
workspace = undefined,
s3StorageConfigured = true,
actions,
innerBottomSnippet,
fieldHeaderActions,
@@ -1455,6 +1457,15 @@
{:else if !noMargin}
<div class="mb-2"></div>
{/if}
{#if !s3StorageConfigured && extra['x-no-s3-storage-workspace-warning']}
<Alert
type="warning"
title={extra['x-no-s3-storage-workspace-warning']}
size="xs"
titleClass="text-2xs"
/>
{/if}
</div>
<style>
@@ -18,6 +18,7 @@
channels?: ChannelItem[]
teamId?: string
onError?: (error: Error) => void
onselectedchannelchange?: (channel: ChannelItem | undefined) => void
}
let {
@@ -28,34 +29,56 @@
minWidth = '160px',
channels = undefined,
teamId,
onError
onError,
onselectedchannelchange
}: Props = $props()
let isFetching = $state(false)
let searchResults = $state<ChannelItem[]>([])
// Only enable search mode if no channels are provided AND teamId is provided
let selectedChannelId = $state<string | undefined>(selectedChannel?.channel_id)
const searchMode = !channels && !!teamId
// Determine which channels to show: provided channels or search results
// In search mode, include the selected channel if it exists
let displayChannels = $derived(() => {
const baseChannels = channels || searchResults;
if (searchMode && selectedChannel && !baseChannels.find(c => c.channel_id === selectedChannel?.channel_id)) {
return [selectedChannel, ...baseChannels];
let displayChannels = $derived.by(() => {
const baseChannels = channels || searchResults
if (selectedChannel && !baseChannels.find(c => c.channel_id === selectedChannel?.channel_id)) {
return [selectedChannel, ...baseChannels]
}
return baseChannels
})
$effect(() => {
const newChannel = selectedChannelId
? displayChannels.find(c => c.channel_id === selectedChannelId)
: undefined
if (newChannel?.channel_id !== selectedChannel?.channel_id) {
selectedChannel = newChannel
}
})
$effect(() => {
if (selectedChannel?.channel_id !== selectedChannelId) {
selectedChannelId = selectedChannel?.channel_id
}
})
let previousChannelId = $state<string | undefined>(undefined)
$effect(() => {
if (selectedChannel?.channel_id !== previousChannelId) {
previousChannelId = selectedChannel?.channel_id
onselectedchannelchange?.(selectedChannel)
}
return baseChannels;
})
// Create separate filter text for search mode
let searchFilterText = $state('')
// Debounced search function
const debouncedSearch = debounce(async (query: string) => {
await searchChannels(query)
}, 500)
// Watch for search filter text changes (only in search mode)
$effect(() => {
if (searchMode) {
if (searchFilterText.length >= 1) {
@@ -97,37 +120,27 @@
{#if searchMode}
<Select
containerStyle={'min-width: ' + minWidth}
items={searchFilterText.length >= 1 || (searchFilterText.length === 0 && selectedChannel) ? displayChannels().filter(channel => channel.channel_id && channel.channel_name).map((channel) => ({
items={displayChannels.filter(channel => channel.channel_id && channel.channel_name).map((channel) => ({
label: channel.channel_name ?? 'Unknown Channel',
value: channel.channel_id ?? ''
})) : []}
}))}
placeholder={isFetching ? "Searching..." : (teamId ? "Search channels..." : "Select a team first")}
clearable
disabled={disabled || isFetching || !teamId}
bind:filterText={searchFilterText}
bind:value={
() => selectedChannel?.channel_id,
(value) => {
selectedChannel = value ? displayChannels().find((channel) => channel.channel_id === value) : undefined
}
}
bind:value={selectedChannelId}
/>
{:else}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayChannels().filter(channel => channel.channel_id && channel.channel_name).map((channel) => ({
items={displayChannels.filter(channel => channel.channel_id && channel.channel_name).map((channel) => ({
label: channel.channel_name ?? 'Unknown Channel',
value: channel.channel_id ?? ''
}))}
{placeholder}
clearable
disabled={disabled || displayChannels().length === 0}
bind:value={
() => selectedChannel?.channel_id,
(value) => {
selectedChannel = value ? displayChannels().find((channel) => channel.channel_id === value) : undefined
}
}
disabled={disabled || displayChannels.length === 0}
bind:value={selectedChannelId}
/>
{/if}
</div>
@@ -49,7 +49,7 @@
hideConnectButton?: boolean
} = $props()
let selectedTeam: TeamItem | undefined = undefined
let selectedTeam: TeamItem | undefined = $state(undefined)
async function connectTeams() {
if (!selectedTeam) return
@@ -496,19 +496,16 @@
minWidth="200px"
placeholder="Search Teams channels"
teamId={teams_team_id}
bind:selectedChannel={
() =>
handlerExtraArgs['channel']
? {
channel_id: handlerExtraArgs['channel'],
channel_name: handlerExtraArgs['channel_name']
}
: undefined,
(channel) => {
handlerExtraArgs['channel'] = channel?.channel_id
handlerExtraArgs['channel_name'] = channel?.channel_name
}
}
selectedChannel={handlerExtraArgs['channel']
? {
channel_id: handlerExtraArgs['channel'],
channel_name: handlerExtraArgs['channel_name']
}
: undefined}
onselectedchannelchange={(channel) => {
handlerExtraArgs['channel'] = channel?.channel_id
handlerExtraArgs['channel_name'] = channel?.channel_name
}}
onError={(e) => sendUserToast('Failed to load channels: ' + e.message, true)}
/>
</div>
@@ -3,7 +3,8 @@
'flow_input',
'results',
'resource',
'variable'
'variable',
'flow_env'
])
</script>
@@ -67,6 +68,7 @@
otherArgs?: Record<string, InputTransform>
helperScript?: DynamicInputTypes.HelperScript | undefined
isAgentTool?: boolean
s3StorageConfigured?: boolean
}
let {
@@ -91,7 +93,8 @@
editor = $bindable(undefined),
otherArgs = {},
helperScript = undefined,
isAgentTool = false
isAgentTool = false,
s3StorageConfigured = true
}: Props = $props()
let monaco: SimpleEditor | undefined = $state(undefined)
@@ -159,7 +162,13 @@
function getPropertyType(arg: InputTransform | any): PropertyType {
// For agent tools, if static with undefined/empty value, treat as 'ai', meaning the field will be filled by the AI agent dynamically.
if (isAgentTool && arg?.type === 'static' && arg?.value === undefined) {
if (
isAgentTool &&
((arg?.type === 'static' && arg?.value === undefined) || arg?.type === 'ai')
) {
if (arg?.type === 'static') {
arg.type = 'ai'
}
return 'ai'
}
@@ -563,7 +572,7 @@
if (e.detail === 'ai') {
// Switch to AI mode: static with no value
if (arg) {
arg.type = 'static'
arg.type = 'ai'
arg.value = undefined
arg.expr = undefined
}
@@ -792,6 +801,7 @@
bind:title={schema.properties[argName].title}
bind:placeholder={schema.properties[argName].placeholder}
{helperScript}
{s3StorageConfigured}
otherArgs={Object.fromEntries(
Object.entries(otherArgs).map(([key, transform]) => [
key,
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Schema } from '$lib/common'
import { VariableService, type InputTransform } from '$lib/gen'
import { VariableService, WorkspaceService, type InputTransform } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { allTrue, type DynamicInput as DynamicInputTypes } from '$lib/utils'
import { untrack } from 'svelte'
@@ -73,6 +73,22 @@
let itemPicker: ItemPicker | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
let s3StorageConfigured = $state(true)
async function checkS3Storage() {
try {
if ($workspaceStore) {
const settings = await WorkspaceService.getSettings({ workspace: $workspaceStore })
s3StorageConfigured = settings.large_file_storage?.s3_resource_path !== undefined
}
} catch (error) {
console.error('Failed to fetch workspace settings:', error)
s3StorageConfigured = true
}
}
checkS3Storage()
let keys: string[] = $state([])
$effect(() => {
let lkeys = Object.keys(schema?.properties ?? {})
@@ -110,6 +126,7 @@
bind:arg={args[argName]}
bind:schema
bind:argName={keys[index]}
argExtra={schema.properties?.[argName] ?? {}}
bind:inputCheck={
() => inputCheck[argName] ?? false, (value) => (inputCheck[argName] = value)
}
@@ -122,6 +139,7 @@
{enableAi}
{helperScript}
{isAgentTool}
{s3StorageConfigured}
otherArgs={Object.fromEntries(
Object.entries(args ?? {}).filter(([key]) => key !== argName)
)}
@@ -163,13 +163,17 @@
teamItem: { team_id: string; team_name: string } | undefined,
i: number
) {
const currentTeamChannel = $values['critical_error_channels'][i]?.teams_channel
const teamIdChanged = currentTeamChannel?.team_id !== teamItem?.team_id
$values['critical_error_channels'][i] = {
teams_channel: teamItem
? {
team_id: teamItem.team_id,
team_name: teamItem.team_name,
channel_id: undefined, // Will be set when channel is selected
channel_name: undefined
// Preserve existing channel if team didn't actually change
channel_id: teamIdChanged ? undefined : currentTeamChannel?.channel_id,
channel_name: teamIdChanged ? undefined : currentTeamChannel?.channel_name
}
: undefined
}
@@ -191,6 +195,7 @@
}
}
}
</script>
<!-- {JSON.stringify($values, null, 2)} -->
@@ -368,6 +373,11 @@
onKeyDown={() => {
licenseKeyChanged = true
}}
onBlur={() => {
if ($values[setting.key] && typeof $values[setting.key] === 'string') {
$values[setting.key] = $values[setting.key].trim()
}
}}
bind:password={$values[setting.key]}
/>
<Button
@@ -558,24 +568,25 @@
value={v?.slack_channel ?? ''}
/>
{:else if v && 'teams_channel' in v}
{@const currentTeam = $values['critical_error_channels'][i]?.teams_channel
? {
team_id: $values['critical_error_channels'][i]?.teams_channel?.team_id,
team_name: $values['critical_error_channels'][i]?.teams_channel?.team_name
}
: undefined}
{@const currentChannel = $values['critical_error_channels'][i]?.teams_channel?.channel_id
? {
channel_id: $values['critical_error_channels'][i]?.teams_channel?.channel_id,
channel_name: $values['critical_error_channels'][i]?.teams_channel?.channel_name
}
: undefined}
<div class="flex flex-row gap-2 w-full">
<TeamSelector
containerClass="w-44"
minWidth="140px"
showRefreshButton={false}
bind:selectedTeam={
() =>
$values['critical_error_channels'][i]?.teams_channel
? {
team_id:
$values['critical_error_channels'][i]?.teams_channel?.team_id,
team_name:
$values['critical_error_channels'][i]?.teams_channel
?.team_name
}
: undefined,
(team) => handleTeamChange(team, i)
}
selectedTeam={currentTeam}
onselectedteamchange={(team) => handleTeamChange(team, i)}
/>
{#if $values['critical_error_channels'][i]?.teams_channel?.team_id}
@@ -583,20 +594,8 @@
containerClass=""
placeholder="Search channels"
teamId={$values['critical_error_channels'][i]?.teams_channel?.team_id}
bind:selectedChannel={
() =>
$values['critical_error_channels'][i]?.teams_channel?.channel_id
? {
channel_id:
$values['critical_error_channels'][i]?.teams_channel
?.channel_id,
channel_name:
$values['critical_error_channels'][i]?.teams_channel
?.channel_name
}
: undefined,
(channel) => handleChannelChange(channel, i)
}
selectedChannel={currentChannel}
onselectedchannelchange={(channel) => handleChannelChange(channel, i)}
onError={(e) =>
sendUserToast('Failed to load channels: ' + e.message, true)}
/>
@@ -136,6 +136,11 @@
let shouldReloadPage = false
if ($values) {
// Trim license key before saving
if ($values['license_key'] && typeof $values['license_key'] === 'string') {
$values['license_key'] = $values['license_key'].trim()
}
const allSettings = [...Object.values(settings), scimSamlSetting].flatMap((x) =>
Object.entries(x)
)
+5 -1
View File
@@ -9,6 +9,7 @@
required?: boolean
small?: boolean
onKeyDown?: (event: KeyboardEvent) => void
onBlur?: (event: FocusEvent) => void
}
let {
@@ -17,7 +18,8 @@
disabled = false,
required = false,
small = false,
onKeyDown
onKeyDown,
onBlur
}: Props = $props()
let red = $derived(required && (password == '' || password == undefined))
@@ -43,6 +45,7 @@
type="password"
bind:value={password}
onkeydown={onKeyDown}
onblur={onBlur}
autocomplete="new-password"
{placeholder}
{disabled}
@@ -55,6 +58,7 @@
type="text"
bind:value={password}
onkeydown={bubble('keydown')}
onblur={onBlur}
autocomplete="new-password"
{placeholder}
{disabled}
+40 -27
View File
@@ -18,6 +18,7 @@
teams?: TeamItem[] | undefined
minWidth?: string
onError?: (error: Error) => void
onselectedteamchange?: (team: TeamItem | undefined) => void
}
let {
@@ -27,34 +28,56 @@
showRefreshButton = true,
teams = undefined,
minWidth = '160px',
onError
onError,
onselectedteamchange
}: Props = $props()
let isFetching = $state(false)
let searchResults = $state<TeamItem[]>([])
// Only enable search mode if no teams are provided
let selectedTeamId = $state<string | undefined>(selectedTeam?.team_id)
const searchMode = !teams
// Determine which teams to show: provided teams or search results
// In search mode, include the selected team if it exists
let displayTeams = $derived(() => {
const baseTeams = teams || searchResults;
if (searchMode && selectedTeam && !baseTeams.find(t => t.team_id === selectedTeam?.team_id)) {
return [selectedTeam, ...baseTeams];
let displayTeams = $derived.by(() => {
const baseTeams = teams || searchResults
if (selectedTeam && !baseTeams.find(t => t.team_id === selectedTeam?.team_id)) {
return [selectedTeam, ...baseTeams]
}
return baseTeams
})
$effect(() => {
const newTeam = selectedTeamId
? displayTeams.find(t => t.team_id === selectedTeamId)
: undefined
if (newTeam?.team_id !== selectedTeam?.team_id) {
selectedTeam = newTeam
}
})
$effect(() => {
if (selectedTeam?.team_id !== selectedTeamId) {
selectedTeamId = selectedTeam?.team_id
}
})
let previousTeamId = $state<string | undefined>(undefined)
$effect(() => {
if (selectedTeam?.team_id !== previousTeamId) {
previousTeamId = selectedTeam?.team_id
onselectedteamchange?.(selectedTeam)
}
return baseTeams;
})
// Create separate filter text for search mode
let searchFilterText = $state('')
// Debounced search function
const debouncedSearch = debounce(async (query: string) => {
await searchTeams(query)
}, 500)
// Watch for search filter text changes (only in search mode)
$effect(() => {
if (searchMode) {
if (searchFilterText.length >= 1) {
@@ -100,37 +123,27 @@
{#if searchMode}
<Select
containerStyle={'min-width: ' + minWidth}
items={searchFilterText.length >= 1 || (searchFilterText.length === 0 && selectedTeam) ? displayTeams().map((team) => ({
items={displayTeams.map((team) => ({
label: team.team_name,
value: team.team_id
})) : []}
}))}
placeholder={isFetching ? "Searching..." : "Search teams..."}
clearable
disabled={disabled || isFetching}
bind:filterText={searchFilterText}
bind:value={
() => selectedTeam?.team_id,
(value) => {
selectedTeam = value ? displayTeams().find((team) => team.team_id === value) : undefined
}
}
bind:value={selectedTeamId}
/>
{:else}
<Select
containerStyle={'min-width: ' + minWidth}
items={displayTeams().map((team) => ({
items={displayTeams.map((team) => ({
label: team.team_name,
value: team.team_id
}))}
placeholder="Select a team"
clearable
disabled={disabled || isFetching}
bind:value={
() => selectedTeam?.team_id,
(value) => {
selectedTeam = value ? displayTeams().find((team) => team.team_id === value) : undefined
}
}
bind:value={selectedTeamId}
/>
{/if}
</div>
@@ -19,6 +19,7 @@
import RowWrapper from '../../layout/RowWrapper.svelte'
import type { ICellRendererParams } from 'ag-grid-community'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import AppModal from '../../layout/AppModal.svelte'
interface Props {
p: ICellRendererParams<any>
@@ -47,8 +48,18 @@
}: Props = $props()
const dispatch = createEventDispatcher()
const { selectedComponent, hoverStore, mode, connectingInput } =
const { selectedComponent, hoverStore, mode, connectingInput, componentControl, app } =
getContext<AppViewerContext>('AppViewerContext')
$componentControl[id] = {
...$componentControl[id],
onDelete: () => {
// Remove associated subgrid
actions.forEach((action) => {
if (action?.type === 'modalcomponent') delete $app.subgrids?.[`${action.id}-0`]
})
}
}
</script>
<RowWrapper
@@ -198,6 +209,19 @@
replaceCallback={true}
{controls}
/>
{:else if action.type == 'modalcomponent'}
<AppModal
{render}
noWFull
id={action.id}
customCss={action.customCss}
configuration={action.configuration}
verticalAlignment="center"
preclickAction={async () => {
dispatch('toggleRow')
selectRow(p)
}}
/>
{:else if action.type == 'checkboxcomponent'}
<AppCheckbox
noInitialize
@@ -256,6 +280,19 @@
replaceCallback={true}
componentInput={action.componentInput}
/>
{:else if action.type == 'modalcomponent'}
<AppModal
{render}
noWFull
id={action.id}
customCss={action.customCss}
configuration={action.configuration}
verticalAlignment="center"
preclickAction={async () => {
dispatch('toggleRow')
selectRow(p)
}}
/>
{:else if action.type == 'checkboxcomponent'}
<AppCheckbox
noInitialize
@@ -41,6 +41,7 @@
components,
type ButtonComponent,
type CheckboxComponent,
type ModalComponent,
type SelectComponent
} from '../../../editor/component'
import { initCss } from '../../../utils'
@@ -62,7 +63,8 @@
id: string
componentInput: AppInput | undefined
configuration: RichConfigurations
actionButtons: (BaseAppComponent & (ButtonComponent | CheckboxComponent | SelectComponent))[]
actionButtons: (BaseAppComponent &
(ButtonComponent | CheckboxComponent | SelectComponent | ModalComponent))[]
initializing?: boolean | undefined
customCss?: ComponentCustomCSS<'tablecomponent'> | undefined
render: boolean
@@ -29,6 +29,7 @@
render: boolean
onOpenRecomputeIds?: string[] | undefined
onCloseRecomputeIds?: string[] | undefined
preclickAction?: (() => Promise<void>) | undefined
}
let {
@@ -40,7 +41,8 @@
noWFull = false,
render,
onOpenRecomputeIds = undefined,
onCloseRecomputeIds = undefined
onCloseRecomputeIds = undefined,
preclickAction
}: Props = $props()
const {
@@ -156,6 +158,7 @@
e?.stopPropagation()
}}
on:click={async (e) => {
await preclickAction?.()
$focusedGrid = {
parentComponentId: id,
subGridIndex: 0
@@ -176,7 +176,7 @@ export type AgChartsComponentEe = BaseComponent<'agchartscomponentee'> & {
export type ScatterChartComponent = BaseComponent<'scatterchartcomponent'>
export type TableAction = BaseAppComponent &
(ButtonComponent | CheckboxComponent | SelectComponent) &
(ButtonComponent | CheckboxComponent | SelectComponent | ModalComponent) &
GridItem
export type TableComponent = BaseComponent<'tablecomponent'> & {
@@ -9,7 +9,12 @@
import { getContext, onMount } from 'svelte'
import type { AppViewerContext, BaseAppComponent, RichConfiguration } from '../../types'
import { appComponentFromType } from '../appUtils'
import type { ButtonComponent, CheckboxComponent, SelectComponent } from '../component'
import type {
ButtonComponent,
CheckboxComponent,
ModalComponent,
SelectComponent
} from '../component'
import PanelSection from './common/PanelSection.svelte'
import { GripVertical, Inspect, List, ToggleRightIcon, ListOrdered } from 'lucide-svelte'
import { dragHandle, dragHandleZone } from '@windmill-labs/svelte-dnd-action'
@@ -17,10 +22,12 @@
import { flip } from 'svelte/animate'
import TableActionsWizard from '$lib/components/wizards/TableActionsWizard.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
interface Props {
components:
| (BaseAppComponent & (ButtonComponent | CheckboxComponent | SelectComponent))[]
| (BaseAppComponent &
(ButtonComponent | CheckboxComponent | SelectComponent | ModalComponent))[]
| undefined
actionsOrder?: RichConfiguration | undefined
id: string
@@ -48,35 +55,40 @@
const { selectedComponent, app, errorByComponent, hoverStore } =
getContext<AppViewerContext>('AppViewerContext')
function addComponent(typ: 'buttoncomponent' | 'checkboxcomponent' | 'selectcomponent') {
function addComponent(
typ: 'buttoncomponent' | 'checkboxcomponent' | 'selectcomponent' | 'modalcomponent'
) {
if (!components) {
return
}
const actionId = getNextId(components.map((x) => x.id.split('_')[1]))
const newComponent = {
...appComponentFromType(typ)(`${id}_${actionId}`),
recomputeIds: []
}
const newComponent = appComponentFromType(typ)(`${id}_${actionId}`)
if (typ == 'buttoncomponent') {
if (newComponent?.type == 'buttoncomponent') {
if (newComponent?.configuration?.size) {
// @ts-ignore
newComponent.configuration.size = { type: 'static', value: 'xs2' }
}
}
items = [
...items,
{
value: newComponent,
id: generateRandomString(),
originalIndex: items.length
if (newComponent?.type == 'modalcomponent') {
if (newComponent?.configuration?.buttonSize) {
// @ts-ignore
newComponent.configuration.buttonSize = { type: 'static', value: 'xs2' }
}
]
// Create associated subgrid
if ($app.subgrids) $app.subgrids[`${newComponent.id}-0`] = []
newComponent.id
}
components = [...components, newComponent]
const newItem = {
value: newComponent,
id: generateRandomString(),
originalIndex: items.length
}
items = [...items, newItem as any]
components = [...components, newComponent as any]
$app = $app
}
@@ -84,6 +96,10 @@
if (!components) {
return
}
if (components.find((x) => x.id === cid)?.type === 'modalcomponent') {
// Remove associated subgrid
delete $app.subgrids?.[`${cid}-0`]
}
components = components.filter((x) => x.id !== cid)
delete $errorByComponent[cid]
@@ -171,6 +187,8 @@
Select
{:else if component.type == 'checkboxcomponent'}
Toggle
{:else if component.type == 'modalcomponent'}
Modal
{/if}
</div>
</div>
@@ -192,8 +210,8 @@
</div>
<div class="w-full flex gap-2">
<Button
btnClasses="gap-1 flex items-center text-sm text-primary"
wrapperClasses="w-full"
btnClasses="gap-1 flex items-center text-xs text-primary"
wrapperClasses="flex-1"
variant="default"
on:click={() => addComponent('buttoncomponent')}
title="Add Button"
@@ -201,8 +219,8 @@
+ <Inspect size={14} />
</Button>
<Button
btnClasses="gap-1 flex items-center text-sm text-primary"
wrapperClasses="w-full"
btnClasses="gap-1 flex items-center text-xs text-primary"
wrapperClasses="flex-1"
variant="default"
on:click={() => addComponent('checkboxcomponent')}
title="Add Toggle"
@@ -210,14 +228,24 @@
+ <ToggleRightIcon size={14} />
</Button>
<Button
btnClasses="gap-1 flex items-center text-sm text-primary"
wrapperClasses="w-full"
btnClasses="gap-1 flex items-center text-xs text-primary"
wrapperClasses="flex-1"
variant="default"
on:click={() => addComponent('selectcomponent')}
title="Add Select"
>
+ <List size={14} />
</Button>
<DropdownV2
items={[
{
displayName: 'Modal button',
icon: Inspect,
action: () => addComponent('modalcomponent')
}
]}
fixedHeight={false}
></DropdownV2>
</div>
<div class="w-full flex flex-col">
{#if actionsOrder}
@@ -1,157 +0,0 @@
<script lang="ts">
import { dfs } from '$lib/components/flows/dfs'
import FlowCard from '../common/FlowCard.svelte'
import { Alert, Badge } from '$lib/components/common'
import type { FlowModule, FlowModuleValue, InputTransform, PathScript, RawScript } from '$lib/gen'
import { getContext, setContext } from 'svelte'
import type { PropPickerWrapperContext } from '../propPicker/PropPickerWrapper.svelte'
import { writable } from 'svelte/store'
import Toggle from '../../Toggle.svelte'
import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte'
import type { FlowEditorContext } from '../types'
interface Props {
noEditor: boolean
}
let { noEditor }: Props = $props()
let hideOptional = $state(false)
const { flowStateStore, flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
let scriptModules = $derived(
dfs(flowStore.val.value.modules, (x) => x)
.map((x) => [x.value, x] as [FlowModuleValue, FlowModule])
.filter((x) => x[0].type == 'script' || x[0].type == 'rawscript' || x[0].type == 'flow') as [
PathScript | RawScript,
FlowModule
][]
)
let resources = $derived(
Object.fromEntries(
scriptModules
.map(([v, m]) => [
m.id,
Object.entries(v.input_transforms)
.map((x) => {
let schema = flowStateStore.val[m.id]?.schema
let val: { argName: string; type: string } | undefined = undefined
const [k, inputTransform] = x
const v = schema?.properties[k]
if (
v?.format?.includes('resource') &&
inputTransform.type === 'static' &&
(inputTransform.value === '' ||
inputTransform.value === undefined ||
inputTransform.value === null)
) {
val = {
argName: k,
type: v.format.split('-')[1]
}
}
return val
})
.filter(Boolean)
])
.filter((x) => x[1].length > 0)
) as {
[k: string]: {
argName: string
type: string
}[]
}
)
let steps = $derived(
scriptModules
.map(
([v, m]) =>
[
v.input_transforms,
Object.entries(v.input_transforms)
.filter((x) => {
const shouldDisplay = hideOptional
? flowStateStore.val[m.id]?.schema?.required?.includes(x[0])
: true
return x[1].type == 'static' && shouldDisplay
})
.map((x) => x[0]),
m
] as [Record<string, InputTransform>, string[], FlowModule]
)
.filter(([i, f, m]) => f.length > 0)
)
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
inputMatches: writable(undefined),
focusProp: () => {},
propPickerConfig: writable(undefined),
clearFocus: () => {}
})
</script>
<div class="min-h-full">
<FlowCard {noEditor} title="All Static Inputs">
{#snippet header()}
<Toggle bind:checked={hideOptional} options={{ left: 'Hide optional inputs' }} />
{/snippet}
<div class="min-h-full flex-1">
<Alert type="info" title="Static Inputs" class="m-4"
>This page centralizes the static inputs of every steps. It is aking to a file containing
all constants. Modifying a value here modifies it in the step input directly. It is
especially useful when forking a flow to get an overview of all the variables to parametrize
that are not exposed directly as flow inputs.</Alert
>
{#if Object.keys(resources).length > 0}
<Alert type="warning" title="Missing resources" class="m-4">
The following resources are missing and the flow will not be fully runnable until they are
set. Add your own resources:
{#each Object.entries(resources) as [id, r]}
{#each r as resource}
<div class="mt-2">
<Badge color="red">{id}</Badge> is missing a resource of type{' '}
<Badge color="red">{resource?.type}</Badge> for the input{' '}
<Badge color="red">{resource?.argName}</Badge>
</div>
{/each}
{/each}
</Alert>
{/if}
{#if steps.length == 0}
<div class="mt-2"></div>
{#if flowStore.val.value.modules.length == 0}
<Alert type="warning" title="No steps" class="m-4">
This flow has no steps. Add a step to see its static inputs.
</Alert>
{:else}
<Alert type="warning" title="No static inputs" class="m-4">
This flow has no steps with static inputs. Add a step with static inputs to see them
here.
</Alert>
{/if}
{/if}
{#each steps as [_args, filter, m], index (m.id + index)}
{#if filter.length > 0}
<div class="relative h-full border-t p-4">
<p class="text-sm font-bold sticky w-full top-0 z-10 inline-flex items-center">
<span class="mr-4">{m.summary || m.value['path'] || 'Inline script'}</span>
<Badge large color="indigo">{m.id}</Badge>
</p>
<InputTransformSchemaForm
noDynamicToggle
{filter}
class="mt-2"
schema={flowStateStore.val[m.id]?.schema ?? {}}
bind:args={steps[index][0]}
/>
</div>
{/if}
{/each}
</div>
</FlowCard>
</div>
@@ -5,7 +5,7 @@
import FlowSettings from './FlowSettings.svelte'
import FlowInput from './FlowInput.svelte'
import FlowFailureModule from './FlowFailureModule.svelte'
import FlowConstants from './FlowConstants.svelte'
import FlowEnvironmentVariables from './FlowEnvironmentVariables.svelte'
import type { FlowModule, Flow, Job } from '$lib/gen'
import FlowPreprocessorModule from './FlowPreprocessorModule.svelte'
import type { TriggerContext } from '$lib/components/triggers'
@@ -102,7 +102,7 @@
{:else if $selectedId === 'Result'}
<FlowResult {noEditor} {job} {isOwner} {suspendStatus} {onOpenDetails} />
{:else if $selectedId === 'constants'}
<FlowConstants {noEditor} />
<FlowEnvironmentVariables {noEditor} />
{:else if $selectedId === 'failure'}
<FlowFailureModule {noEditor} savedModule={savedFlow?.value.failure_module} />
{:else if $selectedId === 'preprocessor'}
@@ -0,0 +1,286 @@
<script lang="ts">
import { Alert } from '$lib/components/common'
import { getContext, setContext } from 'svelte'
import type { PropPickerWrapperContext } from '../propPicker/PropPickerWrapper.svelte'
import { writable } from 'svelte/store'
import type { FlowEditorContext } from '../types'
import { Button } from '$lib/components/common'
import { Plus, Trash2 } from 'lucide-svelte'
import FlowCard from '../common/FlowCard.svelte'
import JsonEditor from '$lib/components/JsonEditor.svelte'
import Label from '$lib/components/Label.svelte'
import Select from '$lib/components/select/Select.svelte'
interface Props {
noEditor: boolean
}
type EnvVarType = 'string' | 'json'
interface EnvVarEntry {
id: string
key: string
value: any
type: EnvVarType
displayValue: string
error?: string
}
let { noEditor }: Props = $props()
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
if (!flowStore.val.value.flow_env) {
flowStore.val.value.flow_env = {}
}
let flowEnvVarsMap = $derived(new Map(Object.entries(flowStore.val.value.flow_env || {})))
function determineValueType(value: any): EnvVarType {
if (typeof value === 'string') {
try {
JSON.parse(value)
return value.trim().startsWith('{') ||
value.trim().startsWith('[') ||
value.trim().startsWith('"')
? 'json'
: 'string'
} catch {
return 'string'
}
}
return 'json'
}
let flowEnvTypes = $state<Record<string, EnvVarType>>({})
const typeOptions = [
{ label: 'String', value: 'string' as EnvVarType },
{ label: 'JSON', value: 'json' as EnvVarType }
]
$effect(() => {
for (const [key, value] of flowEnvVarsMap.entries()) {
if (!flowEnvTypes[key]) {
flowEnvTypes[key] = determineValueType(value)
}
}
})
$effect(() => {
for (const [key, type] of Object.entries(flowEnvTypes)) {
if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) {
const currentType = determineValueType(flowStore.val.value.flow_env[key])
if (currentType !== type) {
updateEnvType(key, type)
}
}
}
})
let flowEnvEntries = $derived(
Array.from(flowEnvVarsMap.entries()).map(([key, value]): EnvVarEntry => {
const stringValue = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
const type = flowEnvTypes[key] || determineValueType(value)
return {
id: key,
key,
value,
type,
displayValue: stringValue,
error: undefined
}
})
)
function addEnvVar() {
const existingKeys = Array.from(flowEnvVarsMap.keys())
let counter = 1
let newKey = `VAR_${counter}`
while (existingKeys.includes(newKey)) {
counter++
newKey = `VAR_${counter}`
}
if (!flowStore.val.value.flow_env) {
flowStore.val.value.flow_env = {}
}
flowStore.val.value.flow_env[newKey] = ''
flowEnvTypes[newKey] = 'string'
flowStore.val = flowStore.val
}
function removeEnvVar(key: string) {
if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) {
delete flowStore.val.value.flow_env[key]
delete flowEnvTypes[key]
flowStore.val = flowStore.val
}
}
function updateEnvValue(key: string, value: string, type: EnvVarType) {
if (flowStore.val.value.flow_env) {
if (type === 'json') {
try {
const parsed = JSON.parse(value)
flowStore.val.value.flow_env[key] = parsed
} catch (e) {
flowStore.val.value.flow_env[key] = value
}
} else {
flowStore.val.value.flow_env[key] = value
}
flowStore.val = flowStore.val
}
}
function updateEnvKey(oldKey: string, newKey: string) {
if (flowStore.val.value.flow_env && oldKey !== newKey && newKey.trim() !== '') {
const value = flowStore.val.value.flow_env[oldKey]
const type = flowEnvTypes[oldKey] || 'string'
const newEnvVars: Record<string, any> = {}
for (const [k, v] of flowEnvVarsMap.entries()) {
if (k === oldKey) {
newEnvVars[newKey] = value
} else {
newEnvVars[k] = v
}
}
flowStore.val.value.flow_env = newEnvVars
delete flowEnvTypes[oldKey]
flowEnvTypes[newKey] = type
flowStore.val = flowStore.val
}
}
function updateEnvType(key: string, newType: EnvVarType) {
if (flowStore.val.value.flow_env && key in flowStore.val.value.flow_env) {
const currentValue = flowStore.val.value.flow_env[key]
const stringValue =
typeof currentValue === 'string' ? currentValue : JSON.stringify(currentValue, null, 2)
flowEnvTypes[key] = newType
if (newType === 'json') {
try {
const parsed = JSON.parse(stringValue)
flowStore.val.value.flow_env[key] = parsed
} catch {
flowStore.val.value.flow_env[key] = stringValue
}
} else {
flowStore.val.value.flow_env[key] = stringValue
}
flowStore.val = flowStore.val
}
}
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
inputMatches: writable(undefined),
focusProp: () => {},
propPickerConfig: writable(undefined),
clearFocus: () => {}
})
</script>
<div class="min-h-full">
<FlowCard {noEditor} title="Flow Env Variables">
<div class="min-h-full flex-1">
<Alert type="info" title="Flow Env Variables" class="m-4">
Flow envs can be referenced in any flow step input using the syntax{' '}
<code>flow_env.VARIABLE_NAME</code> or <code>flow_env["VARIABLE_NAME"]</code>. These
variables are available in the property picker and can be used in JavaScript expressions and
input bindings. You can choose between String or JSON types for each variable - JSON types
allow complex data structures.
</Alert>
{#if flowEnvEntries.length === 0}
<Alert type="warning" title="No flow env variables" class="m-4">
This flow has no flow env variables defined. Click "Add Variable" to create your first
flow env variable.
</Alert>
{:else}
<div class="space-y-4 p-4">
{#each flowEnvEntries as entry (entry.id)}
<div class="flex flex-col gap-4 p-4 border rounded-lg bg-surface-secondary">
<div class="flex items-end gap-3">
<div class="flex-1 min-w-0 max-w-xs">
<Label label="Variable Name">
<input
type="text"
value={entry.key}
onblur={(e) => {
const newKey = e.currentTarget.value.trim()
if (newKey !== entry.key && newKey !== '') {
updateEnvKey(entry.key, newKey)
}
}}
disabled={noEditor}
class="input w-full"
placeholder="VARIABLE_NAME"
/>
</Label>
</div>
<Label label="Type">
<Select
bind:value={flowEnvTypes[entry.key]}
items={typeOptions}
disabled={noEditor}
size="sm"
class="text-sm"
/>
</Label>
{#if !noEditor}
<Button
size="sm"
color="red"
startIcon={{ icon: Trash2 }}
onClick={() => removeEnvVar(entry.key)}
>
Remove
</Button>
{/if}
</div>
<div class="flex flex-col gap-1">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="text-sm font-medium">Value</label>
{#if entry.type === 'json'}
<div class="w-full">
<JsonEditor
bind:code={entry.displayValue}
disabled={noEditor}
class="min-h-[60px] max-h-[200px]"
on:change={() => {
updateEnvValue(entry.key, entry.displayValue, 'json')
}}
/>
</div>
{:else}
<input
type="text"
value={entry.displayValue}
oninput={(e) => updateEnvValue(entry.key, e.currentTarget.value, 'string')}
disabled={noEditor}
class="input w-full"
placeholder="Variable value"
/>
{/if}
</div>
</div>
{/each}
</div>
{/if}
<div class=" p-4">
{#if !noEditor}
<Button size="sm" startIcon={{ icon: Plus }} onClick={addEnvVar} color="light">
Add Variable
</Button>
{/if}
</div>
</div>
</FlowCard>
</div>
@@ -173,7 +173,6 @@
bind:value={tool.value.include_tools}
items={toolOptions}
placeholder="Choose tools to include..."
disablePortal
/>
</Label>
</div>
@@ -183,7 +182,6 @@
bind:value={tool.value.exclude_tools}
items={toolOptions}
placeholder="Choose tools to exclude..."
disablePortal
/>
</Label>
</div>
@@ -37,6 +37,8 @@ export const AI_AGENT_SCHEMA = {
type: 'number',
description:
'Maximum number of conversation messages to store and retrieve from memory. If not set or 0, memory is disabled.',
'x-no-s3-storage-workspace-warning':
'When no S3 storage is configured in your workspace settings, memory will be stored in database, which implies a limit of 100KB per memory entry. If you need to store more messages, you should use S3 storage in your workspace settings.',
showExpr: "fields.output_type === 'text'"
},
output_schema: {
@@ -66,7 +66,7 @@
onClick={() => ($selectedId = 'constants')}
/>
{#snippet text()}
Static inputs
Environment Variables
{/snippet}
</Popover>
{/if}
@@ -9,6 +9,7 @@ export type PickableProperties = {
priorIds: Record<string, any>
previousId: string | undefined
hasResume: boolean
flow_env?: Record<string, any>
}
type StepPropPicker = {
@@ -156,7 +157,8 @@ export function getFailureStepPropPicker(flowState: FlowState, flow: OpenFlow, a
flow_input: schemaToObject(flow.schema as any, args),
priorIds: priorIds,
previousId: undefined,
hasResume: false
hasResume: false,
flow_env: flow.value.flow_env
},
extraLib: `
/**
@@ -178,6 +180,17 @@ declare const results = ${JSON.stringify(priorIds)}
* flow input as an object
*/
declare const flow_input = ${JSON.stringify(flowInput)};
${
flow.value.flow_env
? `
/**
* flow environment variables
*/
declare const flow_env = ${JSON.stringify(flow.value.flow_env)};
`
: ''
}
`
}
}
@@ -218,7 +231,8 @@ export function getStepPropPicker(
flow_input: flowInput,
priorIds: priorIds,
previousId: previousIds[0],
hasResume: previousModule?.suspend != undefined
hasResume: previousModule?.suspend != undefined,
flow_env: flow.value.flow_env
}
if (pickableProperties.hasResume) {
@@ -230,7 +244,8 @@ export function getStepPropPicker(
flowInput,
priorIds,
previousModule?.suspend != undefined,
previousModule?.id
previousModule?.id,
flow.value.flow_env
),
pickableProperties
}
@@ -240,7 +255,8 @@ export function buildExtraLib(
flowInput: Record<string, any>,
results: Record<string, any>,
resume: boolean,
previousId: string | undefined
previousId: string | undefined,
flowEnv?: Record<string, any>
): string {
return `
/**
@@ -275,6 +291,17 @@ declare const results = ${JSON.stringify(results)};
*/
declare const previous_result: ${previousId ? JSON.stringify(results[previousId]) : 'any'};
${
flowEnv
? `
/**
* flow environment variables
*/
declare const flow_env = ${JSON.stringify(flowEnv)};
`
: ''
}
${
resume
? `
@@ -31,6 +31,7 @@
import type { PickableProperties } from '../previousResults'
import AnimatedButton from '$lib/components/common/button/AnimatedButton.svelte'
import type { PropPickerContext } from '$lib/components/prop_picker'
import type { FlowEditorContext } from '../types'
interface Props {
pickableProperties: PickableProperties | undefined
@@ -69,6 +70,10 @@
const { flowPropPickerConfig } = getContext<PropPickerContext>('PropPickerContext')
flowPropPickerConfig.set(undefined)
const { flowStore } = getContext<FlowEditorContext>('FlowEditorContext')
let flow_env = $derived(pickableProperties?.flow_env || flowStore.val.value.flow_env)
setContext<PropPickerWrapperContext>('PropPickerWrapper', {
propPickerConfig,
inputMatches,
@@ -156,6 +161,7 @@
<PropPicker
{displayContext}
{error}
{flow_env}
previousId={pickableProperties?.previousId}
{pickableProperties}
allowCopy={!notSelectable && !$propPickerConfig}
@@ -18,11 +18,13 @@
export let error: boolean = false
export let allowCopy = false
export let previousId: string | undefined = undefined
export let flow_env: Record<string, string> | undefined = undefined
let variables: Record<string, string> = {}
let resources: Record<string, any> = {}
let displayVariable = false
let displayResources = false
let displayFlowEnv = false
let allResultsCollapsed = true
let collapsableInitialState:
@@ -30,6 +32,7 @@
allResultsCollapsed: boolean
displayVariable: boolean
displayResources: boolean
displayFlowEnv: boolean
}
| undefined
@@ -46,6 +49,7 @@
let flowInputsFiltered: any = pickableProperties.flow_input
let resultByIdFiltered: any = pickableProperties.priorIds
let flowEnvFiltered: any = pickableProperties.flow_env
let timeout: number | undefined
function onSearch(search: string) {
@@ -63,6 +67,9 @@
search === EMPTY_STRING
? pickableProperties.priorIds
: keepByKey(pickableProperties.priorIds, search)
flowEnvFiltered =
search === EMPTY_STRING ? pickableProperties.flow_env : keepByKey(pickableProperties.flow_env, search)
}, 50)
}
@@ -98,6 +105,7 @@
if (search === EMPTY_STRING) {
flowInputsFiltered = pickableProperties.flow_input
resultByIdFiltered = pickableProperties.priorIds
flowEnvFiltered = pickableProperties.flow_env
}
filteringFlowInputsOrResult = ''
return
@@ -109,6 +117,9 @@
if (!$inputMatches?.some((match) => match.word === 'results')) {
resultByIdFiltered = {}
}
if (!$inputMatches?.some((match) => match.word === 'flow_env')) {
flowEnvFiltered = {}
}
if ($inputMatches?.length == 1) {
filteringFlowInputsOrResult = $inputMatches[0].value
if ($inputMatches[0].word === 'flow_input') {
@@ -125,6 +136,13 @@
if (Object.keys(filtered).length > 0) {
resultByIdFiltered = filtered
}
} else if ($inputMatches[0].word === 'flow_env') {
flowEnvFiltered = pickableProperties.flow_env
let [, ...nestedKeys] = $inputMatches[0].value.split('.')
let filtered = filterNestedObject(flowEnvFiltered, nestedKeys)
if (Object.keys(filtered).length > 0) {
flowEnvFiltered = filtered
}
}
} else {
filteringFlowInputsOrResult = ''
@@ -143,7 +161,12 @@
}
if (!collapsableInitialState) {
collapsableInitialState = { allResultsCollapsed, displayVariable, displayResources }
collapsableInitialState = {
allResultsCollapsed,
displayVariable,
displayResources,
displayFlowEnv
}
}
if ($inputMatches[0].word === 'variable') {
@@ -156,6 +179,10 @@
displayResources = true
return
}
if ($inputMatches[0].word === 'flow_env') {
displayFlowEnv = true
return
}
if ($inputMatches[0].word === 'results') {
allResultsCollapsed = false
return
@@ -166,7 +193,8 @@
if (!collapsableInitialState) {
return
}
;({ allResultsCollapsed, displayVariable, displayResources } = collapsableInitialState)
;({ allResultsCollapsed, displayVariable, displayResources, displayFlowEnv } =
collapsableInitialState)
collapsableInitialState = undefined
}
@@ -183,6 +211,7 @@
if (prev && !filterActive) {
flowInputsFiltered = pickableProperties.flow_input
resultByIdFiltered = pickableProperties.priorIds
flowEnvFiltered = pickableProperties.flow_env
}
}
@@ -192,7 +221,7 @@
await updateCollapsable()
}
$: (search, $inputMatches, $propPickerConfig, pickableProperties, updateState())
$: search, $inputMatches, $propPickerConfig, pickableProperties, updateState()
onDestroy(() => {
clearTimeout(timeout)
@@ -400,6 +429,45 @@
{/if}
</div>
{/if}
{#if flow_env && Object.keys(flow_env).length > 0 && (!filterActive || $inputMatches?.some((match) => match.word === 'flow_env'))}
<div class="overflow-y-auto pb-2">
<span class="font-normal text-xs text-secondary">Flow Env Variables:</span>
{#if displayFlowEnv}
<Button
color="light"
size="xs2"
variant="border"
on:click={() => {
displayFlowEnv = false
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-mono h-4 text-2xs font-thin px-1 rounded-[0.275rem]">-</Button
>
<ObjectViewer
{allowCopy}
pureViewer={!$propPickerConfig}
rawKey={false}
json={flowEnvFiltered}
prefix="flow_env"
on:select
/>
{:else}
<Button
color="light"
size="xs2"
variant="border"
on:click={() => {
displayFlowEnv = true
}}
wrapperClasses="inline-flex whitespace-nowrap w-fit"
btnClasses="font-normal text-2xs rounded-[0.275rem] h-4 px-1"
>
{'{...}'}
</Button>
{/if}
</div>
{/if}
{/if}
<!-- </div> -->
</Scrollable>
@@ -5,6 +5,7 @@
export let result: any
export let extraResults: any = undefined
export let flow_input: any = undefined
export let flow_env: any = undefined
</script>
<div class="w-full px-2">
@@ -18,4 +19,10 @@
<ObjectViewer {allowCopy} json={flow_input} prefix="flow_input" on:select />
</div>
{/if}
{#if flow_env}
<span class="font-normal text-sm text-secondary">Flow Environment Variables</span>
<div class="overflow-y-auto w-full">
<ObjectViewer {allowCopy} json={flow_env} prefix="flow_env" on:select />
</div>
{/if}
</div>
@@ -106,7 +106,7 @@
<div
bind:this={wrapperEl}
class={twMerge(
'flex items-center flex-wrap',
'flex items-center flex-wrap relative',
inputBaseClass,
inputSizeClasses[size],
inputBorderClass({ forceFocus: open && !disabled }),
@@ -169,10 +169,7 @@
)}
style="height: {uiState.visible ? dropdownPos.height : 0}px;"
>
<div
bind:this={listEl}
class="flex flex-col max-h-64 rounded-md overflow-clip bg-surface-tertiary"
>
<div bind:this={listEl} class="flex flex-col max-h-64 rounded-md bg-surface-tertiary">
{@render header?.()}
{#if processedItems?.length === 0}
<div class="py-8 px-4 text-center text-primary text-xs">{noItemsMsg}</div>
@@ -10,13 +10,15 @@
import type {
ButtonComponent,
CheckboxComponent,
ModalComponent,
SelectComponent
} from '../apps/editor/component'
interface Props {
actionsOrder?: RichConfiguration | undefined
selectedId?: string | undefined
components:
| (BaseAppComponent & (ButtonComponent | CheckboxComponent | SelectComponent))[]
| (BaseAppComponent &
(ButtonComponent | CheckboxComponent | SelectComponent | ModalComponent))[]
| undefined
trigger?: import('svelte').Snippet
}
+2 -2
View File
@@ -4,8 +4,8 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.573.3"
wmill_pg = ">=1.573.3"
wmill = ">=1.573.5"
wmill_pg = ">=1.573.5"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+5 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.573.3
version: 1.573.5
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -62,6 +62,10 @@ components:
type: string
cache_ttl:
type: number
flow_env:
type: object
additionalProperties:
type: string
priority:
type: number
early_return:
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.573.3'
ModuleVersion = '1.573.5'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.573.3"
version = "1.573.5"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill-pg"
version = "1.573.3"
version = "1.573.5"
description = "An extension client for the wmill client library focused on pg"
license = "Apache-2.0"
homepage = "https://windmill.dev"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.573.3",
"version": "1.573.5",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.573.3",
"version": "1.573.5",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"devDependencies": {
+1 -1
View File
@@ -1 +1 @@
1.573.3
1.573.5