Merge remote-tracking branch 'origin/main' into dieri/triggers-queue-mode

This commit is contained in:
hugocasa
2025-11-27 11:25:53 +01:00
123 changed files with 2821 additions and 1963 deletions
+13 -5
View File
@@ -82,6 +82,10 @@ jobs:
run: |
set -e # Exit on any command failure
PR_NUMBER=${{ github.event.issue.number }}
# Set up error trap to comment on PR for any failure
trap 'gh pr comment $PR_NUMBER --body "❌ SQLx update failed. Please check the workflow logs for details."' ERR
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
echo "Checking out PR branch: $BRANCH_NAME"
git checkout $BRANCH_NAME
@@ -93,10 +97,8 @@ jobs:
cd backend
cargo install sqlx-cli --version 0.8.5
sqlx migrate run
if ! ./update_sqlx.sh --dir ./windmill-ee-private; then
gh pr comment $PR_NUMBER --body "❌ SQLx update failed. Please check the workflow logs for details."
exit 1
fi
./substitute_ee_code.sh --dir ./windmill-ee-private
./update_sqlx.sh
# Pass the branch name to the next step
echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV
@@ -216,11 +218,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 ee ref update...'
body: `Starting ee ref update...\n\n[View workflow run](${runUrl})`
})
- name: Checkout repository
@@ -249,7 +252,12 @@ jobs:
env:
GH_TOKEN: ${{ steps.app.outputs.token }}
run: |
set -e # Exit on any command failure
PR_NUMBER=${{ github.event.issue.number }}
# Set up error trap to comment on PR for any failure
trap 'gh pr comment $PR_NUMBER --body "❌ EE ref update failed. Please check the workflow logs for details."' ERR
BRANCH_NAME=$(gh pr view $PR_NUMBER --json headRefName --jq .headRefName)
echo "Checking out PR branch: $BRANCH_NAME"
git checkout $BRANCH_NAME
+55
View File
@@ -1,5 +1,60 @@
# Changelog
## [1.585.1](https://github.com/windmill-labs/windmill/compare/v1.585.0...v1.585.1) (2025-11-26)
### Bug Fixes
* clone group members on workspace forks ([#7232](https://github.com/windmill-labs/windmill/issues/7232)) ([faad000](https://github.com/windmill-labs/windmill/commit/faad0006b430262f1ab6b749ccf60a6860a54405))
## [1.585.0](https://github.com/windmill-labs/windmill/compare/v1.584.0...v1.585.0) (2025-11-26)
### Features
* flow for loop squashing ([#7107](https://github.com/windmill-labs/windmill/issues/7107)) ([93c34c6](https://github.com/windmill-labs/windmill/commit/93c34c6d769dd56da02064c0abdaa060efe0259d))
* **frontend:** move app reports script to hub ([#7227](https://github.com/windmill-labs/windmill/issues/7227)) ([f8a0bfc](https://github.com/windmill-labs/windmill/commit/f8a0bfc20d1ccb6ce36e3adf4188533470270bdb))
### Bug Fixes
* **cli:** improve back-compatibility with app.yaml with policy still present ([c0a92f8](https://github.com/windmill-labs/windmill/commit/c0a92f83b00341b0eee36e2dc88d301a75b6fdfb))
* **frontend:** workspace color race condition for superadmins ([#7229](https://github.com/windmill-labs/windmill/issues/7229)) ([8922be1](https://github.com/windmill-labs/windmill/commit/8922be11a6935d200e1909f14aa410c6eff67a13))
* workspace forks shouldn't inherit promotion mode repo ([#7223](https://github.com/windmill-labs/windmill/issues/7223)) ([f56f3b0](https://github.com/windmill-labs/windmill/commit/f56f3b0fe6185167e9c4c8541ddefba01c509b8d))
## [1.584.0](https://github.com/windmill-labs/windmill/compare/v1.583.3...v1.584.0) (2025-11-25)
### Features
* **app:** add chat component ([#7199](https://github.com/windmill-labs/windmill/issues/7199)) ([c47669b](https://github.com/windmill-labs/windmill/commit/c47669be0bffb0821afe9d4182cc071cb3b675f0))
### Bug Fixes
* **cli:** handle better public apps ([de7739a](https://github.com/windmill-labs/windmill/commit/de7739a3ee7ef76a8eb4fe6570ef0bdfbdb92293))
* **frontend:** forking UI issues ([#7215](https://github.com/windmill-labs/windmill/issues/7215)) ([dccee1d](https://github.com/windmill-labs/windmill/commit/dccee1dba08b4c4b170336b3f3e959fbe7bca8ce))
* **frontend:** improve preprocessor discoverability ([#7214](https://github.com/windmill-labs/windmill/issues/7214)) ([a2d3297](https://github.com/windmill-labs/windmill/commit/a2d3297343338ba0b559481723e64db5f4acf3a7))
* **frontend:** loading saved/history inputs when json view is on ([#7217](https://github.com/windmill-labs/windmill/issues/7217)) ([e87f814](https://github.com/windmill-labs/windmill/commit/e87f81470904578467f42598cfce5fad2249f131))
* **frontend:** show trigger table when no trigger selected ([#7219](https://github.com/windmill-labs/windmill/issues/7219)) ([c37dde0](https://github.com/windmill-labs/windmill/commit/c37dde096c301a502c6ff2c570b91153a5717fcd))
* list scripts without 1000 limits ([897faf0](https://github.com/windmill-labs/windmill/commit/897faf085b49ada0648594797cfe627decb50952))
## [1.583.3](https://github.com/windmill-labs/windmill/compare/v1.583.2...v1.583.3) (2025-11-24)
### Bug Fixes
* **cli:** fix pushing non existing apps with cli ([29d8ad2](https://github.com/windmill-labs/windmill/commit/29d8ad2e5de1447f39252edd5ceea22034801547))
* fix deadlock on oidc ([0e717c9](https://github.com/windmill-labs/windmill/commit/0e717c9588c1a3811cc4d438b53a93b4b13cf239))
## [1.583.2](https://github.com/windmill-labs/windmill/compare/v1.583.1...v1.583.2) (2025-11-24)
### Bug Fixes
* listScripts include description with opt-out query arg ([#7210](https://github.com/windmill-labs/windmill/issues/7210)) ([80c7911](https://github.com/windmill-labs/windmill/commit/80c79116416db8629135a8853df645d9fff286dd))
* **scim:** use value instead of display for group updates ([#7203](https://github.com/windmill-labs/windmill/issues/7203)) ([193efe3](https://github.com/windmill-labs/windmill/commit/193efe31c8aeab3a5e3e28278855d518c000bda8))
## [1.583.1](https://github.com/windmill-labs/windmill/compare/v1.583.0...v1.583.1) (2025-11-21)
@@ -0,0 +1,58 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n workspace.id AS \"id!\",\n workspace.name AS \"name!\",\n workspace.owner AS \"owner!\",\n workspace.deleted AS \"deleted!\",\n workspace.premium AS \"premium!\",\n workspace_settings.color AS \"color\",\n workspace.parent_workspace_id AS \"parent_workspace_id\"\n FROM workspace\n LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id\n WHERE workspace.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name!",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "owner!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "deleted!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "premium!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "color",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "parent_workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false,
false,
false,
true,
true
]
},
"hash": "00c1dd0cfaf15aafdcfcabc1f123cebdf8d777f48e148bcb171fa15e8bf6f098"
}
@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM flow_conversation WHERE id = $1) as \"exists!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "086fdf726b88e9f4fd9750bf9dd7f49c589465194548d88e5ae30872846b70a9"
}
@@ -59,9 +59,7 @@
"failure",
"command",
"approval",
"preprocessor",
"schedule_handler_old",
"dynamic_skip"
"preprocessor"
]
}
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status\n SET flow_status = jsonb_set(\n flow_status,\n '{memory_id}',\n to_jsonb($2::uuid)\n )\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": []
},
"hash": "348d64dc9f81f04538c5fec98df51312794c22d6337be897bc618585fa5b27f3"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr_to_group (workspace_id, group_, usr)\n SELECT $2, group_, usr\n FROM usr_to_group\n WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "3f9004b5542afaaef40942a3ca8889ea164d9cfb4962997650d922d82f000df9"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
+265 -316
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.583.1"
version = "1.585.1"
authors.workspace = true
edition.workspace = true
@@ -33,7 +33,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.583.1"
version = "1.585.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+12 -4
View File
@@ -65,15 +65,19 @@ const targets = [
# -Z build-std=panic_abort,std -Z build-std-features=panic_immediate_abort
# Build all separately
def 'main all' [ --no-opt(-n), --cli] {
def 'main all' [ --no-opt(-n), --cli, --node ] {
if ($no_opt) {
if ($cli) {
if ($node) {
$targets | each { main $in.ident -n --node }
} else if ($cli) {
$targets | each { main $in.ident -n --cli }
} else {
$targets | each { main $in.ident -n }
}
} else {
if ($cli) {
if ($node) {
$targets | each { main $in.ident --node }
} else if ($cli) {
$targets | each { main $in.ident --cli }
} else {
$targets | each { main $in.ident }
@@ -86,6 +90,7 @@ def main [
target: string, # Language, e.g.: py, ts, rust
--no-opt(-n), # Compile in debug mode for dev
--cli # Compile and place binaries in cli.
--node
] {
let t = $targets | where ident == $target;
@@ -97,7 +102,10 @@ def main [
mut profile = "";
mut tar = "";
if ($cli) {
if ($node) {
$env.OUT_DIR = $"node-pkg-($t.ident)"
$tar = "nodejs"
} else if ($cli) {
$env.OUT_DIR = $"../../../cli/wasm/($t.ident)"
$tar = "deno"
} else {
+8 -6
View File
@@ -2316,13 +2316,14 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_n
);
let error_message = format!(
"Job timed out after no ping from job since {} (ZOMBIE_JOB_TIMEOUT: {}, reason: {:?}).\nThis likely means that the job died on worker {}, OOM are a common reason for worker crashes.\nCheck the workers around the time of the last ping and the exit code if any.",
job.last_ping.unwrap_or_default(),
*ZOMBIE_JOB_TIMEOUT,
error_kind.to_string(),
job.worker.clone().unwrap_or_default(),
);
"Job timed out after no ping from job since {} (ZOMBIE_JOB_TIMEOUT: {}, reason: {:?}).\nThis likely means that the job died on worker {}, OOM are a common reason for worker crashes.\nCheck the workers around the time of the last ping and the exit code if any.",
job.last_ping.unwrap_or_default(),
*ZOMBIE_JOB_TIMEOUT,
error_kind.to_string(),
job.worker.clone().unwrap_or_default(),
);
let memory_peak = job.memory_peak.unwrap_or(0);
let (_, killpill_rx_never_used) = KillpillSender::new(1);
let _ = handle_job_error(
db,
&client,
@@ -2335,6 +2336,7 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_n
"",
node_name,
send_result_never_used,
&killpill_rx_never_used,
#[cfg(feature = "benchmark")]
&mut windmill_common::bench::BenchmarkIter::new(),
)
+2
View File
@@ -217,6 +217,7 @@ async fn test_deno_flow(db: Pool<Postgres>) -> anyhow::Result<()> {
iterator: InputTransform::Javascript { expr: "result".to_string() },
skip_failures: false,
parallel: false,
squash: None,
parallelism: None,
modules: vec![FlowModule {
id: "c".to_string(),
@@ -396,6 +397,7 @@ async fn test_deno_flow_same_worker(db: Pool<Postgres>) -> anyhow::Result<()> {
iterator: InputTransform::Static { value: windmill_common::worker::to_raw_value(&[1, 2, 3]) },
skip_failures: false,
parallel: false,
squash: None,
parallelism: None,
modules: vec![
FlowModule {
-9
View File
@@ -2,15 +2,6 @@
set -e
# Parse arguments
while [[ "$#" -gt 0 ]]; do
case $1 in
--dir) EE_DIR="$2"; shift ;;
*) echo "Unknown parameter: $1"; exit 1 ;;
esac
shift
done
# Check if running on macOS
if [[ "$(uname)" == "Darwin" ]]; then
echo "Running on macOS - substituting samael..."
+20 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.583.1
version: 1.585.1
title: Windmill API
contact:
@@ -688,6 +688,22 @@ paths:
schema:
$ref: "#/components/schemas/UserWorkspaceList"
/w/{workspace}/workspaces/get_as_superadmin:
get:
summary: get workspace as super admin (require to be super admin)
operationId: getWorkspaceAsSuperAdmin
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: workspace
content:
application/json:
schema:
$ref: "#/components/schemas/Workspace"
/workspaces/list_as_superadmin:
get:
summary: list all workspaces as super admin (require to be super admin)
@@ -7707,6 +7723,9 @@ paths:
type: array
items:
type: string
run_query_params:
type: object
description: Runnable query parameters
required:
- args
- component
+18 -1
View File
@@ -10,6 +10,7 @@ use std::{collections::HashMap, sync::Arc};
use crate::{
auth::OptTokened,
db::{ApiAuthed, DB},
jobs::RunJobQuery,
resources::get_resource_value_interpolated_internal,
users::{require_owner_of_path, OptAuthed},
utils::{check_scopes, WithStarredInfoQuery},
@@ -1654,6 +1655,8 @@ pub struct ExecuteApp {
pub force_viewer_static_fields: Option<StaticFields>,
pub force_viewer_one_of_fields: Option<OneOfFields>,
pub force_viewer_allow_user_resources: Option<AllowUserResources>,
/// Runnable query parameters (e.g., memory_id for chat-enabled flows)
pub run_query_params: Option<RunJobQuery>,
}
fn digest(code: &str) -> String {
@@ -1894,6 +1897,12 @@ async fn execute_component(
)
.await?;
let is_flow = payload
.path
.as_ref()
.map(|p| p.starts_with("flow/"))
.unwrap_or(false);
let (job_payload, tag, on_behalf_of) = match (payload.path, payload.raw_code, payload.id) {
// flow or script:
(Some(path), None, None) => get_payload_tag_from_prefixed_path(&path, &db, &w_id).await?,
@@ -1920,7 +1929,7 @@ async fn execute_component(
let end_user_email = opt_authed.as_ref().map(|a| a.email.clone());
let (uuid, tx) = push(
let (uuid, mut tx) = push(
&db,
tx,
&w_id,
@@ -1954,6 +1963,14 @@ async fn execute_component(
None
)
.await?;
// Apply runnable query parameters if provided
if let Some(ref run_query) = payload.run_query_params {
if is_flow {
crate::jobs::process_flow_run_query_params(&mut tx, uuid, run_query).await?;
}
}
tx.commit().await?;
Ok(uuid.to_string())
@@ -134,6 +134,7 @@ pub async fn get_or_create_conversation_with_id(
} else {
title.to_string()
};
// Create new conversation with provided ID
let conversation = sqlx::query_as!(
FlowConversation,
@@ -148,6 +149,7 @@ pub async fn get_or_create_conversation_with_id(
)
.fetch_one(&mut **tx)
.await?;
Ok(conversation)
}
+1
View File
@@ -1583,6 +1583,7 @@ mod tests {
skip_failures: true,
parallel: false,
parallelism: None,
squash: None,
}),
stop_after_if: Some(StopAfterIf {
expr: "previous.isEmpty()".to_string(),
+21 -8
View File
@@ -1741,7 +1741,7 @@ pub struct ListableCompletedJob {
pub labels: Option<serde_json::Value>,
}
#[derive(Deserialize, Clone, Default)]
#[derive(Debug, Deserialize, Clone, Default)]
pub struct RunJobQuery {
pub scheduled_for: Option<chrono::DateTime<chrono::Utc>>,
pub scheduled_in_secs: Option<i64>,
@@ -3955,13 +3955,13 @@ async fn set_flow_memory_id(
memory_id: Uuid,
) -> error::Result<()> {
sqlx::query!(
"UPDATE v2_job_status
SET flow_status = jsonb_set(
flow_status,
'{memory_id}',
to_jsonb($2::uuid)
)
WHERE id = $1",
"UPDATE v2_job_status
SET flow_status = jsonb_set(
flow_status,
'{memory_id}',
to_jsonb($2::uuid)
)
WHERE id = $1",
job_id,
memory_id
)
@@ -3970,6 +3970,19 @@ async fn set_flow_memory_id(
Ok(())
}
/// Apply flow-specific query parameters after job creation
pub async fn process_flow_run_query_params(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
job_id: Uuid,
run_query: &RunJobQuery,
) -> error::Result<()> {
// Set memory_id if provided (for agent memory)
if let Some(memory_id) = run_query.memory_id {
set_flow_memory_id(tx, job_id, memory_id).await?;
}
Ok(())
}
async fn handle_chat_conversation_messages(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
authed: &ApiAuthed,
+5 -5
View File
@@ -41,11 +41,11 @@ use windmill_audit::ActionKind;
use windmill_worker::{process_relative_imports, scoped_dependency_map::ScopedDependencyMap};
use windmill_common::{
assets::{clear_asset_usage, insert_asset_usage, AssetUsageKind, AssetWithAltAccessType},
assets::{AssetUsageKind, AssetWithAltAccessType, clear_asset_usage, insert_asset_usage},
error::to_anyhow,
s3_helpers::upload_artifact_to_store,
scripts::hash_script,
utils::WarnAfterExt,
utils::{WarnAfterExt, paginate_without_limits},
worker::{CLOUD_HOSTED, MIN_VERSION_SUPPORTS_DEBOUNCING},
};
@@ -61,7 +61,7 @@ use windmill_common::{
},
users::username_to_permissioned_as,
utils::{
not_found_if_none, paginate, query_elems_from_hub, require_admin, Pagination, StripPath,
not_found_if_none, query_elems_from_hub, require_admin, Pagination, StripPath,
},
worker::to_raw_value,
HUB_BASE_URL,
@@ -193,7 +193,7 @@ async fn list_search_scripts(
) -> JsonResult<Vec<SearchScript>> {
let mut tx = user_db.begin(&authed).await?;
#[cfg(feature = "enterprise")]
let n = 1000;
let n = 10000;
#[cfg(not(feature = "enterprise"))]
let n = 10;
@@ -219,7 +219,7 @@ async fn list_scripts(
Query(pagination): Query<Pagination>,
Query(lq): Query<ListScriptQuery>,
) -> JsonResult<Vec<ListableScript>> {
let (per_page, offset) = paginate(pagination);
let (per_page, offset) = paginate_without_limits(pagination);
let mut sqlb = SqlBuilder::select_from("script as o")
.fields(&[
"hash",
+71 -5
View File
@@ -50,7 +50,7 @@ use windmill_common::{
oauth2::WORKSPACE_SLACK_BOT_TOKEN_PATH,
utils::{paginate, rd_string, require_admin, Pagination},
};
use windmill_git_sync::{handle_fork_branch_creation, handle_deployment_metadata, DeployedObject};
use windmill_git_sync::{handle_deployment_metadata, handle_fork_branch_creation, DeployedObject};
use windmill_worker::scoped_dependency_map::{DependencyMap, ScopedDependencyMap};
#[cfg(feature = "enterprise")]
@@ -73,6 +73,7 @@ lazy_static::lazy_static! {
pub fn workspaced_service() -> Router {
let router = Router::new()
.route("/get_as_superadmin", get(get_workspace_as_superadmin))
.route("/list_pending_invites", get(list_pending_invites))
.route("/update", post(edit_workspace))
.route("/archive", post(archive_workspace))
@@ -163,7 +164,10 @@ pub fn workspaced_service() -> Router {
post(acknowledge_all_critical_alerts),
)
.route("/critical_alerts/mute", post(mute_critical_alerts))
.route("/create_workspace_fork_branch", post(create_workspace_fork_branch))
.route(
"/create_workspace_fork_branch",
post(create_workspace_fork_branch),
)
.route("/operator_settings", post(update_operator_settings));
#[cfg(all(feature = "stripe", feature = "enterprise"))]
@@ -706,7 +710,9 @@ async fn get_slack_oauth_config(
.await?;
// Mask the secret if it exists
let masked_secret = settings.slack_oauth_client_secret.map(|_| "***".to_string());
let masked_secret = settings
.slack_oauth_client_secret
.map(|_| "***".to_string());
Ok(Json(GetSlackOAuthConfigResponse {
slack_oauth_client_id: settings.slack_oauth_client_id,
@@ -2227,6 +2233,35 @@ async fn get_used_triggers(
Ok(Json(websocket_used))
}
async fn get_workspace_as_superadmin(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<Workspace> {
require_super_admin(&db, &authed.email).await?;
let workspace = sqlx::query_as!(
Workspace,
"SELECT
workspace.id AS \"id!\",
workspace.name AS \"name!\",
workspace.owner AS \"owner!\",
workspace.deleted AS \"deleted!\",
workspace.premium AS \"premium!\",
workspace_settings.color AS \"color\",
workspace.parent_workspace_id AS \"parent_workspace_id\"
FROM workspace
LEFT JOIN workspace_settings ON workspace.id = workspace_settings.workspace_id
WHERE workspace.id = $1",
w_id
)
.fetch_optional(&db)
.await?;
let workspace = not_found_if_none(workspace, "workspace", w_id)?;
Ok(Json(workspace))
}
async fn list_workspaces_as_super_admin(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -2310,6 +2345,13 @@ lazy_static::lazy_static! {
}
};
pub static ref DISABLE_WORKSPACE_FORK: bool = {
match std::env::var("DISABLE_WORKSPACE_FORK") {
Ok(val) => val == "true",
Err(_) => false,
}
};
}
async fn create_workspace_require_superadmin() -> String {
@@ -2566,9 +2608,14 @@ async fn update_workspace_settings(
WorkspaceGitSyncSettings::default()
};
// We only keep the first git sync repo, since it is considered the main one
// We only keep the first git sync repo that is sync mode (use_individual_branch = false), since it is considered the main one
// Context: see WIN-1559
git_sync_settings.repositories.truncate(1);
git_sync_settings.repositories = git_sync_settings
.repositories
.into_iter()
.filter(|r| !r.use_individual_branch.unwrap_or(false))
.take(1)
.collect();
let serialized_config = serde_json::to_value::<WorkspaceGitSyncSettings>(git_sync_settings)
.map_err(|err| Error::internal_err(err.to_string()))?;
@@ -2638,6 +2685,17 @@ async fn clone_groups(
.execute(&mut **tx)
.await?;
sqlx::query!(
"INSERT INTO usr_to_group (workspace_id, group_, usr)
SELECT $2, group_, usr
FROM usr_to_group
WHERE workspace_id = $1",
source_workspace_id,
target_workspace_id,
)
.execute(&mut **tx)
.await?;
Ok(())
}
@@ -3008,6 +3066,10 @@ async fn create_workspace_fork_branch(
)));
}
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed.email).await?;
}
Ok(Json(
handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?,
))
@@ -3025,6 +3087,10 @@ async fn create_workspace_fork(
)));
}
if *DISABLE_WORKSPACE_FORK {
require_super_admin(&db, &authed.email).await?;
}
let mut tx: Transaction<'_, Postgres> = db.begin().await?;
// Generate unique forked workspace ID with wm-fork prefix
@@ -15,6 +15,8 @@ pub enum MessageType {
}
/// Add a message to a conversation using an existing transaction
/// If the conversation doesn't exist, logs a warning and returns Ok (no error thrown)
/// This allows memory_id to be used for agent memory without requiring a conversation
pub async fn add_message_to_conversation_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
conversation_id: Uuid,
@@ -24,6 +26,23 @@ pub async fn add_message_to_conversation_tx(
step_name: Option<&str>,
success: bool,
) -> Result<()> {
// Check if conversation exists first
let conversation_exists = sqlx::query!(
"SELECT EXISTS(SELECT 1 FROM flow_conversation WHERE id = $1) as \"exists!\"",
conversation_id
)
.fetch_one(&mut **tx)
.await?
.exists;
if !conversation_exists {
tracing::warn!(
"Conversation {} does not exist. Skipping message insertion. This is expected when flows are called from apps (memory_id is used for agent memory only).",
conversation_id
);
return Ok(());
}
// Insert the message
sqlx::query!(
"INSERT INTO flow_conversation_message (conversation_id, message_type, content, job_id, step_name, success)
+7
View File
@@ -869,6 +869,8 @@ pub enum FlowModuleValue {
parallel: bool,
#[serde(skip_serializing_if = "Option::is_none")]
parallelism: Option<InputTransform>,
#[serde(skip_serializing_if = "Option::is_none")]
squash: Option<bool>,
},
/// While loop node
@@ -878,6 +880,8 @@ pub enum FlowModuleValue {
modules_node: Option<FlowNodeId>,
#[serde(default = "default_false")]
skip_failures: bool,
#[serde(skip_serializing_if = "Option::is_none")]
squash: Option<bool>,
},
/// Branch-one node
@@ -989,6 +993,7 @@ struct UntaggedFlowModuleValue {
assets: Option<Vec<AssetWithAltAccessType>>,
tools: Option<Vec<AgentTool>>,
pass_flow_input_directly: Option<bool>,
squash: Option<bool>,
}
impl<'de> Deserialize<'de> for FlowModuleValue {
@@ -1027,6 +1032,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
skip_failures: untagged.skip_failures.unwrap_or(true),
parallel: untagged.parallel.unwrap_or(false),
parallelism: untagged.parallelism,
squash: untagged.squash,
}),
"whileloopflow" => Ok(FlowModuleValue::WhileloopFlow {
modules: untagged
@@ -1034,6 +1040,7 @@ impl<'de> Deserialize<'de> for FlowModuleValue {
.ok_or_else(|| serde::de::Error::missing_field("modules"))?,
modules_node: untagged.modules_node,
skip_failures: untagged.skip_failures.unwrap_or(false),
squash: untagged.squash,
}),
"branchone" => Ok(FlowModuleValue::BranchOne {
branches: untagged
+79 -1
View File
@@ -24,6 +24,9 @@ use serde::{ser::SerializeMap, Serialize};
use serde_json::{json, value::RawValue};
use sqlx::{types::Json, Pool, Postgres, Transaction};
use sqlx::{Encode, PgExecutor};
use tokio::sync::mpsc::Sender;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use tokio::{sync::RwLock, time::sleep};
use ulid::Ulid;
use uuid::Uuid;
@@ -138,7 +141,7 @@ pub struct CanceledBy {
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize)]
pub struct JobCompleted {
pub job: MiniCompletedJob,
pub preprocessed_args: Option<HashMap<String, Box<RawValue>>>,
@@ -152,6 +155,10 @@ pub struct JobCompleted {
pub duration: Option<i64>,
pub has_stream: Option<bool>,
pub from_cache: Option<bool>,
#[serde(skip)]
pub flow_runners: Option<Arc<FlowRunners>>,
#[serde(skip)]
pub done_tx: Option<oneshot::Sender<()>>,
}
pub async fn cancel_single_job<'c>(
@@ -2272,6 +2279,8 @@ pub struct JobAndPerms {
pub parent_runnable_path: Option<String>,
pub token: String,
pub precomputed_agent_info: Option<PrecomputedAgentInfo>,
#[serde(skip)]
pub flow_runners: Option<Arc<FlowRunners>>,
}
impl PulledJob {
pub async fn get_job_and_perms(self, db: &DB) -> JobAndPerms {
@@ -2303,6 +2312,7 @@ impl PulledJob {
parent_runnable_path: self.parent_runnable_path,
token,
precomputed_agent_info: None,
flow_runners: None,
}
}
}
@@ -2493,6 +2503,8 @@ impl PulledJobResult {
duration: None,
has_stream: Some(false),
from_cache: None,
flow_runners: None,
done_tx: None,
}),
),
PulledJobResult { job: Some(job), error_while_preprocessing: Some(e), .. } => Err(
@@ -2512,6 +2524,8 @@ impl PulledJobResult {
duration: None,
has_stream: Some(false),
from_cache: None,
flow_runners: None,
done_tx: None,
}),
),
PulledJobResult { job, .. } => Ok(job),
@@ -5718,10 +5732,74 @@ async fn restarted_flows_resolution(
))
}
// Wrapper struct to send both job and optional flow_runners to dedicated workers
pub struct DedicatedWorkerJob {
pub job: Arc<MiniPulledJob>,
pub flow_runners: Option<Arc<FlowRunners>>,
pub done_tx: Option<oneshot::Sender<()>>,
}
#[derive(Debug)]
pub struct FlowRunners {
pub runners: HashMap<String, Sender<DedicatedWorkerJob>>,
pub handles: Vec<JoinHandle<()>>,
pub job_id: Uuid,
}
impl Drop for FlowRunners {
fn drop(&mut self) {
let total_runners = self.handles.len();
tracing::info!("dropping {} flow runners for job {}", total_runners, self.job_id);
// First, drop all senders to signal workers to stop gracefully
self.runners.clear();
// Spawn a background task to wait with timeout and abort if needed
let handles = std::mem::take(&mut self.handles);
let job_id = self.job_id;
tokio::spawn(async move {
// Extract abort handles before consuming the join handles
let abort_handles: Vec<_> = handles.iter().map(|h| h.abort_handle()).collect();
// Wait up to 5 seconds for natural termination
let timeout_result = tokio::time::timeout(
tokio::time::Duration::from_secs(5),
futures::future::join_all(handles)
).await;
match timeout_result {
Ok(_) => {
tracing::info!("all {} flow runners for job {} terminated gracefully", total_runners, job_id);
}
Err(_) => {
// Timeout reached, abort only the handles that haven't finished
let mut aborted = 0;
for abort_handle in abort_handles {
if !abort_handle.is_finished() {
abort_handle.abort();
aborted += 1;
}
}
let graceful = total_runners - aborted;
tracing::warn!(
"flow runners for job {}: {} terminated gracefully, {} aborted after 5s timeout",
job_id, graceful, aborted
);
}
}
});
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SameWorkerPayload {
pub job_id: Uuid,
pub recoverable: bool,
#[serde(skip)]
pub flow_runners: Option<Arc<FlowRunners>>,
}
pub async fn get_same_worker_job(
+1 -1
View File
@@ -6,7 +6,7 @@
* LICENSE-AGPL for a copy of the license.
*/
mod jobs;
pub mod jobs;
#[cfg(feature = "private")]
pub mod jobs_ee;
pub mod jobs_oss;
+3 -1
View File
@@ -529,6 +529,7 @@ async fn execute_windmill_tool(
&mut occupancy_metrics_spawn,
&mut killpill_rx_spawn,
None,
None,
#[cfg(feature = "benchmark")]
&mut bench_spawn,
)
@@ -656,8 +657,9 @@ async fn handle_tool_execution_success(
..
}) = send_result.as_ref()
{
let result = result.clone();
ctx.job_completed_tx
.send(send_result.as_ref().unwrap().result.clone(), true)
.send(send_result.unwrap().result, true)
.await
.map_err(to_anyhow)?;
result
+24 -16
View File
@@ -9,10 +9,8 @@ use serde_json::value::RawValue;
use uuid::Uuid;
use windmill_parser_ts::remove_pinned_imports;
use windmill_queue::{append_logs, CanceledBy, MiniPulledJob, PrecomputedAgentInfo};
#[cfg(feature = "enterprise")]
use crate::common::build_envs_map;
use windmill_queue::{append_logs, CanceledBy, MiniPulledJob, PrecomputedAgentInfo};
use crate::{
common::{
@@ -38,12 +36,6 @@ use tokio::{fs::File, process::Command};
use tokio::io::AsyncReadExt;
#[cfg(feature = "enterprise")]
use tokio::sync::mpsc::Receiver;
#[cfg(feature = "enterprise")]
use windmill_common::variables;
use windmill_common::{
error::{self, Result},
get_latest_hash_for_path,
@@ -1567,10 +1559,18 @@ pub async fn get_common_bun_proc_envs(base_internal_url: Option<&str>) -> HashMa
return bun_envs;
}
#[cfg(feature = "enterprise")]
use crate::{dedicated_worker::handle_dedicated_process, JobCompletedSender};
#[cfg(feature = "private")]
use crate::{
common::build_envs_map, dedicated_worker_oss::handle_dedicated_process, JobCompletedSender,
};
#[cfg(feature = "private")]
use tokio::sync::mpsc::Receiver;
#[cfg(feature = "private")]
use windmill_common::variables;
#[cfg(feature = "private")]
use windmill_queue::DedicatedWorkerJob;
#[cfg(feature = "enterprise")]
#[cfg(feature = "private")]
pub async fn start_worker(
requirements_o: Option<String>,
codebase: Option<String>,
@@ -1584,8 +1584,9 @@ pub async fn start_worker(
script_path: &str,
token: &str,
job_completed_tx: JobCompletedSender,
jobs_rx: Receiver<std::sync::Arc<MiniPulledJob>>,
jobs_rx: Receiver<DedicatedWorkerJob>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
client: windmill_common::client::AuthedClient,
) -> Result<()> {
let mut logs = "".to_string();
let mut mem_peak: i32 = 0;
@@ -1737,6 +1738,12 @@ BigInt.prototype.toJSON = function () {{
console.log('start');
function getArgs(line) {{
let {{ {spread} }} = JSON.parse(line)
{dates}
return [ {spread} ];
}}
for await (const line of Readline.createInterface({{ input: process.stdin }})) {{
{print_lines}
@@ -1744,9 +1751,8 @@ for await (const line of Readline.createInterface({{ input: process.stdin }})) {
process.exit(0);
}}
try {{
let {{ {spread} }} = JSON.parse(line)
{dates}
let res = await Main.main(...[ {spread} ]);
const args = getArgs(line);
const res = await Main.main(...args);
console.log("wm_res[success]:" + JSON.stringify(res ?? null, (key, value) => typeof value === 'undefined' ? null : value));
}} catch (e) {{
console.log("wm_res[error]:" + JSON.stringify({{ message: e.message, name: e.name, stack: e.stack, line: line }}));
@@ -1812,6 +1818,7 @@ for await (const line of Readline.createInterface({{ input: process.stdin }})) {
db,
&script_path,
"nodejs",
client,
)
.await
} else {
@@ -1838,6 +1845,7 @@ for await (const line of Readline.createInterface({{ input: process.stdin }})) {
db,
script_path,
"bun",
client,
)
.await
}
@@ -1,759 +0,0 @@
// use tokio::sync::mpsc;
// pub fn create_dedicated_worker() {
// let (job_completed_tx, mut new_job) = mpsc::channel::<JobCompleted>(100);
// }
use async_recursion::async_recursion;
use std::{collections::VecDeque, process::Stdio, sync::Arc};
use tokio::sync::mpsc::Sender;
use tokio::{
io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
process::Command,
task::JoinHandle,
};
use windmill_common::error::Error;
use windmill_common::flows::FlowValue;
use windmill_common::worker::WORKER_CONFIG;
use windmill_common::KillpillSender;
use windmill_common::{
cache, error,
flows::{FlowModule, FlowModuleValue},
scripts::{ScriptHash, ScriptLang},
variables,
worker::to_raw_value,
DB,
};
use windmill_queue::append_logs;
use windmill_queue::MiniPulledJob;
use anyhow::Context;
use crate::{common::start_child_process, JobCompletedSender, MAX_BUFFERED_DEDICATED_JOBS};
use futures::{future, Future};
use std::{collections::HashMap, task::Poll};
use tokio::sync::mpsc::Receiver;
fn conditional_polling<T>(
fut: impl Future<Output = T>,
predicate: bool,
) -> impl Future<Output = T> {
let mut fut = Box::pin(fut);
future::poll_fn(move |cx| {
if predicate {
fut.as_mut().poll(cx)
} else {
Poll::Pending
}
})
}
async fn write_stdin(stdin: &mut tokio::process::ChildStdin, s: &str) -> error::Result<()> {
let _ = &stdin.write_all(format!("{s}\n").as_bytes()).await?;
stdin.flush().await.context("stdin flush")?;
Ok(())
}
#[cfg(feature = "enterprise")]
pub async fn handle_dedicated_process(
command_path: &String,
job_dir: &str,
context_envs: HashMap<String, String>,
envs: HashMap<String, String>,
reserved_variables: Vec<variables::ContextualVariable>,
common_bun_proc_envs: HashMap<String, String>,
args: Vec<&str>,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
job_completed_tx: JobCompletedSender,
token: &str,
mut jobs_rx: Receiver<std::sync::Arc<MiniPulledJob>>,
worker_name: &str,
db: &DB,
script_path: &str,
mode: &str,
) -> std::result::Result<(), error::Error> {
//do not cache local dependencies
use windmill_queue::{JobCompleted, MiniCompletedJob};
use crate::{handle_child::process_status, PROXY_ENVS};
let cmd_name = format!("dedicated {command_path}");
let mut child = {
let mut cmd = Command::new(command_path);
cmd.current_dir(job_dir)
.env_clear()
.envs(context_envs)
.envs(envs)
.envs(PROXY_ENVS.clone())
.envs(
reserved_variables
.iter()
.map(|x| (x.name.clone(), x.value.clone()))
.collect::<Vec<_>>(),
)
.envs(common_bun_proc_envs)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
start_child_process(cmd, command_path, false).await?
};
let stdout = child
.stdout()
.take()
.expect("child did not have a handle to stdout");
let stderr = child
.stderr()
.take()
.expect("child did not have a handle to stderr");
let mut reader = BufReader::new(stdout).lines();
let mut err_reader = BufReader::new(stderr).lines();
let mut stdin = child
.stdin()
.take()
.expect("child did not have a handle to stdin");
// Ensure the child process is spawned in the runtime so it can
// make progress on its own while we await for any output.
let child = tokio::spawn(async move {
let status = Box::into_pin(child.wait())
.await
.expect("child process encountered an error");
if let Err(e) = process_status(&cmd_name, status, vec![]) {
tracing::error!("child exit status was not success: {e:#}");
} else {
tracing::info!("child exit status was success");
}
});
let mut jobs: VecDeque<MiniCompletedJob> = VecDeque::with_capacity(MAX_BUFFERED_DEDICATED_JOBS);
// let mut i = 0;
// let mut j = 0;
let mut alive = true;
let init_log = format!("dedicated worker {mode}: {worker_name}\n\n");
let mut logs = init_log.clone();
loop {
tokio::select! {
biased;
_ = killpill_rx.recv(), if alive => {
println!("received killpill for dedicated worker");
alive = false;
if let Err(e) = write_stdin(&mut stdin, "end").await {
tracing::info!("Could not write end message to stdin: {e:?}")
}
stdin.flush().await.context("stdin flush")?;
},
line = err_reader.next_line() => {
if let Some(line) = line.expect("line is ok") {
tracing::error!("stderr dedicated worker: {line}");
logs.push_str("[stderr] ");
logs.push_str(&line);
logs.push_str("\n");
} else {
tracing::info!("dedicated worker process exited {script_path}");
let mut last_stdout = "".to_string();
while let Some(line) = reader.next_line().await.ok().flatten() {
last_stdout = line;
last_stdout.push_str("\n");
}
tracing::info!("Last stdout for {script_path}: {last_stdout}");
break;
}
},
line = reader.next_line() => {
// j += 1;
if let Some(line) = line.expect("line is ok") {
if line == "start" {
tracing::info!("dedicated worker process started {script_path}");
continue;
}
tracing::debug!("processed job: |{line}|");
if line.starts_with("wm_res[") {
let job = jobs.pop_front().expect("pop");
tracing::info!("job completed on dedicated worker {script_path}: {}", job.id);
match serde_json::from_str::<Box<serde_json::value::RawValue>>(&line.replace("wm_res[success]:", "").replace("wm_res[error]:", "")) {
Ok(result) => {
let result = Arc::new(result);
append_logs(&job.id, &job.workspace_id, logs.clone(), &db.into()).await;
if line.starts_with("wm_res[success]:") {
job_completed_tx.send_job(JobCompleted { job , result, result_columns: None, mem_peak: 0, canceled_by: None, success: true, cached_res_path: None, token: token.to_string(), duration: None, preprocessed_args: None, has_stream: Some(false), from_cache: None }, true).await.unwrap()
} else {
job_completed_tx.send_job(JobCompleted { job , result, result_columns: None, mem_peak: 0, canceled_by: None, success: false, cached_res_path: None, token: token.to_string(), duration: None, preprocessed_args: None, has_stream: Some(false), from_cache: None }, true).await.unwrap()
}
},
Err(e) => {
tracing::error!("Could not deserialize job result `{line}`: {e:?}");
job_completed_tx.send_job(JobCompleted { job , result: Arc::new(to_raw_value(&serde_json::json!({"error": format!("Could not deserialize job result `{line}`: {e:?}")}))), result_columns: None, mem_peak: 0, canceled_by: None, success: false, cached_res_path: None, token: token.to_string(), duration: None, preprocessed_args: None, has_stream: Some(false), from_cache: None }, true).await.unwrap();
},
};
logs = init_log.clone();
} else {
logs.push_str(&line);
logs.push_str("\n");
}
} else {
tracing::info!("dedicated worker {script_path} process exited");
let mut last_stderr = "".to_string();
while let Some(line) = err_reader.next_line().await.ok().flatten() {
last_stderr = line;
last_stderr.push_str("\n");
}
tracing::info!("Last stderr for {script_path}: {last_stderr}");
break;
}
},
job = conditional_polling(jobs_rx.recv(), alive && jobs.len() < MAX_BUFFERED_DEDICATED_JOBS) => {
// i += 1;
if let Some(job) = job {
let id = job.id;
let args = serde_json::to_string(&job.args).expect("serialize");
jobs.push_back(MiniCompletedJob::from(job));
tracing::info!("received job and adding to queue on dedicated worker for {script_path}: {} (queue_size: {})", id, jobs.len());
// write_stdin(&mut stdin, &serde_json::to_string(&job.args.unwrap_or_else(|| serde_json::json!({"x": job.id}))).expect("serialize")).await?;
write_stdin(&mut stdin, &args).await?;
stdin.flush().await.context("stdin flush")?;
// tracing::info!("wrote job to stdin for {script_path}: {} (queue_size: {})", id, jobs.len());
} else {
tracing::debug!("job channel closed");
alive = false;
if let Err(e) = write_stdin(&mut stdin, "end").await {
tracing::error!("Could not write end message to stdin: {e:?}")
}
}
}
}
}
child
.await
.map_err(|e| anyhow::anyhow!("child process {script_path} encountered an error: {e:#}"))?;
tracing::info!("dedicated worker {script_path} child process exited successfully");
Ok(())
}
type DedicatedWorker = (
String,
Sender<std::sync::Arc<MiniPulledJob>>,
Option<JoinHandle<()>>,
);
// spawn one dedicated worker per compatible steps of the flow, associating the node id to the dedicated worker channel send
#[async_recursion]
#[cfg(feature = "enterprise")]
async fn spawn_dedicated_workers_for_flow(
modules: &Vec<FlowModule>,
w_id: &str,
path: &str,
killpill_tx: KillpillSender,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
db: &DB,
worker_dir: &str,
base_internal_url: &str,
worker_name: &str,
job_completed_tx: &JobCompletedSender,
) -> Vec<DedicatedWorker> {
let mut workers = vec![];
let mut script_path_to_worker: HashMap<String, Sender<std::sync::Arc<MiniPulledJob>>> =
HashMap::new();
for module in modules.iter() {
let value = module.get_value();
if let Ok(value) = value {
match &value {
FlowModuleValue::Script { path, hash, .. } => {
let key = format!(
"{}:{}",
path,
hash.clone()
.map(|x| x.to_string())
.unwrap_or_else(|| "".to_string())
);
if let Some(sender) = script_path_to_worker.get(&key) {
workers.push((module.id.clone(), sender.clone(), None));
} else {
if let Some(dedi_w) = spawn_dedicated_worker(
SpawnWorker::Script { path: path.to_string(), hash: hash.clone() },
w_id,
killpill_tx.clone(),
killpill_rx,
db,
worker_dir,
base_internal_url,
worker_name,
job_completed_tx,
Some(module.id.clone()),
)
.await
{
script_path_to_worker.insert(key, dedi_w.1.clone());
workers.push(dedi_w);
}
}
}
FlowModuleValue::ForloopFlow { modules, .. } => {
let w = spawn_dedicated_workers_for_flow(
&modules,
w_id,
path,
killpill_tx.clone(),
killpill_rx,
db,
worker_dir,
base_internal_url,
worker_name,
job_completed_tx,
)
.await;
workers.extend(w);
}
FlowModuleValue::WhileloopFlow { modules, .. } => {
let w = spawn_dedicated_workers_for_flow(
&modules,
w_id,
path,
killpill_tx.clone(),
killpill_rx,
db,
worker_dir,
base_internal_url,
worker_name,
job_completed_tx,
)
.await;
workers.extend(w);
}
FlowModuleValue::BranchOne { branches, default, .. } => {
for modules in branches
.iter()
.map(|x| &x.modules)
.chain(std::iter::once(default))
{
let w = spawn_dedicated_workers_for_flow(
&modules,
w_id,
path,
killpill_tx.clone(),
killpill_rx,
db,
worker_dir,
base_internal_url,
worker_name,
job_completed_tx,
)
.await;
workers.extend(w);
}
}
FlowModuleValue::BranchAll { branches, .. } => {
for branch in branches {
let w = spawn_dedicated_workers_for_flow(
&branch.modules,
w_id,
path,
killpill_tx.clone(),
killpill_rx,
db,
worker_dir,
base_internal_url,
worker_name,
job_completed_tx,
)
.await;
workers.extend(w);
}
}
FlowModuleValue::RawScript { content, lock, path: spath, language, .. } => {
if let Some(dedi_w) = spawn_dedicated_worker(
SpawnWorker::RawScript {
path: spath.clone().unwrap_or(path.to_string()),
content: content.to_string(),
lock: lock.clone(),
lang: language.clone(),
},
w_id,
killpill_tx.clone(),
killpill_rx,
db,
worker_dir,
base_internal_url,
worker_name,
job_completed_tx,
Some(module.id.clone()),
)
.await
{
workers.push(dedi_w);
}
}
FlowModuleValue::FlowScript { id, language, .. } => {
let spawn = cache::flow::fetch_script(
&windmill_common::worker::Connection::Sql(db.clone()),
*id,
)
.await
.map(|data| SpawnWorker::RawScript {
path: "".to_string(),
content: data.code.clone(),
lock: data.lock.clone(),
lang: *language,
});
match spawn {
Ok(spawn) => {
if let Some(dedi_w) = spawn_dedicated_worker(
spawn,
w_id,
killpill_tx.clone(),
killpill_rx,
db,
worker_dir,
base_internal_url,
worker_name,
job_completed_tx,
Some(module.id.clone()),
)
.await
{
workers.push(dedi_w);
}
}
Err(err) => tracing::error!(
"failed to get script for module: {:?}, err: {:?}",
module,
err
),
}
}
FlowModuleValue::Flow { .. } => (),
FlowModuleValue::Identity => (),
FlowModuleValue::AIAgent { .. } => (),
}
} else {
tracing::error!("failed to get value for module: {:?}", module);
}
}
workers
}
pub async fn create_dedicated_worker_map(
killpill_tx: &KillpillSender,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
db: &DB,
worker_dir: &str,
base_internal_url: &str,
worker_name: &str,
job_completed_tx: &JobCompletedSender,
) -> (
HashMap<String, Sender<std::sync::Arc<MiniPulledJob>>>,
bool,
Vec<JoinHandle<()>>,
) {
let mut dedicated_handles = vec![];
if let Some(_wp) = WORKER_CONFIG.read().await.dedicated_worker.clone() {
let mut hm = HashMap::new();
let is_flow_worker;
if let Some(flow_path) = _wp.path.strip_prefix("flow/") {
is_flow_worker = true;
let value = sqlx::query_scalar!(
"SELECT flow_version.value AS \"value!: sqlx::types::Json<Box<sqlx::types::JsonRawValue>>\"
FROM flow
LEFT JOIN flow_version
ON flow_version.id = flow.versions[array_upper(flow.versions, 1)]
WHERE flow.path = $1 AND flow.workspace_id = $2",
flow_path,
_wp.workspace_id
)
.fetch_optional(db)
.await;
if let Ok(v) = value {
if let Some(v) = v {
let value = serde_json::from_str::<FlowValue>(v.get()).map_err(|err| {
Error::internal_err(format!(
"could not convert json to flow for {flow_path}: {err:?}"
))
});
if let Ok(flow) = value {
let workers = spawn_dedicated_workers_for_flow(
&flow.modules,
&_wp.workspace_id,
&_wp.path,
killpill_tx.clone(),
&killpill_rx,
db,
&worker_dir,
base_internal_url,
&worker_name,
&job_completed_tx,
)
.await;
workers.into_iter().for_each(|(path, sender, handle)| {
tracing::info!("spawned dedicated worker for flow: {}", path.as_str());
if let Some(h) = handle {
dedicated_handles.push(h);
}
hm.insert(path, sender);
});
}
} else {
tracing::error!(
"flow present but value not found for dedicated worker. {}",
flow_path
);
}
} else {
tracing::error!("flow not found for dedicated worker: {}. Waiting for dependency job and expected to restart.", flow_path);
}
} else {
is_flow_worker = false;
if let Some((path, sender, handle)) = spawn_dedicated_worker(
SpawnWorker::Script { path: _wp.path.clone(), hash: None },
&_wp.workspace_id,
killpill_tx.clone(),
&killpill_rx,
db,
&worker_dir,
base_internal_url,
&worker_name,
&job_completed_tx,
None,
)
.await
{
if let Some(h) = handle {
dedicated_handles.push(h);
}
hm.insert(path, sender);
} else {
tracing::error!(
"failed to spawn dedicated worker for {}, script not found",
_wp.path
);
}
}
(hm, is_flow_worker, dedicated_handles)
} else {
(HashMap::new(), false, dedicated_handles)
}
}
#[derive(Debug, Clone)]
pub enum SpawnWorker {
Script { path: String, hash: Option<ScriptHash> },
RawScript { path: String, content: String, lock: Option<String>, lang: ScriptLang },
}
// spawn one dedicated worker and return the key, the channel sender and the join handle
// note that for it will return none for language that do not support dedicated workers
// note that go using cache binary does not need dedicated workers so all languages are supported
#[cfg(feature = "enterprise")]
async fn spawn_dedicated_worker(
sw: SpawnWorker,
w_id: &str,
killpill_tx: KillpillSender,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
db: &DB,
worker_dir: &str,
base_internal_url: &str,
worker_name: &str,
job_completed_tx: &JobCompletedSender,
node_id: Option<String>,
) -> Option<DedicatedWorker> {
use windmill_common::{
error::Error,
scripts::{ScriptHash, ScriptLang},
utils::rd_string,
};
use windmill_queue::MiniPulledJob;
use crate::{build_envs, get_script_content_by_hash, ContentReqLangEnvs};
#[cfg(not(feature = "enterprise"))]
{
tracing::error!("Dedicated worker is an enterprise feature");
killpill_tx.send(()).expect("send");
return None;
}
#[cfg(feature = "enterprise")]
{
let (dedicated_worker_tx, dedicated_worker_rx) = tokio::sync::mpsc::channel::<
std::sync::Arc<MiniPulledJob>,
>(MAX_BUFFERED_DEDICATED_JOBS);
let killpill_rx = killpill_rx.resubscribe();
let db2 = db.clone();
let base_internal_url = base_internal_url.to_string();
let worker_name = worker_name.to_string();
let job_completed_tx = job_completed_tx.clone();
let job_dir = format!(
"{}/dedicated{}",
worker_dir,
node_id
.as_ref()
.map(|x| format!("-{x}"))
.unwrap_or_else(|| "".to_string())
);
tokio::fs::create_dir_all(&job_dir)
.await
.expect("create dir");
let path = match &sw {
SpawnWorker::RawScript { path, .. } => path.to_string(),
SpawnWorker::Script { path, .. } => path.to_string(),
};
let path2 = path.clone();
let w_id = w_id.to_string();
let (content, lock, language, envs, codebase) = match sw.clone() {
SpawnWorker::Script { path, hash } => {
let q = if let Some(hash) = hash {
get_script_content_by_hash(&hash, &w_id, &db2.into())
.await
.map(|r: ContentReqLangEnvs| {
Some((r.content, r.lockfile, r.language, r.envs, r.codebase))
})
} else {
sqlx::query_as::<_, (String, Option<String>, Option<ScriptLang>, Option<Vec<String>>, bool, Option<ScriptHash>)>(
"SELECT content, lock, language, envs, codebase IS NOT NULL, hash FROM script WHERE path = $1 AND workspace_id = $2 AND
archived = false AND lock IS not NULL AND lock_error_logs IS NULL ORDER BY created_at DESC LIMIT 1",
)
.bind(&path)
.bind(&w_id)
.fetch_optional(&db2)
.await
.map_err(|e| Error::internal_err(format!("expected content and lock: {e:#}")))
.map(|x| x.map(|y| (y.0, y.1, y.2, y.3, if y.4 { y.5.map(|z| z.to_string()) } else { None })))
};
if let Ok(q) = q {
if let Some(wp) = q {
wp
} else {
tracing::error!(
"Failed to fetch script `{}` in workspace {} for dedicated worker.",
path,
w_id
);
return None;
}
} else {
tracing::error!("Failed to fetch script for dedicated worker");
killpill_tx.send();
return None;
}
}
SpawnWorker::RawScript { content, lock, lang, .. } => {
(content, lock, Some(lang), None, None)
}
};
match language {
Some(ScriptLang::Python3) | Some(ScriptLang::Bun) | Some(ScriptLang::Deno) => {}
_ => return None,
}
let db = db.clone();
let handle = tokio::spawn(async move {
let token = {
let token = rd_string(32);
if let Err(e) = sqlx::query_scalar!(
"INSERT INTO token
(token, label, super_admin, email)
VALUES ($1, $2, $3, $4)",
token,
"dedicated_worker",
true,
"dedicated_worker@windmill.dev"
)
.execute(&db)
.await
{
tracing::error!("failed to create token for dedicated worker: {:?}", e);
killpill_tx.clone().send();
};
token
};
let worker_envs = build_envs(envs.as_ref()).expect("failed to build envs");
if let Err(e) = match language {
Some(ScriptLang::Python3) => {
#[cfg(not(feature = "python"))]
{
tracing::error!("Python requires the python feature to be enabled");
killpill_tx.send();
return;
}
#[cfg(feature = "python")]
crate::python_executor::start_worker(
lock.as_ref(),
&db,
&content,
&base_internal_url,
&job_dir,
&worker_name,
worker_envs,
&w_id,
&path,
&token,
job_completed_tx,
dedicated_worker_rx,
killpill_rx,
)
.await
}
Some(ScriptLang::Bun) => {
crate::bun_executor::start_worker(
lock,
codebase,
&db,
&content,
&base_internal_url,
&job_dir,
&worker_name,
worker_envs,
&w_id,
&path,
&token,
job_completed_tx,
dedicated_worker_rx,
killpill_rx,
)
.await
}
Some(ScriptLang::Deno) => {
crate::deno_executor::start_worker(
&content,
&base_internal_url,
&job_dir,
&worker_name,
worker_envs,
&w_id,
&path,
&token,
job_completed_tx,
dedicated_worker_rx,
killpill_rx,
&db,
)
.await
}
_ => unreachable!("Non supported language for dedicated worker"),
} {
tracing::error!("error in dedicated worker for {sw:#?}: {:?}", e);
};
killpill_tx.clone().send();
});
return Some((node_id.unwrap_or(path2), dedicated_worker_tx, Some(handle)));
// (Some(dedi_path), Some(dedicated_worker_tx), Some(handle))
}
}
@@ -0,0 +1,26 @@
#[cfg(feature = "private")]
// #[allow(unused)]
pub use crate::dedicated_worker_ee::*;
#[cfg(not(feature = "private"))]
pub async fn spawn_flow_module_runners(
_job: &windmill_queue::MiniPulledJob,
_module: &windmill_common::flows::FlowModule,
_failure_module: Option<&Box<windmill_common::flows::FlowModule>>,
_killpill_rx: &tokio::sync::broadcast::Receiver<()>,
_db: &windmill_common::db::DB,
_worker_dir: &str,
_base_internal_url: &str,
_worker_name: &str,
_job_completed_tx: &crate::JobCompletedSender,
) -> windmill_common::error::Result<(
std::collections::HashMap<
String,
tokio::sync::mpsc::Sender<windmill_queue::DedicatedWorkerJob>,
>,
Vec<tokio::task::JoinHandle<()>>,
)> {
Err(windmill_common::error::Error::internal_err(
"Squashing flow loops is not available in OSS".to_string(),
))
}
+9 -6
View File
@@ -514,13 +514,14 @@ async fn build_import_map(
Ok(()) as error::Result<()>
}
#[cfg(feature = "enterprise")]
use crate::{dedicated_worker::handle_dedicated_process, JobCompletedSender};
#[cfg(feature = "enterprise")]
#[cfg(feature = "private")]
use crate::{dedicated_worker_oss::handle_dedicated_process, JobCompletedSender};
#[cfg(feature = "private")]
use tokio::sync::mpsc::Receiver;
#[cfg(feature = "private")]
use windmill_queue::DedicatedWorkerJob;
#[cfg(feature = "enterprise")]
#[cfg(feature = "private")]
pub async fn start_worker(
inner_content: &str,
base_internal_url: &str,
@@ -531,9 +532,10 @@ pub async fn start_worker(
script_path: &str,
token: &str,
job_completed_tx: JobCompletedSender,
jobs_rx: Receiver<std::sync::Arc<MiniPulledJob>>,
jobs_rx: Receiver<DedicatedWorkerJob>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
db: &sqlx::Pool<sqlx::Postgres>,
client: windmill_common::client::AuthedClient,
) -> Result<()> {
use windmill_common::variables;
@@ -654,6 +656,7 @@ for await (const chunk of Deno.stdin.readable) {{
db,
script_path,
"deno",
client,
)
.await
}
+4 -2
View File
@@ -23,8 +23,10 @@ mod bun_executor;
pub mod common;
mod config;
mod csharp_executor;
#[cfg(feature = "enterprise")]
mod dedicated_worker;
#[cfg(feature = "private")]
mod dedicated_worker_ee;
mod dedicated_worker_oss;
mod deno_executor;
#[cfg(feature = "duckdb")]
mod duckdb_executor;
+13 -9
View File
@@ -18,6 +18,8 @@ use tokio::{
sync::Semaphore,
task,
};
use windmill_queue::MiniPulledJob;
use uuid::Uuid;
#[cfg(all(feature = "enterprise", feature = "parquet", unix))]
use windmill_common::ee_oss::{get_license_plan, LicensePlan};
@@ -546,7 +548,6 @@ pub async fn handle_python_job(
precomputed_agent_info: Option<PrecomputedAgentInfo>,
has_stream: &mut bool,
) -> windmill_common::error::Result<Box<RawValue>> {
let script_path = crate::common::use_flow_root_path(job.runnable_path());
let annotations = PythonAnnotations::parse(inner_content);
@@ -2130,16 +2131,16 @@ fn get_result_postprocessor<'a>(skip: bool) -> &'a str {
}
}
#[cfg(feature = "enterprise")]
#[cfg(feature = "private")]
use crate::JobCompletedSender;
#[cfg(feature = "enterprise")]
use crate::{common::build_envs_map, dedicated_worker::handle_dedicated_process};
#[cfg(feature = "enterprise")]
#[cfg(feature = "private")]
use crate::{common::build_envs_map, dedicated_worker_oss::handle_dedicated_process};
#[cfg(feature = "private")]
use windmill_common::variables;
#[cfg(feature = "private")]
use windmill_queue::DedicatedWorkerJob;
use windmill_queue::MiniPulledJob;
#[cfg(feature = "enterprise")]
#[cfg(feature = "private")]
pub async fn start_worker(
requirements_o: Option<&String>,
db: &sqlx::Pool<sqlx::Postgres>,
@@ -2152,10 +2153,12 @@ pub async fn start_worker(
script_path: &str,
token: &str,
job_completed_tx: JobCompletedSender,
jobs_rx: tokio::sync::mpsc::Receiver<std::sync::Arc<MiniPulledJob>>,
jobs_rx: tokio::sync::mpsc::Receiver<DedicatedWorkerJob>,
killpill_rx: tokio::sync::broadcast::Receiver<()>,
client: windmill_common::client::AuthedClient,
) -> error::Result<()> {
use crate::{PyV, PyVAlias};
tracing::info!("script path: {}", script_path);
let mut mem_peak: i32 = 0;
let mut canceled_by: Option<CanceledBy> = None;
@@ -2346,6 +2349,7 @@ for line in sys.stdin:
db,
script_path,
"python",
client,
)
.await
}
@@ -31,8 +31,8 @@ use windmill_common::{
use windmill_common::bench::{BenchmarkInfo, BenchmarkIter};
use windmill_queue::{
append_logs, get_mini_completed_job, CanceledBy, JobCompleted, MiniCompletedJob, MiniPulledJob,
ValidableJson, WrappedError, INIT_SCRIPT_TAG,
append_logs, get_mini_completed_job, CanceledBy, FlowRunners, JobCompleted, MiniCompletedJob,
MiniPulledJob, ValidableJson, WrappedError, INIT_SCRIPT_TAG,
};
use serde_json::{json, value::RawValue, Value};
@@ -66,6 +66,7 @@ async fn process_jc(
same_worker_tx: Option<&SameWorkerSender>,
job_completed_sender: &JobCompletedSender,
stats_map: &JobStatsMap,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) {
let success: bool = jc.success;
@@ -155,6 +156,7 @@ async fn process_jc(
same_worker_tx,
&worker_name,
job_completed_sender.clone(),
killpill_rx,
#[cfg(feature = "benchmark")]
bench,
)
@@ -285,6 +287,7 @@ pub fn start_background_processor(
Some(&same_worker_tx),
&job_completed_sender,
&stats_map,
&killpill_rx,
#[cfg(feature = "benchmark")]
&mut bench,
)
@@ -350,6 +353,8 @@ pub fn start_background_processor(
stop_early_override,
&worker_name,
job_completed_sender.clone(),
None,
&killpill_rx,
#[cfg(feature = "benchmark")]
&mut bench,
)
@@ -413,6 +418,7 @@ pub async fn process_result(
conn: &Connection,
duration: Option<i64>,
has_stream: bool,
flow_runners: Option<Arc<FlowRunners>>,
) -> error::Result<bool> {
match result {
Ok(result) => {
@@ -431,6 +437,8 @@ pub async fn process_result(
duration,
has_stream: Some(has_stream),
from_cache: None,
flow_runners,
done_tx: None,
},
)
.with_context(windmill_common::otel_oss::otel_ctx())
@@ -495,6 +503,8 @@ pub async fn process_result(
duration,
has_stream: Some(has_stream),
from_cache: None,
flow_runners,
done_tx: None,
},
)
.with_context(windmill_common::otel_oss::otel_ctx())
@@ -512,6 +522,7 @@ pub async fn handle_receive_completed_job(
same_worker_tx: Option<&SameWorkerSender>,
worker_name: &str,
job_completed_tx: JobCompletedSender,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) -> Option<Arc<MiniPulledJob>> {
let token = jc.token.clone();
@@ -529,6 +540,7 @@ pub async fn handle_receive_completed_job(
same_worker_tx.clone(),
worker_name,
job_completed_tx.clone(),
killpill_rx,
#[cfg(feature = "benchmark")]
bench,
)
@@ -548,6 +560,7 @@ pub async fn handle_receive_completed_job(
&worker_dir,
worker_name,
job_completed_tx,
killpill_rx,
#[cfg(feature = "benchmark")]
bench,
)
@@ -571,6 +584,8 @@ pub async fn process_completed_job(
preprocessed_args,
has_stream,
from_cache,
flow_runners,
done_tx,
..
}: JobCompleted,
client: &AuthedClient,
@@ -579,6 +594,7 @@ pub async fn process_completed_job(
same_worker_tx: Option<&SameWorkerSender>,
worker_name: &str,
job_completed_tx: JobCompletedSender,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) -> error::Result<Option<Arc<MiniPulledJob>>> {
if success {
@@ -661,12 +677,19 @@ pub async fn process_completed_job(
None,
worker_name,
job_completed_tx,
flow_runners,
killpill_rx,
#[cfg(feature = "benchmark")]
bench,
)
.warn_after_seconds(10)
.await?;
add_time!(bench, "updated flow status END");
if let Some(done_tx) = done_tx {
done_tx
.send(())
.expect("done receiver should still be alive");
}
return Ok(r);
}
}
@@ -707,11 +730,18 @@ pub async fn process_completed_job(
None,
worker_name,
job_completed_tx,
flow_runners,
killpill_rx,
#[cfg(feature = "benchmark")]
bench,
)
.warn_after_seconds(10)
.await?;
if let Some(done_tx) = done_tx {
done_tx
.send(())
.expect("done receiver should still be alive");
}
return Ok(r);
}
}
@@ -761,6 +791,7 @@ pub async fn handle_job_error(
worker_dir: &str,
worker_name: &str,
job_completed_tx: JobCompletedSender,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) {
let err_string = format!("{}: {}", err.name(), err.to_string());
@@ -809,6 +840,8 @@ pub async fn handle_job_error(
None,
worker_name,
job_completed_tx.clone(),
None,
killpill_rx,
#[cfg(feature = "benchmark")]
bench,
)
+175 -64
View File
@@ -11,6 +11,7 @@
use anyhow::anyhow;
use futures::TryFutureExt;
use tokio::sync::Mutex;
use tokio::time::sleep;
use tokio::time::timeout;
use windmill_common::client::AuthedClient;
@@ -59,6 +60,8 @@ use std::{
time::Duration,
};
use windmill_parser::MainArgSignature;
use windmill_queue::DedicatedWorkerJob;
use windmill_queue::FlowRunners;
use windmill_queue::MiniCompletedJob;
use windmill_queue::PulledJobResultToJobErr;
@@ -167,8 +170,8 @@ use crate::duckdb_executor::do_duckdb;
#[cfg(all(feature = "enterprise", feature = "oracledb"))]
use crate::oracledb_executor::do_oracledb;
#[cfg(feature = "enterprise")]
use crate::dedicated_worker::create_dedicated_worker_map;
#[cfg(all(feature = "private", feature = "enterprise"))]
use crate::dedicated_worker_oss::create_dedicated_worker_map;
#[cfg(feature = "enterprise")]
use crate::snowflake_executor::do_snowflake;
@@ -532,6 +535,8 @@ lazy_static::lazy_static! {
.ok()
.and_then(|x| x.parse::<i64>().ok())
.unwrap_or(1000);
pub static ref FLOW_RUNNER_RUNNING: Mutex<bool> = Mutex::new(false);
}
type Envs = Vec<(String, String)>;
@@ -558,14 +563,14 @@ lazy_static::lazy_static! {
#[derive(Debug)]
pub enum NextJob {
Sql(PulledJob),
Sql { job: PulledJob, flow_runners: Option<Arc<FlowRunners>> },
Http(JobAndPerms),
}
impl NextJob {
pub fn job(self) -> MiniPulledJob {
match self {
NextJob::Sql(job) => job.job,
NextJob::Sql { job, .. } => job.job,
NextJob::Http(job) => job.job,
}
}
@@ -575,7 +580,7 @@ impl std::ops::Deref for NextJob {
type Target = MiniPulledJob;
fn deref(&self) -> &Self::Target {
match self {
NextJob::Sql(job) => &job.job,
NextJob::Sql { job, .. } => &job.job,
NextJob::Http(job) => &job.job,
}
}
@@ -733,10 +738,10 @@ impl JobCompletedSender {
impl SameWorkerSender {
pub async fn send(
&self,
payload: SameWorkerPayload,
message: SameWorkerPayload,
) -> Result<(), tokio::sync::mpsc::error::SendError<SameWorkerPayload>> {
self.1.fetch_add(1, Ordering::Relaxed);
self.0.send(payload).await
self.0.send(message).await
}
}
@@ -829,18 +834,36 @@ fn add_outstanding_wait_time(
async fn extract_job_and_perms(job: NextJob, conn: &Connection) -> JobAndPerms {
match (job, conn) {
(NextJob::Sql(job), Connection::Sql(db)) => job.get_job_and_perms(db).await,
(NextJob::Sql(_), Connection::Http(_)) => panic!("sql job on http connection"),
(NextJob::Sql { job, flow_runners, .. }, Connection::Sql(db)) => {
JobAndPerms { flow_runners, ..job.get_job_and_perms(db).await }
}
(NextJob::Sql { .. }, Connection::Http(_)) => panic!("sql job on http connection"),
(NextJob::Http(job), _) => job,
}
}
fn create_span(arc_job: &Arc<MiniPulledJob>, worker_name: &str, hostname: &str) -> Span {
let span = tracing::span!(tracing::Level::INFO, "job",
job_id = %arc_job.id, root_job = field::Empty, workspace_id = %arc_job.workspace_id, worker = %worker_name, hostname = %hostname, tag = %arc_job.tag,
pub fn create_span_with_name(
arc_job: &MiniPulledJob,
worker_name: &str,
hostname: Option<&str>,
span_name: &str,
) -> Span {
// The span macro requires a literal, so we use a fixed name and set otel.name dynamically
let span = tracing::span!(
tracing::Level::INFO,
"job",
job_id = %arc_job.id,
root_job = field::Empty,
workspace_id = %arc_job.workspace_id,
worker = %worker_name,
hostname = field::Empty,
tag = %arc_job.tag,
language = field::Empty,
script_path = field::Empty, flow_step_id = field::Empty, parent_job = field::Empty,
otel.name = field::Empty);
script_path = field::Empty,
flow_step_id = field::Empty,
parent_job = field::Empty,
otel.name = field::Empty
);
let rj = arc_job.flow_innermost_root_job.unwrap_or(arc_job.id);
@@ -848,10 +871,10 @@ fn create_span(arc_job: &Arc<MiniPulledJob>, worker_name: &str, hostname: &str)
span.record("language", lg.as_str());
}
if let Some(step_id) = arc_job.flow_step_id.as_ref() {
span.record("otel.name", format!("job {}", step_id).as_str());
span.record("otel.name", format!("{} {}", span_name, step_id).as_str());
span.record("flow_step_id", step_id.as_str());
} else {
span.record("otel.name", "job");
span.record("otel.name", span_name);
}
if let Some(parent_job) = arc_job.parent_job.as_ref() {
span.record("parent_job", parent_job.to_string().as_str());
@@ -862,6 +885,9 @@ fn create_span(arc_job: &Arc<MiniPulledJob>, worker_name: &str, hostname: &str)
if let Some(root_job) = arc_job.flow_innermost_root_job.as_ref() {
span.record("root_job", root_job.to_string().as_str());
}
if let Some(hostname) = hostname {
span.record("hostname", hostname);
}
windmill_common::otel_oss::set_span_parent(&span, &rj);
span
@@ -876,6 +902,7 @@ pub async fn handle_all_job_kind_error(
worker_dir: &str,
worker_name: &str,
job_completed_tx: JobCompletedSender,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) {
match conn {
@@ -892,6 +919,7 @@ pub async fn handle_all_job_kind_error(
&worker_dir,
&worker_name,
job_completed_tx.clone(),
&killpill_rx,
#[cfg(feature = "benchmark")]
bench,
)
@@ -915,6 +943,8 @@ pub async fn handle_all_job_kind_error(
duration: None,
has_stream: Some(false),
from_cache: None,
flow_runners: None,
done_tx: None,
},
false,
)
@@ -961,27 +991,21 @@ pub fn start_interactive_worker_shell(
.await;
match job {
Ok(j) => match j.to_pulled_job() {
Ok(j) => Ok(j.clone().map(NextJob::Sql)),
ref e @ (Err(PulledJobResultToJobErr::MissingConcurrencyKey(
ref jc,
))
| Err(PulledJobResultToJobErr::ErrorWhilePreprocessing(
ref jc,
))) => {
if let Err(err) =
job_completed_tx.send_job(jc.clone(), true).await
{
let e_fmt = match e {
Ok(_) => "unknown error".to_owned(),
Err(e) => e.to_string(),
};
tracing::error!("An error occurred while sending job completed ({e_fmt}): {:#?}", err)
Ok(j) => {
match j.to_pulled_job() {
Ok(j) => Ok(j
.clone()
.map(|job| NextJob::Sql { flow_runners: None, job })),
Err(PulledJobResultToJobErr::MissingConcurrencyKey(jc))
| Err(PulledJobResultToJobErr::ErrorWhilePreprocessing(jc)) => {
if let Err(err) = job_completed_tx.send_job(jc, true).await
{
tracing::error!("An error occurred while sending job completed: {:#?}", err)
}
Ok(None)
}
Ok(None)
}
},
}
Err(err) => Err(err),
}
}
@@ -1010,6 +1034,7 @@ pub fn start_interactive_worker_shell(
parent_runnable_path,
token,
precomputed_agent_info: precomputed_bundle,
flow_runners,
} = extract_job_and_perms(job, &conn).await;
let authed_client = AuthedClient::new(
@@ -1039,6 +1064,7 @@ pub fn start_interactive_worker_shell(
&mut occupancy_metrics,
&mut killpill_rx,
precomputed_bundle,
flow_runners,
#[cfg(feature = "benchmark")]
&mut bench,
)
@@ -1476,9 +1502,9 @@ pub async fn run_worker(
// Option<Sender<Arc<QueuedJob>>>,
// Option<JoinHandle<()>>,
#[cfg(feature = "enterprise")]
#[cfg(all(feature = "private", feature = "enterprise"))]
let (dedicated_workers, is_flow_worker, dedicated_handles): (
HashMap<String, Sender<Arc<MiniPulledJob>>>,
HashMap<String, Sender<DedicatedWorkerJob>>,
bool,
Vec<JoinHandle<()>>,
) = match conn {
@@ -1497,9 +1523,9 @@ pub async fn run_worker(
Connection::Http(_) => (HashMap::new(), false, vec![]),
};
#[cfg(not(feature = "enterprise"))]
#[cfg(any(not(feature = "private"), not(feature = "enterprise")))]
let (dedicated_workers, is_flow_worker, dedicated_handles): (
HashMap<String, Sender<Arc<MiniPulledJob>>>,
HashMap<String, Sender<DedicatedWorkerJob>>,
bool,
Vec<JoinHandle<()>>,
) = (HashMap::new(), false, vec![]);
@@ -1656,7 +1682,7 @@ pub async fn run_worker(
if let Ok(same_worker_job) = same_worker_rx.try_recv() {
same_worker_queue_size.fetch_sub(1, Ordering::SeqCst);
tracing::debug!(
tracing::info!(
worker = %worker_name, hostname = %hostname,
"received {} from same worker channel",
same_worker_job.job_id
@@ -1676,7 +1702,12 @@ pub async fn run_worker(
.expect("send kill to job completed tx");
break;
} else {
job.map(|x| x.map(NextJob::Sql))
job.map(|x| {
x.map(|job| NextJob::Sql {
flow_runners: same_worker_job.flow_runners,
job,
})
})
}
}
Connection::Http(client) => client
@@ -1827,22 +1858,14 @@ pub async fn run_worker(
}
match job {
Ok(pulled_job_result) => match pulled_job_result.to_pulled_job() {
Ok(j) => Ok(j.map(NextJob::Sql)),
ref e @ (Err(PulledJobResultToJobErr::MissingConcurrencyKey(
ref jc,
))
| Err(PulledJobResultToJobErr::ErrorWhilePreprocessing(
ref jc,
))) => {
if let Err(err) =
job_completed_tx.send_job(jc.clone(), true).await
{
let e_fmt = match e {
Ok(_) => "unknown error".to_owned(),
Err(e) => e.to_string(),
};
tracing::error!("An error occurred while sending job completed ({e_fmt}): {:#?}", err)
Ok(j) => Ok(j.map(|job| NextJob::Sql { flow_runners: None, job })),
Err(PulledJobResultToJobErr::MissingConcurrencyKey(jc))
| Err(PulledJobResultToJobErr::ErrorWhilePreprocessing(jc)) => {
if let Err(err) = job_completed_tx.send_job(jc, true).await {
tracing::error!(
"An error occurred while sending job completed: {:#?}",
err
)
}
Ok(None)
}
@@ -1874,7 +1897,10 @@ pub async fn run_worker(
tracing::debug!(worker = %worker_name, hostname = %hostname, "started handling of job {}", job.id);
if matches!(job.kind, JobKind::Script | JobKind::Preview) {
if matches!(
job.kind,
JobKind::Script | JobKind::Preview | JobKind::FlowScript
) {
if !dedicated_workers.is_empty() {
let key_o = if is_flow_worker {
job.flow_step_id.as_ref().map(|x| x.to_string())
@@ -1883,8 +1909,12 @@ pub async fn run_worker(
};
if let Some(key) = key_o {
if let Some(dedicated_worker_tx) = dedicated_workers.get(&key) {
if let Err(e) = dedicated_worker_tx.send(Arc::new(job.job())).await
{
let dedicated_job = DedicatedWorkerJob {
job: Arc::new(job.job()),
flow_runners: None,
done_tx: None,
};
if let Err(e) = dedicated_worker_tx.send(dedicated_job).await {
tracing::info!("failed to send jobs to dedicated workers. Likely dedicated worker has been shut down. This is normal: {e:?}");
}
@@ -1898,6 +1928,77 @@ pub async fn run_worker(
}
}
}
// Extract flow_runners early to use in both dedicated workers and flow runners
let flow_runners = match &job {
NextJob::Sql { flow_runners, .. } => flow_runners.clone(),
NextJob::Http(_) => None,
};
if let Some(flow_runners) = flow_runners {
let key_o = job.flow_step_id.as_ref().map(|x| x.to_string());
if let Some(key) = key_o {
if let Some(flow_runner_tx) = flow_runners.runners.get(&key) {
tracing::info!(
"sending job {} to flow runner step {}",
job.id,
key
);
let (done_tx, done_rx) = tokio::sync::oneshot::channel::<()>();
let flow_runners = flow_runners.clone();
let job = job.job();
let dedicated_job = DedicatedWorkerJob {
job: Arc::new(job.clone()),
flow_runners: Some(flow_runners),
done_tx: Some(done_tx),
};
if let Err(e) = flow_runner_tx.send(dedicated_job).await {
let token = match &conn {
Connection::Sql(db) => {
windmill_queue::jobs::create_token(db, &job, None).await
}
_ => "".to_string(),
};
handle_all_job_kind_error(
&conn,
&AuthedClient::new(
base_internal_url.to_owned(),
job.workspace_id.clone(),
token,
None,
),
MiniCompletedJob::from(job),
error::Error::InternalErr(format!(
"failed to send jobs to flow runners: {e:?}"
)),
Some(&same_worker_tx),
&worker_dir,
&worker_name,
job_completed_tx.clone(),
&killpill_rx,
#[cfg(feature = "benchmark")]
&mut bench,
)
.await;
} else {
if let Err(err) = done_rx.await {
tracing::error!("Flow runner done channel has been dropped without being received: {err:?}");
}
}
#[cfg(feature = "benchmark")]
{
add_time!(bench, "sent to flow runner");
infos.add_iter(bench, true);
}
continue;
}
}
}
}
if matches!(job.kind, JobKind::Noop) {
@@ -1917,6 +2018,8 @@ pub async fn run_worker(
duration: None,
has_stream: Some(false),
from_cache: None,
flow_runners: None,
done_tx: None,
},
true,
)
@@ -2048,6 +2151,7 @@ pub async fn run_worker(
parent_runnable_path,
token,
precomputed_agent_info: precomputed_bundle,
flow_runners,
} = extract_job_and_perms(job, &conn).await;
let authed_client = AuthedClient::new(
@@ -2059,7 +2163,7 @@ pub async fn run_worker(
let arc_job = Arc::new(job);
let span = create_span(&arc_job, &worker_name, hostname);
let span = create_span_with_name(&arc_job, &worker_name, Some(hostname), "job");
let job_result = handle_queued_job(
arc_job.clone(),
@@ -2079,6 +2183,7 @@ pub async fn run_worker(
&mut occupancy_metrics,
&mut killpill_rx2,
precomputed_bundle,
flow_runners,
#[cfg(feature = "benchmark")]
&mut bench,
)
@@ -2138,6 +2243,7 @@ pub async fn run_worker(
&worker_dir,
&worker_name,
job_completed_tx.clone(),
&killpill_rx,
#[cfg(feature = "benchmark")]
&mut bench,
)
@@ -2302,7 +2408,7 @@ async fn queue_init_bash_maybe<'c>(
};
if let Some((uuid, content)) = uuid_content {
same_worker_tx
.send(SameWorkerPayload { job_id: uuid, recoverable: false })
.send(SameWorkerPayload { job_id: uuid, recoverable: false, flow_runners: None })
.await
.map_err(to_anyhow)?;
tracing::info!("Creating initial job {uuid} from initial script script: {content}");
@@ -2420,7 +2526,7 @@ async fn queue_periodic_script_bash_maybe<'c>(
};
same_worker_tx
.send(SameWorkerPayload { job_id: uuid, recoverable: false })
.send(SameWorkerPayload { job_id: uuid, recoverable: false, flow_runners: None })
.await
.map_err(to_anyhow)?;
tracing::info!("Creating periodic script job {uuid} from periodic script: {content}");
@@ -2432,7 +2538,6 @@ pub struct SendResult {
pub time: Instant,
}
#[derive(Clone)]
pub enum SendResultPayload {
JobCompleted(JobCompleted),
UpdateFlow(UpdateFlow),
@@ -2519,6 +2624,7 @@ pub async fn handle_queued_job(
occupancy_metrics: &mut OccupancyMetrics,
killpill_rx: &mut tokio::sync::broadcast::Receiver<()>,
precomputed_agent_info: Option<PrecomputedAgentInfo>,
flow_runners: Option<Arc<FlowRunners>>,
#[cfg(feature = "benchmark")] _bench: &mut BenchmarkIter,
) -> windmill_common::error::Result<bool> {
// Extract the active span from the context
@@ -2646,6 +2752,8 @@ pub async fn handle_queued_job(
duration: None,
has_stream: Some(false),
from_cache: Some(true),
flow_runners: None,
done_tx: None,
},
true,
)
@@ -2681,6 +2789,8 @@ pub async fn handle_queued_job(
worker_dir,
job_completed_tx.clone(),
worker_name,
flow_runners,
&killpill_rx,
))
.warn_after_seconds(10)
.await?;
@@ -2911,6 +3021,7 @@ pub async fn handle_queued_job(
conn,
Some(started.elapsed().as_millis() as i64),
has_stream,
flow_runners,
)
.await
}
+90 -13
View File
@@ -64,7 +64,7 @@ use windmill_common::{
use windmill_queue::schedule::get_schedule_opt;
use windmill_queue::{
add_completed_job, add_completed_job_error, append_logs, get_mini_pulled_job,
handle_maybe_scheduled_job, insert_concurrency_key, interpolate_args, CanceledBy,
handle_maybe_scheduled_job, insert_concurrency_key, interpolate_args, CanceledBy, FlowRunners,
MiniCompletedJob, MiniPulledJob, PushArgs, PushIsolationLevel, SameWorkerPayload, WrappedError,
};
@@ -88,6 +88,8 @@ pub async fn update_flow_status_after_job_completion(
stop_early_override: Option<bool>,
worker_name: &str,
job_completed_tx: JobCompletedSender,
flow_runners: Option<Arc<FlowRunners>>,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) -> error::Result<Option<Arc<MiniPulledJob>>> {
// this is manual tailrecursion because async_recursion blows up the stack
@@ -121,6 +123,8 @@ pub async fn update_flow_status_after_job_completion(
rec.has_triggered_error_handler,
worker_name,
job_completed_tx.clone(),
flow_runners.clone(),
killpill_rx,
#[cfg(feature = "benchmark")]
bench,
))
@@ -147,6 +151,8 @@ pub async fn update_flow_status_after_job_completion(
rec.has_triggered_error_handler,
worker_name,
job_completed_tx.clone(),
flow_runners.clone(),
killpill_rx,
#[cfg(feature = "benchmark")]
bench,
))
@@ -285,6 +291,8 @@ pub async fn update_flow_status_after_job_completion_internal(
has_triggered_error_handler: bool,
worker_name: &str,
job_completed_tx: JobCompletedSender,
flow_runners: Option<Arc<FlowRunners>>,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "benchmark")] bench: &mut BenchmarkIter,
) -> error::Result<UpdateFlowStatusAfterJobCompletion> {
let mut has_triggered_error_handler = has_triggered_error_handler;
@@ -434,7 +442,6 @@ pub async fn update_flow_status_after_job_completion_internal(
args
}));
let from_result_to_args = |args: &Result<HashMap<String, Box<RawValue>>, sqlx::Error>| {
let args = args
.as_ref()
@@ -473,7 +480,7 @@ 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),
@@ -742,7 +749,6 @@ pub async fn update_flow_status_after_job_completion_internal(
&mut stop_early_err_msg,
&mut nresult,
args,
)
.await?;
}
@@ -1565,6 +1571,8 @@ pub async fn update_flow_status_after_job_completion_internal(
worker_dir,
job_completed_tx,
worker_name,
flow_runners,
&killpill_rx,
))
.warn_after_seconds(10)
.await
@@ -2044,6 +2052,8 @@ pub async fn handle_flow(
worker_dir: &str,
job_completed_tx: JobCompletedSender,
worker_name: &str,
flow_runners: Option<Arc<FlowRunners>>,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
) -> anyhow::Result<()> {
let flow = flow_data.value();
let status = flow_job
@@ -2101,6 +2111,9 @@ pub async fn handle_flow(
same_worker_tx,
worker_dir,
worker_name,
flow_runners.clone(),
job_completed_tx.clone(),
&killpill_rx,
))
.warn_after_seconds(10)
.await?;
@@ -2185,6 +2198,7 @@ struct PushNextFlowJobRec {
flow_job: Arc<MiniPulledJob>,
status: FlowStatus,
}
// #[async_recursion]
// #[instrument(level = "trace", skip_all)]
async fn push_next_flow_job(
@@ -2197,6 +2211,9 @@ async fn push_next_flow_job(
same_worker_tx: &SameWorkerSender,
worker_dir: &str,
worker_name: &str,
flow_runners: Option<Arc<FlowRunners>>,
job_completed_tx: JobCompletedSender,
killpill_rx: &tokio::sync::broadcast::Receiver<()>,
) -> error::Result<PushNextFlowJob> {
let job_root = flow_job
.flow_innermost_root_job
@@ -3028,6 +3045,22 @@ async fn push_next_flow_job(
}
};
// only start runners if we're not already in a squash for loop
let start_runners = flow_runners.is_none()
&& matches!(
next_status,
NextStatus::NextLoopIteration { start_runners: true, .. }
);
let do_not_pass_runners = matches!(next_status, NextStatus::NextStep { .. })
&& flow_runners
.as_ref()
.is_some_and(|fr| fr.job_id == flow_job.id);
let continue_with_runners = (start_runners || (flow_runners.is_some() && !do_not_pass_runners))
&& module.suspend.is_none()
&& module.sleep.is_none();
// Also check `flow_job.same_worker` for [`JobKind::Flow`] jobs as it's no
// more reflected to the flow value on push.
let job_same_worker = flow_job.same_worker
@@ -3083,6 +3116,7 @@ async fn push_next_flow_job(
NextStatus::NextLoopIteration {
next: ForloopNextIteration { new_args, .. },
simple_input_transforms,
..
} => {
let mut args = if let Ok(args) = nargs {
args.as_ref().clone()
@@ -3300,7 +3334,7 @@ async fn push_next_flow_job(
Some(module.id.clone()),
new_job_priority_override,
job_perms.as_ref(),
false,
continue_with_runners,
None,
None,
None
@@ -3308,7 +3342,7 @@ async fn push_next_flow_job(
.warn_after_seconds(2)
.await?;
if continue_on_same_worker {
if continue_on_same_worker || continue_with_runners {
let _ = sqlx::query!(
"UPDATE v2_job_queue SET worker = $2 WHERE id = $1",
uuid,
@@ -3583,7 +3617,7 @@ async fn push_next_flow_job(
.warn_after_seconds(3)
.await?;
if continue_on_same_worker {
if continue_on_same_worker || continue_with_runners {
if !is_one_uuid {
return Err(Error::BadRequest(
"Cannot continue on same worker with multiple jobs, parallel cannot be used in conjunction with same_worker".to_string(),
@@ -3593,9 +3627,43 @@ async fn push_next_flow_job(
tx.commit().warn_after_seconds(3).await?;
tracing::info!(id = %flow_job.id, root_id = %job_root, "all next flow jobs pushed: {uuids:?}");
if continue_on_same_worker {
if continue_on_same_worker || continue_with_runners {
let flow_runners = if start_runners {
tracing::info!(id = %flow_job.id, "starting flow runners for module {}", module.id);
let (new_flow_runners, new_flow_runner_handles) =
crate::dedicated_worker_oss::spawn_flow_module_runners(
&flow_job,
module,
flow.failure_module.as_ref(),
&killpill_rx,
db,
worker_dir,
&client.base_internal_url,
worker_name,
&job_completed_tx,
)
.await
.with_context(|| {
format!(
"failed to spawn flow module runners for job: {}",
flow_job.id
)
})?;
let flow_runners = FlowRunners {
runners: new_flow_runners,
handles: new_flow_runner_handles,
job_id: flow_job.id,
};
Some(Arc::new(flow_runners))
} else if !do_not_pass_runners {
flow_runners
} else {
None
};
same_worker_tx
.send(SameWorkerPayload { job_id: first_uuid, recoverable: true })
.send(SameWorkerPayload { job_id: first_uuid, recoverable: true, flow_runners })
.warn_after_seconds(3)
.await
.map_err(to_anyhow)?;
@@ -3702,6 +3770,7 @@ enum NextStatus {
NextLoopIteration {
next: ForloopNextIteration,
simple_input_transforms: Option<HashMap<String, InputTransform>>,
start_runners: bool,
},
AllFlowJobs {
branchall: Option<BranchAllStatus>,
@@ -3943,7 +4012,7 @@ async fn compute_next_flow_transform(
NextStatus::NextStep,
))
}
FlowModuleValue::WhileloopFlow { modules, modules_node, .. } => {
FlowModuleValue::WhileloopFlow { modules, modules_node, squash, .. } => {
// if it's a simple single step flow, we will collapse it as an optimization and need to pass flow_input as an arg
let is_simple = is_simple_modules(&modules, flow.failure_module.as_ref());
let (flow_jobs, flow_jobs_success, flow_jobs_duration) = match status_module {
@@ -3960,6 +4029,8 @@ async fn compute_next_flow_transform(
_ => (vec![], Some(vec![]), Some(FlowJobsDuration::new(0))),
};
let next_loop_idx = flow_jobs.len();
let start_runners = next_loop_idx == 0 && squash.unwrap_or(false);
next_loop_iteration(
flow,
status,
@@ -3982,11 +4053,14 @@ async fn compute_next_flow_transform(
db,
module,
delete_after_use,
start_runners,
)
.await
}
/* forloop modules are expected set `iter: { value: Value, index: usize }` as job arguments */
FlowModuleValue::ForloopFlow { modules, modules_node, iterator, parallel, .. } => {
FlowModuleValue::ForloopFlow {
modules, modules_node, iterator, parallel, squash, ..
} => {
// if it's a simple single step flow, we will collapse it as an optimization and need to pass flow_input as an arg
let is_simple = !matches!(flow_job.kind, JobKind::FlowPreview)
&& !parallel
@@ -4025,6 +4099,7 @@ async fn compute_next_flow_transform(
Ok(NextFlowTransform::EmptyInnerFlows { branch_chosen: None })
}
ForLoopStatus::NextIteration(ns) => {
let start_runners = ns.index == 0 && squash.unwrap_or(false);
next_loop_iteration(
flow,
status,
@@ -4036,6 +4111,7 @@ async fn compute_next_flow_transform(
db,
module,
delete_after_use,
start_runners,
)
.await
}
@@ -4300,6 +4376,7 @@ async fn next_loop_iteration(
db: &sqlx::Pool<sqlx::Postgres>,
module: &FlowModule,
delete_after_use: bool,
start_runners: bool,
) -> Result<NextFlowTransform, Error> {
let inner_path = || format!("{}/loop-{}", flow_job.runnable_path(), ns.index);
if is_simple {
@@ -4317,7 +4394,7 @@ async fn next_loop_iteration(
ContinuePayload::SingleJob(
payload_from_simple_module(value, db, flow_job, module, inner_path()).await?,
),
NextStatus::NextLoopIteration { next: ns, simple_input_transforms },
NextStatus::NextLoopIteration { next: ns, simple_input_transforms, start_runners },
));
}
@@ -4341,7 +4418,7 @@ async fn next_loop_iteration(
timeout: None,
on_behalf_of: None,
}),
NextStatus::NextLoopIteration { next: ns, simple_input_transforms: None },
NextStatus::NextLoopIteration { next: ns, simple_input_transforms: None, start_runners },
))
}
@@ -1193,6 +1193,7 @@ async fn lock_modules<'c>(
skip_failures,
parallel,
parallelism,
squash,
} => {
let nmodules;
(nmodules, tx, nmodified_ids, nerrors) = Box::pin(lock_modules(
@@ -1222,6 +1223,7 @@ async fn lock_modules<'c>(
skip_failures,
parallel,
parallelism,
squash,
}
.into()
}
@@ -1258,7 +1260,7 @@ async fn lock_modules<'c>(
}
e.value = FlowModuleValue::BranchAll { branches: nbranches, parallel }.into()
}
FlowModuleValue::WhileloopFlow { modules, modules_node, skip_failures } => {
FlowModuleValue::WhileloopFlow { modules, modules_node, skip_failures, squash } => {
let nmodules;
(nmodules, tx, nmodified_ids, nerrors) = Box::pin(lock_modules(
modules,
@@ -1284,6 +1286,7 @@ async fn lock_modules<'c>(
modules: nmodules,
modules_node,
skip_failures,
squash,
}
.into()
}
+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.583.1";
export const VERSION = "v1.585.1";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+16 -4
View File
@@ -18,12 +18,16 @@ import { readInlinePathSync } from "../../utils/utils.ts";
export interface AppFile {
value: any;
public?: boolean;
summary: string;
policy: Policy;
}
const alreadySynced: string[] = [];
export function isExecutionModeAnonymous(app: any) {
return app?.["policy"]?.["execution_mode"] == "anonymous";
}
export async function pushApp(
workspace: string,
remotePath: string,
@@ -45,7 +49,13 @@ export async function pushApp(
} catch {
//ignore
}
app.policy = undefined;
if (isExecutionModeAnonymous(app)) {
app.public = true;
}
// console.log(app);
if (app) {
app.policy = undefined;
}
if (!localPath.endsWith(SEP)) {
localPath += SEP;
@@ -78,7 +88,9 @@ export async function pushApp(
}
replaceInlineScripts(localApp.value);
await generatingPolicy(localApp, remotePath);
// console.log(localApp, localApp?.["policy"]);
await generatingPolicy(localApp, remotePath, localApp?.["public"] ?? (localApp.policy ? isExecutionModeAnonymous(localApp) : false));
// console.log(localApp, localApp?.["policy"]);
if (app) {
if (isSuperset(localApp, app)) {
log.info(colors.green(`App ${remotePath} is up to date`));
@@ -107,11 +119,11 @@ export async function pushApp(
}
}
async function generatingPolicy(app: any, path: string) {
async function generatingPolicy(app: any, path: string, publicApp: boolean) {
log.info(colors.gray(`Generating fresh policy for app ${path}...`));
try {
app.policy = await windmillUtils.updatePolicy(app.value, undefined);
app.policy.execution_mode = "publisher";
app.policy.execution_mode = publicApp ? "anonymous" : "publisher";
} catch (e) {
log.error(colors.red(`Error generating policy for app ${path}: ${e}`));
throw e;
+4
View File
@@ -67,6 +67,7 @@ import {
PathAssigner,
} from "../../../windmill-utils-internal/src/path-utils/path-assigner.ts";
import { extractInlineScripts as extractInlineScriptsForFlows } from "../../../windmill-utils-internal/src/inline-scripts/extractor.ts";
import { isExecutionModeAnonymous } from "../app/apps.ts";
// Merge CLI options with effective settings, preserving CLI flags as overrides
function mergeCliWithEffectiveOptions<
@@ -415,6 +416,9 @@ function ZipFSElement(
};
}
if (isExecutionModeAnonymous(app)) {
app.public = true;
}
app.policy = undefined;
yield {
isDirectory: false,
+1 -1
View File
@@ -68,7 +68,7 @@ export {
// }
// });
export const VERSION = "1.583.1";
export const VERSION = "1.585.1";
export const WM_FORK_PREFIX = "wm-fork";
+85 -96
View File
@@ -1,12 +1,12 @@
{
"name": "windmill-components",
"version": "1.583.1",
"version": "1.585.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "windmill-components",
"version": "1.583.1",
"version": "1.585.1",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@@ -142,7 +142,7 @@
"tar": "^7.4.3",
"tslib": "^2.6.1",
"typescript": "^5.5.0",
"vite": "npm:rolldown-vite@latest",
"vite": "npm:rolldown-vite@7.2.8",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-mkcert": "^1.17.5",
"yootils": "^0.3.1"
@@ -1904,9 +1904,9 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.5.0.tgz",
"integrity": "sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz",
"integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1916,9 +1916,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz",
"integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==",
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -2603,9 +2603,9 @@
}
},
"node_modules/@oxc-project/runtime": {
"version": "0.92.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.92.0.tgz",
"integrity": "sha512-Z7x2dZOmznihvdvCvLKMl+nswtOSVxS2H2ocar+U9xx6iMfTp0VGIrX6a4xB1v80IwOPC7dT1LXIJrY70Xu3Jw==",
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.99.0.tgz",
"integrity": "sha512-8iE5/4OK0SLHqWzRxSvI1gjFPmIH6718s8iwkuco95rBZsCZIHq+5wy4lYsASxnH+8FOhbGndiUrcwsVG5i2zw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2613,9 +2613,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.94.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.94.0.tgz",
"integrity": "sha512-+UgQT/4o59cZfH6Cp7G0hwmqEQ0wE+AdIwhikdwnhWI9Dp8CgSY081+Q3O67/wq3VJu8mgUEB93J9EHHn70fOw==",
"version": "0.99.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.99.0.tgz",
"integrity": "sha512-LLDEhXB7g1m5J+woRSgfKsFPS3LhR9xRhTeIoEBm5WrkwMxn6eZ0Ld0c0K5eHB57ChZX6I3uSmmLjZ8pcjlRcw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2679,9 +2679,9 @@
"license": "SEE LICENSE IN LICENSE"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.42.tgz",
"integrity": "sha512-W5ZKF3TP3bOWuBfotAGp+UGjxOkGV7jRmIRbBA7NFjggx7Oi6vOmGDqpHEIX7kDCiry1cnIsWQaxNvWbMdkvzQ==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.52.tgz",
"integrity": "sha512-MBGIgysimZPqTDcLXI+i9VveijkP5C3EAncEogXhqfax6YXj1Tr2LY3DVuEOMIjWfMPMhtQSPup4fSTAmgjqIw==",
"cpu": [
"arm64"
],
@@ -2696,9 +2696,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.42.tgz",
"integrity": "sha512-abw/wtgJA8OCgaTlL+xJxnN/Z01BwV1rfzIp5Hh9x+IIO6xOBfPsQ0nzi0+rWx3TyZ9FZXyC7bbC+5NpQ9EaXQ==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.52.tgz",
"integrity": "sha512-MmKeoLnKu1d9j6r19K8B+prJnIZ7u+zQ+zGQ3YHXGnr41rzE3eqQLovlkvoZnRoxDGPA4ps0pGiwXy6YE3lJyg==",
"cpu": [
"arm64"
],
@@ -2713,9 +2713,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.42.tgz",
"integrity": "sha512-Y/UrZIRVr8CvXVEB88t6PeC46r1K9/QdPEo2ASE/b/KBEyXIx+QbM6kv9QfQVWU2Atly2+SVsQzxQsIvuk3lZQ==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.52.tgz",
"integrity": "sha512-qpHedvQBmIjT8zdnjN3nWPR2qjQyJttbXniCEKKdHeAbZG9HyNPBUzQF7AZZGwmS9coQKL+hWg9FhWzh2dZ2IA==",
"cpu": [
"x64"
],
@@ -2730,9 +2730,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.42.tgz",
"integrity": "sha512-zRM0oOk7BZiy6DoWBvdV4hyEg+j6+WcBZIMHVirMEZRu8hd18kZdJkg+bjVMfCEhwpWeFUfBfZ1qcaZ5UdYzlQ==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.52.tgz",
"integrity": "sha512-dDp7WbPapj/NVW0LSiH/CLwMhmLwwKb3R7mh2kWX+QW85X1DGVnIEyKh9PmNJjB/+suG1dJygdtdNPVXK1hylg==",
"cpu": [
"x64"
],
@@ -2747,9 +2747,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.42.tgz",
"integrity": "sha512-6RjFaC52QNwo7ilU8C5H7swbGlgfTkG9pudXwzr3VYyT18s0C9gLg3mvc7OMPIGqNxnQ0M5lU8j6aQCk2DTRVg==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.52.tgz",
"integrity": "sha512-9e4l6vy5qNSliDPqNfR6CkBOAx6PH7iDV4OJiEJzajajGrVy8gc/IKKJUsoE52G8ud8MX6r3PMl97NfwgOzB7g==",
"cpu": [
"arm"
],
@@ -2764,9 +2764,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.42.tgz",
"integrity": "sha512-LMYHM5Sf6ROq+VUwHMDVX2IAuEsWTv4SnlFEedBnMGpvRuQ14lCmD4m5Q8sjyAQCgyha9oghdGoK8AEg1sXZKg==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.52.tgz",
"integrity": "sha512-V48oDR84feRU2KRuzpALp594Uqlx27+zFsT6+BgTcXOtu7dWy350J1G28ydoCwKB+oxwsRPx2e7aeQnmd3YJbQ==",
"cpu": [
"arm64"
],
@@ -2781,9 +2781,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.42.tgz",
"integrity": "sha512-/bNTYb9aKNhzdbPn3O4MK2aLv55AlrkUKPE4KNfBYjkoZUfDr4jWp7gsSlvTc5A/99V1RCm9axvt616ZzeXGyA==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.52.tgz",
"integrity": "sha512-ENLmSQCWqSA/+YN45V2FqTIemg7QspaiTjlm327eUAMeOLdqmSOVVyrQexJGNTQ5M8sDYCgVAig2Kk01Ggmqaw==",
"cpu": [
"arm64"
],
@@ -2798,9 +2798,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.42.tgz",
"integrity": "sha512-n/SLa4h342oyeGykZdch7Y3GNCNliRPL4k5wkeZ/5eQZs+c6/ZG1SHCJQoy7bZcmxiMyaXs9HoFmv1PEKrZgWg==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.52.tgz",
"integrity": "sha512-klahlb2EIFltSUubn/VLjuc3qxp1E7th8ukayPfdkcKvvYcQ5rJztgx8JsJSuAKVzKtNTqUGOhy4On71BuyV8g==",
"cpu": [
"x64"
],
@@ -2815,9 +2815,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.42.tgz",
"integrity": "sha512-4PSd46sFzqpLHSGdaSViAb1mk55sCUMpJg+X8ittXaVocQsV3QLG/uydSH8RyL0ngHX5fy3D70LcCzlB15AgHw==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.52.tgz",
"integrity": "sha512-UuA+JqQIgqtkgGN2c/AQ5wi8M6mJHrahz/wciENPTeI6zEIbbLGoth5XN+sQe2pJDejEVofN9aOAp0kaazwnVg==",
"cpu": [
"x64"
],
@@ -2832,9 +2832,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.42.tgz",
"integrity": "sha512-BmWoeJJyeZXmZBcfoxG6J9+rl2G7eO47qdTkAzEegj4n3aC6CBIHOuDcbE8BvhZaEjQR0nh0nJrtEDlt65Q7Sw==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.52.tgz",
"integrity": "sha512-1BNQW8u4ro8bsN1+tgKENJiqmvc+WfuaUhXzMImOVSMw28pkBKdfZtX2qJPADV3terx+vNJtlsgSGeb3+W6Jiw==",
"cpu": [
"arm64"
],
@@ -2849,9 +2849,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.42.tgz",
"integrity": "sha512-2Ft32F7uiDTrGZUKws6CLNTlvTWHC33l4vpXrzUucf9rYtUThAdPCOt89Pmn13tNX6AulxjGEP2R0nZjTSW3eQ==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.52.tgz",
"integrity": "sha512-K/p7clhCqJOQpXGykrFaBX2Dp9AUVIDHGc+PtFGBwg7V+mvBTv/tsm3LC3aUmH02H2y3gz4y+nUTQ0MLpofEEg==",
"cpu": [
"wasm32"
],
@@ -2859,16 +2859,16 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.0.6"
"@napi-rs/wasm-runtime": "^1.0.7"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.42.tgz",
"integrity": "sha512-hC1kShXW/z221eG+WzQMN06KepvPbMBknF0iGR3VMYJLOe9gwnSTfGxFT5hf8XrPv7CEZqTWRd0GQpkSHRbGsw==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.52.tgz",
"integrity": "sha512-a4EkXBtnYYsKipjS7QOhEBM4bU5IlR9N1hU+JcVEVeuTiaslIyhWVKsvf7K2YkQHyVAJ+7/A9BtrGqORFcTgng==",
"cpu": [
"arm64"
],
@@ -2883,9 +2883,9 @@
}
},
"node_modules/@rolldown/binding-win32-ia32-msvc": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.42.tgz",
"integrity": "sha512-AICBYromawouGjj+GS33369E8Vwhy6UwhQEhQ5evfS8jPCsyVvoICJatbDGDGH01dwtVGLD5eDFzPicUOVpe4g==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.52.tgz",
"integrity": "sha512-5ZXcYyd4GxPA6QfbGrNcQjmjbuLGvfz6728pZMsQvGHI+06LT06M6TPtXvFvLgXtexc+OqvFe1yAIXJU1gob/w==",
"cpu": [
"ia32"
],
@@ -2900,9 +2900,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.42.tgz",
"integrity": "sha512-XpZ0M+tjoEiSc9c+uZR7FCnOI0uxDRNs1elGOMjeB0pUP1QmvVbZGYNsyLbLoP4u7e3VQN8rie1OQ8/mB6rcJg==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.52.tgz",
"integrity": "sha512-tzpnRQXJrSzb8Z9sm97UD3cY0toKOImx+xRKsDLX4zHaAlRXWh7jbaKBePJXEN7gNw7Nm03PBNwphdtA8KSUYQ==",
"cpu": [
"x64"
],
@@ -2917,9 +2917,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.42.tgz",
"integrity": "sha512-N7pQzk9CyE7q0bBN/q0J8s6Db279r5kUZc6d7/wWRe9/zXqC52HQovVyu6iXPIDY4BEzzgbVLhVFXrOuGJ22ZQ==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.52.tgz",
"integrity": "sha512-/L0htLJZbaZFL1g9OHOblTxbCYIGefErJjtYOwgl9ZqNx27P3L0SDfjhhHIss32gu5NWgnxuT2a2Hnnv6QGHKA==",
"dev": true,
"license": "MIT"
},
@@ -4437,16 +4437,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/ansis": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz",
"integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -12260,15 +12250,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-beta.42",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.42.tgz",
"integrity": "sha512-xaPcckj+BbJhYLsv8gOqezc8EdMcKKe/gk8v47B0KPvgABDrQ0qmNPAiT/gh9n9Foe0bUkEv2qzj42uU5q1WRg==",
"version": "1.0.0-beta.52",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.52.tgz",
"integrity": "sha512-Hbnpljue+JhMJrlOjQ1ixp9me7sUec7OjFvS+A1Qm8k8Xyxmw3ZhxFu7LlSXW1s9AX3POE9W9o2oqCEeR5uDmg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.94.0",
"@rolldown/pluginutils": "1.0.0-beta.42",
"ansis": "=4.2.0"
"@oxc-project/types": "=0.99.0",
"@rolldown/pluginutils": "1.0.0-beta.52"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -12277,20 +12266,20 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-beta.42",
"@rolldown/binding-darwin-arm64": "1.0.0-beta.42",
"@rolldown/binding-darwin-x64": "1.0.0-beta.42",
"@rolldown/binding-freebsd-x64": "1.0.0-beta.42",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.42",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.42",
"@rolldown/binding-linux-arm64-musl": "1.0.0-beta.42",
"@rolldown/binding-linux-x64-gnu": "1.0.0-beta.42",
"@rolldown/binding-linux-x64-musl": "1.0.0-beta.42",
"@rolldown/binding-openharmony-arm64": "1.0.0-beta.42",
"@rolldown/binding-wasm32-wasi": "1.0.0-beta.42",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.42",
"@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.42",
"@rolldown/binding-win32-x64-msvc": "1.0.0-beta.42"
"@rolldown/binding-android-arm64": "1.0.0-beta.52",
"@rolldown/binding-darwin-arm64": "1.0.0-beta.52",
"@rolldown/binding-darwin-x64": "1.0.0-beta.52",
"@rolldown/binding-freebsd-x64": "1.0.0-beta.52",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.52",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.52",
"@rolldown/binding-linux-arm64-musl": "1.0.0-beta.52",
"@rolldown/binding-linux-x64-gnu": "1.0.0-beta.52",
"@rolldown/binding-linux-x64-musl": "1.0.0-beta.52",
"@rolldown/binding-openharmony-arm64": "1.0.0-beta.52",
"@rolldown/binding-wasm32-wasi": "1.0.0-beta.52",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.52",
"@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.52",
"@rolldown/binding-win32-x64-msvc": "1.0.0-beta.52"
}
},
"node_modules/run-parallel": {
@@ -14130,18 +14119,18 @@
},
"node_modules/vite": {
"name": "rolldown-vite",
"version": "7.1.16",
"resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.1.16.tgz",
"integrity": "sha512-cK6tCmZyEC0KRAcXTjQ+ara+wkqmaE7WUoI0ZfZzDuvaRaZ3mtvbhTJc4cH+PjKRok++++Z1bZZaNlf3+SnnGA==",
"version": "7.2.8",
"resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.2.8.tgz",
"integrity": "sha512-8wKihlF6EDF8grimwd7GPOhLkQkSIgj6Hlcp0CXhtO3HAXeUUqhgZmJmn07OF8e4PbTusMX6Yxmy1BptVRZsdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/runtime": "0.92.0",
"@oxc-project/runtime": "0.99.0",
"fdir": "^6.5.0",
"lightningcss": "^1.30.2",
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rolldown": "1.0.0-beta.42",
"rolldown": "1.0.0-beta.52",
"tinyglobby": "^0.2.15"
},
"bin": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "windmill-components",
"version": "1.583.1",
"version": "1.585.1",
"scripts": {
"dev": "vite dev",
"build": "vite build",
@@ -65,7 +65,7 @@
"tar": "^7.4.3",
"tslib": "^2.6.1",
"typescript": "^5.5.0",
"vite": "npm:rolldown-vite@latest",
"vite": "npm:rolldown-vite@7.2.8",
"vite-plugin-dts": "^4.5.4",
"vite-plugin-mkcert": "^1.17.5",
"yootils": "^0.3.1"
@@ -1049,6 +1049,8 @@
{#if oneOf && oneOf.length >= 2}
<ToggleButtonGroup
selected={oneOfSelected}
wrap
class="mb-4"
on:selected={({ detail }) => {
oneOfSelected = detail
const selectedObjProperties =
@@ -121,6 +121,7 @@
{:else if stepDetail.value.type == 'forloopflow'}
For loop {#if stepDetail.value.parallel}(parallel){/if}
{#if stepDetail.value.skip_failures}(skip failures){/if}
{#if stepDetail.value.squash}(squash){/if}
{:else if stepDetail.value.type == 'branchall'}
Run all branches {#if stepDetail.value.parallel}(parallel){/if}
{:else if stepDetail.value.type == 'branchone'}
@@ -128,7 +129,8 @@
{:else if stepDetail.value.type == 'flow'}
Inner flow
{:else if stepDetail.value.type == 'whileloopflow'}
While loop
While loop {#if stepDetail.value.skip_failures}(skip failures){/if}
{#if stepDetail.value.squash}(squash){/if}
{:else if stepDetail.id === 'failure'}
Error handler
{:else if stepDetail.id === 'preprocessor'}
+6 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import { Map, View, Feature } from 'ol'
import { Fill, Stroke, Style, Text } from 'ol/style.js'
import { useGeographic } from 'ol/proj'
import { OSM, Vector as VectorSource } from 'ol/source'
import { Vector as VectorLayer, Tile as TileLayer } from 'ol/layer'
import { Point } from 'ol/geom'
import { defaults as defaultControls } from 'ol/control'
import CircleStyle from 'ol/style/Circle'
import { useGeographic } from 'ol/proj.js'
import { OSM, Vector as VectorSource } from 'ol/source.js'
import { Vector as VectorLayer, Tile as TileLayer } from 'ol/layer.js'
import { Point } from 'ol/geom.js'
import { defaults as defaultControls } from 'ol/control.js'
import CircleStyle from 'ol/style/Circle.js'
interface Marker {
lon: number
+3 -3
View File
@@ -138,7 +138,7 @@
}
export async function reset() {
if (path == '' || path == 'u//') {
if (path == '' || path == 'u//' || path?.startsWith('tmp/')) {
if ($lastMetaUsed == undefined || $lastMetaUsed.owner != $userStore?.username) {
meta = {
ownerKind: hideUser ? 'folder' : 'user',
@@ -323,12 +323,12 @@
async function initPath() {
await tick()
if (path != undefined && path != '') {
if (path != undefined && path != '' && !path?.startsWith('tmp/')) {
meta = pathToMeta(path, hideUser)
onMetaChange()
return
}
if (initialPath == undefined || initialPath == '') {
if (initialPath == undefined || initialPath == '' || initialPath?.startsWith('tmp/')) {
reset()
} else {
meta = pathToMeta(initialPath, hideUser)
@@ -87,7 +87,7 @@
</script>
{#if queueState}
<div class="text-small ml-4">
<div class="text-xs ml-4">
<span class="text-orange-600">Queue position: <b>{queueState.position}</b></span>
{#if !minimal}
<span class="ml-2 text-primary">(Waiting for an available worker)</span>
@@ -379,7 +379,7 @@
if (templateScript) {
script.content += '\r\n' + templateScript
}
scriptEditor?.inferSchema(script.content, language, true)
scriptEditor?.inferSchema(script.content, { nlang: language, resetArgs: true })
if (script.content != editor?.getCode()) {
setCode(script.content)
}
@@ -567,7 +567,7 @@
if (!disableHistoryChange) {
history.replaceState(history.state, '', `/scripts/edit/${script.path}`)
}
if (stay || script.kind !== 'script' || script.no_main_func) {
if (stay || (script.no_main_func && script.kind !== 'preprocessor')) {
script.parent_hash = newHash
sendUserToast('Deployed')
} else {
@@ -849,7 +849,7 @@
captureOn.set(true)
}
function addPreprocessor() {
function addPreprocessor(e?: { detail?: { args: Record<string, any> } }) {
const code = editor?.getCode()
if (code) {
const preprocessorCode = getPreprocessorFullCode(script.language, false)
@@ -865,6 +865,11 @@
}
}
selectedInputTab = 'preprocessor'
// Apply provided args to the preprocessor
if (e?.detail?.args && Object.keys(e.detail.args).length > 0) {
args = { ...args, ...e.detail.args }
}
}
function handleDeployTrigger(trigger: Trigger) {
@@ -123,6 +123,8 @@
enablePreprocessorSnippet = false
}: Props = $props()
let initialArgs = structuredClone($state.snapshot(args))
$effect.pre(() => {
if (schema == undefined) {
schema = emptySchema()
@@ -269,7 +271,18 @@
})
}
export async function inferSchema(code: string, nlang?: SupportedLanguage, resetArgs = false) {
export async function inferSchema(
code: string,
{
nlang,
resetArgs = false,
applyInitialArgs = false
}: {
nlang?: SupportedLanguage
resetArgs?: boolean
applyInitialArgs?: boolean
} = {}
) {
let nschema = schema ?? emptySchema()
try {
@@ -297,6 +310,10 @@
if (resetArgs) {
args = {}
}
if (applyInitialArgs) {
// we reapply initial args as the schema form might have cleared them between mount and the schema inference
args = initialArgs
}
schema = nschema
} catch (e) {
validCode = false
@@ -338,7 +355,7 @@
}
onMount(() => {
inferSchema(code)
inferSchema(code, { applyInitialArgs: true })
loadPastTests()
aiChatManager.saveAndClear()
aiChatManager.changeMode(AIMode.SCRIPT)
@@ -0,0 +1,55 @@
<div class="flex flex-col h-screen bg-surface">
<!-- Top bar -->
<div class="h-12 px-4 border-b flex items-center gap-4">
<div class="w-8 h-8 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="flex-1 h-6 max-w-md rounded animate-skeleton dark:bg-frost-900/50"></div>
</div>
<!-- Editor bar -->
<div class="border-b shadow-sm px-4 py-2 flex gap-2">
<div class="w-20 h-6 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="w-20 h-6 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="w-20 h-6 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="flex-1"></div>
<div class="w-24 h-6 rounded animate-skeleton dark:bg-frost-900/50"></div>
</div>
<!-- Main content area -->
<div class="flex flex-1 overflow-hidden">
<!-- Left panel - Editor -->
<div class="flex-1 min-w-0 border-r bg-surface-secondary">
<div class="w-full h-full p-4">
<div class="w-32 h-4 mb-3 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="w-48 h-4 mb-3 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="w-40 h-4 mb-3 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="w-56 h-4 mb-3 rounded animate-skeleton dark:bg-frost-900/50"></div>
</div>
</div>
<!-- Right panel - Test/Preview with horizontal split -->
<div class="w-2/5 min-w-0 flex flex-col bg-surface">
<!-- Top section - test inputs -->
<div class="flex-1 border-b flex flex-col">
<div class="h-10 border-b px-4 flex items-center">
<div class="w-24 h-4 rounded animate-skeleton dark:bg-frost-900/50"></div>
</div>
<div class="flex-1 p-4 space-y-3">
<div class="w-full h-8 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="w-full h-8 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="w-32 h-8 rounded animate-skeleton dark:bg-frost-900/50"></div>
</div>
</div>
<!-- Bottom section - results -->
<div class="flex-1 flex flex-col">
<div class="h-10 border-b px-4 flex items-center">
<div class="w-24 h-4 rounded animate-skeleton dark:bg-frost-900/50"></div>
</div>
<div class="flex-1 p-4">
<div class="w-full h-4 mb-2 rounded animate-skeleton dark:bg-frost-900/50"></div>
<div class="w-3/4 h-4 rounded animate-skeleton dark:bg-frost-900/50"></div>
</div>
</div>
</div>
</div>
</div>
@@ -29,6 +29,9 @@
{disabled}
class={disabled ? 'border-0' : ''}
allowClear={!disabled}
onCreateItem={(c) => worker_tags.push(c)}
onCreateItem={(c) => {
worker_tags.push(c)
dispatch('dirty')
}}
createText="Press Enter to use this tag"
/>
@@ -0,0 +1,97 @@
/**
* Component-Managed Fields Registry
*
* This module provides a centralized registry of fields that are automatically
* managed by components at runtime, rather than being configured by users.
*
* These fields should never be treated as static fields in force_viewer_static_fields,
* as their values are determined dynamically by user interactions (pagination,
* sorting, search, chat input, etc.)
*/
import type { AppInput } from '$lib/components/apps/inputType'
/**
* Registry of component types to their managed field names
*/
export const COMPONENT_MANAGED_FIELDS: Record<string, string[]> = {
aggridinfinitecomponent: ['offset', 'limit', 'orderBy', 'isDesc', 'search'],
aggridinfinitecomponentee: ['offset', 'limit', 'orderBy', 'isDesc', 'search'],
chatcomponent: ['user_message']
}
/**
* Get the list of managed fields for a given component type
*/
export function getManagedFields(componentType: string): string[] {
return COMPONENT_MANAGED_FIELDS[componentType] ?? []
}
/**
* Check if a field is managed by a specific component type
*/
export function isFieldManagedByComponent(componentType: string, fieldName: string): boolean {
const managedFields = getManagedFields(componentType)
return managedFields.includes(fieldName)
}
/**
* Convert component-managed fields from static to evalv2 type
* This ensures they are properly handled at runtime and not added to force_viewer_static_fields
*/
export function convertManagedFieldsToEvalv2(
componentType: string,
componentId: string,
fields: Record<string, AppInput>
): Record<string, AppInput> {
const managedFieldNames = getManagedFields(componentType)
if (managedFieldNames.length === 0) {
return fields
}
const convertedFields = { ...fields }
for (const fieldName of managedFieldNames) {
if (convertedFields[fieldName]) {
// Determine the expression based on component type
let expr: string
if (
componentType === 'aggridinfinitecomponent' ||
componentType === 'aggridinfinitecomponentee'
) {
// AgGrid components use params.{fieldName}
expr = `${componentId}.params.${fieldName}`
} else if (componentType === 'chatcomponent') {
// Chat component uses userMessage output
expr = `${componentId}.userMessage`
} else {
// Default pattern for future components
expr = `${componentId}.${fieldName}`
}
// Convert to evalv2 type while preserving fieldType
convertedFields[fieldName] = {
type: 'evalv2',
expr,
fieldType: convertedFields[fieldName].fieldType ?? 'string',
connections: []
} as AppInput
}
}
return convertedFields
}
/**
* Get all managed field names across all component types
* Useful for generic checks
*/
export function getAllManagedFieldNames(): string[] {
const allFields = new Set<string>()
Object.values(COMPONENT_MANAGED_FIELDS).forEach((fields) => {
fields.forEach((field) => allFields.add(field))
})
return Array.from(allFields)
}
@@ -0,0 +1,338 @@
<script lang="ts">
import { getContext } from 'svelte'
import type { AppInput } from '../../inputType'
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
import RunnableWrapper from '../helpers/RunnableWrapper.svelte'
import type RunnableComponent from '../helpers/RunnableComponent.svelte'
import { initConfig, initOutput } from '../../editor/appUtils'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import { twMerge } from 'tailwind-merge'
import { Loader2 } from 'lucide-svelte'
import { initCss } from '../../utils'
import ChatMessage from '$lib/components/chat/ChatMessage.svelte'
import ChatInput from '$lib/components/chat/ChatInput.svelte'
import { parseStreamDeltas } from '$lib/components/chat/utils'
import { randomUUID } from '$lib/components/flows/conversations/FlowChatManager.svelte'
interface Message {
id: string
role: 'user' | 'assistant'
content: string
}
interface Props {
id: string
componentInput: AppInput | undefined
configuration: RichConfigurations
customCss?: ComponentCustomCSS<'chatcomponent'> | undefined
recomputeIds?: string[] | undefined
render: boolean
errorHandledByComponent?: boolean
}
let {
id,
componentInput,
configuration,
customCss = undefined,
recomputeIds = undefined,
render,
errorHandledByComponent = $bindable(false)
}: Props = $props()
const { worldStore, app, componentControl } = getContext<AppViewerContext>('AppViewerContext')
// Initialize outputs
let outputs = initOutput($worldStore, id, {
result: undefined as any,
loading: false,
jobId: undefined as string | undefined,
messages: [] as Message[],
userMessage: '' as string // Output for evalv2 field
})
// Resolve configuration
let resolvedConfig = $state(
initConfig(components['chatcomponent'].initialData.configuration, configuration)
)
// Initialize CSS
let css = $state(initCss($app.css?.chatcomponent, customCss))
// State
let runnableComponent: RunnableComponent | undefined = $state()
let runnableWrapper: RunnableWrapper | undefined = $state()
let loading = $state(false)
let result: any = $state(undefined)
let messages: Message[] = $state([])
let inputValue = $state('')
let messagesContainer: HTMLDivElement | undefined = $state()
// Streaming state management
let currentStreamingMessageIndex: number | undefined = $state(undefined)
let accumulatedContent = $state('')
// Generate stable memory_id for chat session (for agent memory persistence)
let chatMemoryId = $state(randomUUID())
// Register component control for programmatic access
$componentControl[id] = {
sendMessage: (message: string) => {
if (message && !loading) {
inputValue = message
handleSend()
}
}
}
// Auto-scroll to bottom when messages change
$effect(() => {
if (messages.length > 0 && messagesContainer) {
setTimeout(() => {
messagesContainer?.scrollTo({
top: messagesContainer.scrollHeight,
behavior: 'smooth'
})
}, 50)
}
})
// Handle streaming updates
function handleStreamUpdate(e: CustomEvent<{ id: string; result_stream: string }>) {
const streamContent = e.detail.result_stream
const parsed = parseStreamDeltas(streamContent)
if (parsed.content) {
accumulatedContent = parsed.content
} else {
accumulatedContent = streamContent
}
// Update or create streaming message
if (currentStreamingMessageIndex !== undefined) {
messages = messages.map((msg, idx) =>
idx === currentStreamingMessageIndex ? { ...msg, content: accumulatedContent } : msg
)
} else {
const assistantMessage: Message = {
id: randomUUID(),
role: 'assistant',
content: accumulatedContent
}
messages = [...messages, assistantMessage]
currentStreamingMessageIndex = messages.length - 1
}
}
// Handle job completion
function handleJobComplete(e: CustomEvent<{ id: string; result: any }>) {
const finalResult = e.detail.result
// Extract final content
let finalContent = ''
if (typeof finalResult === 'string') {
finalContent = finalResult
} else if (finalResult && typeof finalResult === 'object' && 'output' in finalResult) {
finalContent =
typeof finalResult.output === 'string'
? finalResult.output
: JSON.stringify(finalResult.output, null, 2)
} else {
finalContent = JSON.stringify(finalResult, null, 2)
}
// If we were streaming, update the message with final result to ensure completeness
if (currentStreamingMessageIndex !== undefined && finalContent) {
messages = messages.map((msg, idx) =>
idx === currentStreamingMessageIndex ? { ...msg, content: finalContent } : msg
)
}
// If not streaming, create new message
else if (finalContent.length > 0) {
messages = [
...messages,
{
id: randomUUID(),
role: 'assistant',
content: finalContent
}
]
}
// Finalize streaming
currentStreamingMessageIndex = undefined
accumulatedContent = ''
}
// Handle job error
function handleJobError(e: CustomEvent<{ id: string; error: any }>) {
const error = e.detail.error
// Add error message
messages = [
...messages,
{
id: randomUUID(),
role: 'assistant',
content: `Error: ${error.message || JSON.stringify(error)}`
}
]
// Reset streaming state
currentStreamingMessageIndex = undefined
accumulatedContent = ''
}
// Handle send message
async function handleSend() {
if (!inputValue.trim() || loading) return
const userMessage = inputValue.trim()
inputValue = ''
// Add user message to chat
const newUserMessage: Message = {
id: randomUUID(),
role: 'user',
content: userMessage
}
messages = [...messages, newUserMessage]
// Reset streaming state for new message
currentStreamingMessageIndex = undefined
accumulatedContent = ''
// Update output so evalv2 field can reference it
outputs.userMessage.set(userMessage)
// Trigger the runnable
if (!runnableComponent) {
runnableWrapper?.handleSideEffect(true)
} else {
await runnableComponent?.runComponent()
}
}
// Handle enter key
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
$effect(() => {
errorHandledByComponent = resolvedConfig?.onError?.selected !== 'errorOverlay'
})
</script>
<InitializeComponent {id} />
{#each Object.keys(components['chatcomponent'].initialData.configuration) as key (key)}
<ResolveConfig
{id}
{key}
bind:resolvedConfig={resolvedConfig[key]}
configuration={configuration[key]}
/>
{/each}
{#each Object.keys(css ?? {}) as key (key)}
<ResolveStyle
{id}
{customCss}
{key}
bind:css={css[key]}
componentStyle={$app.css?.chatcomponent}
/>
{/each}
<RunnableWrapper
bind:this={runnableWrapper}
bind:runnableComponent
bind:loading
bind:result
{componentInput}
{id}
{recomputeIds}
{outputs}
doOnSuccess={resolvedConfig.onSuccess}
doOnError={resolvedConfig.onError}
on:streamupdate={handleStreamUpdate}
on:done={handleJobComplete}
on:doneError={handleJobError}
{errorHandledByComponent}
autoRefresh={false}
{render}
extraQueryParams={{ memory_id: chatMemoryId }}
>
{#if render}
<div
class={twMerge(
'flex flex-col h-full border rounded-lg bg-surface',
css?.container?.class,
'wm-chat-container'
)}
style={css?.container?.style}
>
<!-- Messages Container -->
<div
bind:this={messagesContainer}
class={twMerge(
'flex-1 overflow-y-auto p-4 bg-background',
css?.messagesContainer?.class,
'wm-chat-messages'
)}
style={css?.messagesContainer?.style}
>
{#if messages.length === 0}
<div class="flex items-center justify-center h-full text-tertiary text-sm">
No messages yet. Start a conversation!
</div>
{:else}
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
{#each messages as message (message.id)}
<ChatMessage
role={message.role}
content={message.content}
enableMarkdown={true}
enableS3Display={true}
customCss={{
userMessage: css?.userMessage,
assistantMessage: css?.assistantMessage
}}
/>
{/each}
{#if loading}
<div class="flex items-center gap-2 text-tertiary">
<Loader2 size={16} class="animate-spin" />
<span class="text-sm">Processing...</span>
</div>
{/if}
</div>
{/if}
</div>
<!-- Input Container -->
<div
class={twMerge('border-t p-3', css?.inputContainer?.class, 'wm-chat-input')}
style={css?.inputContainer?.style}
>
<ChatInput
bind:value={inputValue}
placeholder={resolvedConfig.placeholder}
disabled={loading}
onSend={handleSend}
onKeydown={handleKeydown}
customCss={{
input: css?.input,
button: css?.button
}}
/>
</div>
</div>
{/if}
</RunnableWrapper>
@@ -6,17 +6,17 @@
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
import { twMerge } from 'tailwind-merge'
import { Map, View, Feature } from 'ol'
import { useGeographic } from 'ol/proj'
import { OSM, Vector as VectorSource } from 'ol/source'
import { Vector as VectorLayer, Tile as TileLayer } from 'ol/layer'
import { Point } from 'ol/geom'
import { defaults as defaultControls } from 'ol/control'
import { useGeographic } from 'ol/proj.js'
import { OSM, Vector as VectorSource } from 'ol/source.js'
import { Vector as VectorLayer, Tile as TileLayer } from 'ol/layer.js'
import { Point } from 'ol/geom.js'
import { defaults as defaultControls } from 'ol/control.js'
import { initConfig, initOutput } from '../../editor/appUtils'
import InitializeComponent from '../helpers/InitializeComponent.svelte'
import ResolveStyle from '../helpers/ResolveStyle.svelte'
import { components } from '../../editor/component'
import ResolveConfig from '../helpers/ResolveConfig.svelte'
import { Style, Circle, Fill, Stroke, Text } from 'ol/style'
import { Style, Circle, Fill, Stroke, Text } from 'ol/style.js'
import { findGridItem } from '../../editor/appUtilsCore'
interface Marker {
lon: number
@@ -197,6 +197,7 @@
result_stream?: string
}) {
setResult(nresult_stream, id, false)
dispatch('streamupdate', { id, result_stream: nresult_stream })
},
cancel({ id }: { id: string }) {
onCancel?.()
@@ -442,7 +443,8 @@
$appPath,
id,
await buildRequestBody(dynamicArgsOverride),
inlineScriptOverride
inlineScriptOverride,
extraQueryParams
)
if (isEditor) {
addJob(uuid)
@@ -473,35 +475,36 @@
}
export async function buildRequestBody(dynamicArgsOverride: Record<string, any> | undefined) {
const nonStaticRunnableInputs = dynamicArgsOverride ?? {}
const staticRunnableInputs = {}
const nonStaticRunnableInputs: Record<string, any> = dynamicArgsOverride ?? {}
const staticRunnableInputs: Record<string, any> = {}
const allowUserResources: string[] = []
for (const k of Object.keys(fields ?? {})) {
let field = fields[k]
if (field?.type == 'static' && fields[k]) {
const field = fields[k]
if (
isEditor &&
['user', 'evalv2', 'connected'].includes(field.type) &&
'allowUserResources' in field &&
field.allowUserResources
) {
allowUserResources.push(k)
}
if (field?.type == 'static') {
if (isEditor) {
staticRunnableInputs[k] = field.value
}
} else if (field?.type == 'user') {
nonStaticRunnableInputs[k] = args?.[k]
if (isEditor && field.allowUserResources) {
allowUserResources.push(k)
}
} else if (field?.type == 'eval' || (field?.type == 'evalv2' && inputValues[k])) {
const ctxMatch = field?.expr?.match(ctxRegex)
if (ctxMatch) {
nonStaticRunnableInputs[k] = '$ctx:' + ctxMatch[1]
} else {
// console.log('k', k)
nonStaticRunnableInputs[k] = await inputValues[k]?.computeExpr()
}
if (isEditor && field?.type == 'evalv2' && field.allowUserResources) {
allowUserResources.push(k)
}
} else {
if (isEditor && field?.type == 'connected' && field.allowUserResources) {
allowUserResources.push(k)
}
nonStaticRunnableInputs[k] = runnableInputValues[k]
}
}
@@ -360,6 +360,7 @@
on:cancel
on:recompute
on:argsChanged
on:streamupdate
on:resultSet={(e) => {
const res = e.detail
if ($initialized?.runnableInitialized?.[fullId] === undefined) {
@@ -31,7 +31,7 @@ function create_context_function_template(
) {
let hasReturnAsLastLine = noReturn || eval_string.split('\n').some((x) => x.startsWith('return '))
return `
return async function (context, state, createProxy, goto, setTab, recompute, globalRecompute, getAgGrid, setValue, setSelectedIndex, openModal, closeModal, open, close, validate, invalidate, validateAll, clearFiles, showToast, waitJob, askNewResource, downloadFile) {
return async function (context, state, createProxy, goto, setTab, recompute, globalRecompute, getAgGrid, setValue, setSelectedIndex, openModal, closeModal, open, close, validate, invalidate, validateAll, clearFiles, sendMessage, showToast, waitJob, askNewResource, downloadFile) {
"use strict";
${
contextKeys && contextKeys.length > 0
@@ -68,6 +68,7 @@ type WmFunctor = (
invalidate,
validateAll,
clearFiles,
sendMessage,
showToast,
waitJob,
askNewResource,
@@ -123,6 +124,7 @@ export async function eval_like(
waitJob?: (jobId: string) => void
askNewResource?: () => void
setGroupValue?: (key: string, value: any) => void
sendMessage?: (message: string) => void
}
>,
worldStore: World | undefined,
@@ -246,6 +248,9 @@ export async function eval_like(
(id) => {
controlComponents[id]?.clearFiles?.()
},
(id, message) => {
controlComponents[id]?.sendMessage?.(message)
},
(message, error) => {
sendUserToast(message, error)
},
@@ -11,7 +11,8 @@ export async function executeRunnable(
path: string,
id: string,
requestBody: ExecuteComponentData['requestBody'],
inlineScriptOverride?: InlineScript
inlineScriptOverride?: InlineScript,
queryParams?: Record<string, any>
) {
let appPath = defaultIfEmptyString(path, `u/${username ?? 'unknown'}/newapp`)
if (runnable?.type === 'runnableByName') {
@@ -40,6 +41,10 @@ export async function executeRunnable(
requestBody['version'] = version
}
if (queryParams && Object.keys(queryParams).length > 0) {
requestBody['run_query_params'] = queryParams
}
const uuid = await AppService.executeComponent({
workspace,
path: appPath,
@@ -676,6 +676,7 @@
document.documentElement.classList.remove('dark')
}
$darkMode = newDarkMode ?? globalDarkMode
$app.darkMode = newDarkMode
}
let priorDarkMode = document.documentElement.classList.contains('dark')
@@ -159,71 +159,6 @@
sendUserToast('App reporting disabled')
}
const appPreviewScript = `import puppeteer from 'puppeteer-core';
import dayjs from 'dayjs';
export async function main(
app_path: string,
startup_duration = 5,
kind: 'pdf' | 'png' = 'pdf',
customWidth: null | number,
customHeight: null | number,
) {
let browser = null
try {
browser = await puppeteer.launch({ headless: true, executablePath: '/usr/bin/chromium', args: ['--no-sandbox',
'--no-zygote',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu'] });
const page = await browser.newPage();
await page.setCookie({
"name": "token",
"value": Bun.env["WM_TOKEN"],
"domain": Bun.env["BASE_URL"]?.replace(/https?:\\/\\//, '')
})
page
.on('console', msg =>
console.log(dayjs().format("HH:mm:ss") + " " + msg.type().substr(0, 3).toUpperCase() + " " + msg.text()))
.on('pageerror', ({ msg }) => console.log(dayjs().format("HH:mm:ss") + " " + msg));
await page.setViewport({ width: 1200, height: 2000 });
await page.goto(Bun.env["BASE_URL"] + '/apps/get/' + app_path + '?workspace=' + Bun.env["WM_WORKSPACE"] + "&hideRefreshBar=true&hideEditBtn=true");
await page.waitForSelector("#app-content", { timeout: 20000 })
const elem = await page.$('#app-content');
let width: null | number = customWidth || 1200
let height: null | number = customHeight || (await elem.boundingBox()).height
await page.setViewport({ width, height });
await new Promise((resolve, _) => {
setTimeout(resolve, startup_duration * 1000)
})
try {
await page.$eval("#sidebar", el => el.remove())
} catch {}
await page.$eval("#content", el => el.classList.remove("md:pl-12"))
await page.$$eval(".app-component-refresh-btn", els => els.forEach(el => el.remove()))
await page.$$eval(".app-table-footer-btn", els => els.forEach(el => el.remove()))
await new Promise((resolve, _) => setTimeout(resolve, 500))
const screenshot = kind === "pdf" ? await page.pdf({
printBackground: true,
width,
height
}) : await page.screenshot({
fullPage: true,
type: "png",
captureBeyondViewport: false
});
await browser.close();
return Buffer.from(screenshot).toString('base64');
} catch (err) {
if (browser) {
await browser.close();
}
throw err;
}
}`
const notificationScripts = {
discord: {
path: hubPaths.discordReport,
@@ -347,10 +282,9 @@ export async function main(
{
id: 'a',
value: {
type: 'rawscript' as const,
tag: 'chromium',
content: appPreviewScript,
language: 'bun' as const,
type: 'script' as const,
tag_override: 'chromium',
path: hubPaths.appReport,
input_transforms: {
app_path: {
expr: 'flow_input.app_path',
@@ -52,6 +52,7 @@
import AppDateInput from '../../components/inputs/AppDateInput.svelte'
import AppSelect from '../../components/inputs/AppSelect.svelte'
import AppBarChart from '../../components/display/AppBarChart.svelte'
import AppChat from '../../components/display/AppChat.svelte'
import AppDivider from '../../components/layout/AppDivider.svelte'
import AppRangeInput from '../../components/inputs/AppRangeInput.svelte'
import AppTabs from '../../components/layout/AppTabs.svelte'
@@ -110,6 +111,7 @@
'carousellistcomponent',
'chartjscomponent',
'chartjscomponentv2',
'chatcomponent',
'checkboxcomponent',
'codeinputcomponent',
'conditionalwrapper',
@@ -340,6 +342,16 @@
xData={component.xData}
{render}
/>
{:else if component.type === 'chatcomponent'}
<AppChat
id={component.id}
configuration={component.configuration}
componentInput={component.componentInput}
customCss={component.customCss}
recomputeIds={component.recomputeIds}
bind:errorHandledByComponent
{render}
/>
{:else if component.type === 'checkboxcomponent'}
<AppCheckbox
id={component.id}
@@ -54,7 +54,8 @@ import {
RefreshCw,
ListCollapse,
GalleryThumbnails,
Code
Code,
MessageSquare
} from 'lucide-svelte'
import type {
Aligned,
@@ -208,6 +209,7 @@ export type AggridInfiniteComponentEe = BaseComponent<'aggridinfinitecomponentee
}
export type DisplayComponent = BaseComponent<'displaycomponent'>
export type ChatComponent = BaseComponent<'chatcomponent'> & RecomputeOthersSource
export type JobIdDisplayComponent = BaseComponent<'jobiddisplaycomponent'>
export type LogComponent = BaseComponent<'logcomponent'>
export type JobIdLogComponent = BaseComponent<'jobidlogcomponent'>
@@ -338,6 +340,7 @@ export type RecomputeAllComponent = BaseComponent<'recomputeallcomponent'>
export type TypedComponent =
| DBExplorerComponent
| DisplayComponent
| ChatComponent
| LogComponent
| JobIdLogComponent
| FlowStatusComponent
@@ -461,6 +464,23 @@ export type AppComponentConfig<T extends TypedComponent['type']> = {
*/
initialData: InitialAppComponent
customCss: ComponentCustomCSS<T>
/**
* Optional configuration for runnable inputs validation
*/
runnableInputsInfo?: {
/**
* Function to validate runnable inputs and return a warning if needed
* @param fields - The fields object from componentInput.fields
* @returns Warning object with type, title, and message, or undefined if valid
*/
validate?: (fields: Record<string, any>) =>
| {
type: 'warning' | 'error' | 'info'
title: string
message: string
}
| undefined
}
}
export type PresetComponentConfig = {
@@ -1128,6 +1148,56 @@ export const components = {
}
}
},
chatcomponent: {
name: 'Chat',
icon: MessageSquare,
documentationLink: `${documentationBaseUrl}/chat`,
dims: '3:8-6:12' as AppComponentDimensions,
customCss: {
container: { class: '', style: '' },
messagesContainer: { class: '', style: '' },
inputContainer: { class: '', style: '' },
userMessage: { class: '', style: '' },
assistantMessage: { class: '', style: '' },
input: { class: '', style: '' },
button: { class: '', style: '' }
},
runnableInputsInfo: {
validate: (fields) => {
const fieldNames = Object.keys(fields)
const hasUserMessage = fieldNames.includes('user_message')
if (!hasUserMessage) {
return {
type: 'warning' as const,
title: 'Chat input configuration',
message:
'The chat component requires a <code>user_message</code> parameter to work. Please add it to your event handler.'
}
}
return undefined
}
},
initialData: {
componentInput: {
type: 'runnable',
fieldType: 'any',
fields: {},
runnable: undefined
},
recomputeIds: true,
configuration: {
placeholder: {
type: 'static',
fieldType: 'text',
value: 'Type a message...'
},
onSuccess: onSuccessClick,
onError: onErrorClick
}
}
},
jobidlogcomponent: {
name: 'Log by Job Id',
icon: Monitor,
@@ -2343,12 +2413,7 @@ This is a paragraph.
documentationLink: `${documentationBaseUrl}/select`,
dims: '2:1-3:1' as AppComponentDimensions,
customCss: {
input: {
style: '',
tooltip:
'https://github.com/rob-balfre/svelte-select/blob/master/docs/theming_variables.md',
class: ''
}
input: { style: '', class: '' }
},
initialData: {
recomputeIds: true,
@@ -765,5 +765,18 @@ return {
}`,
python3: `def main():
return [{"foo": 1}, {"foo": 2}, {"foo": 3}]`
},
chatcomponent: {
deno: `export async function main(user_message: string) {
// Process the user message here
// You can call APIs, query databases, use AI models, etc.
return "Hi, how are you?";
}`,
python3: `def main(user_message: str):
# Process the user message here
# You can call APIs, query databases, use AI models, etc.
return "Hi, how are you?"`
}
} as const
@@ -73,6 +73,7 @@ const display: ComponentSet = {
'htmlcomponent',
'mardowncomponent',
'pdfcomponent',
'chatcomponent',
'displaycomponent',
'jobidlogcomponent',
'jobidflowstatuscomponent',
@@ -83,6 +83,12 @@ const validateAll = {
example: 'validateAll(id: string, key: string)'
}
const sendMessage = {
title: 'sendMessage',
description: 'Programmatically send a message to the chat component',
example: 'sendMessage(id: string, message: string)'
}
export function getComponentControl(type: keyof typeof components): Array<ComponentFunction> {
switch (type) {
case 'tabscomponent':
@@ -135,6 +141,8 @@ export function getComponentControl(type: keyof typeof components): Array<Compon
return [setSelectedIndex]
case 'dbexplorercomponent':
return [recompute]
case 'chatcomponent':
return [sendMessage]
default:
if (components[type].initialData['componentInput']) {
return [recompute]
@@ -76,7 +76,11 @@ export const authorizedClassnames = [
'wm-conditional-tabs',
'wm-rich-result-header',
'wm-rich-result-container'
'wm-rich-result-container',
'wm-chat-container',
'wm-chat-messages',
'wm-chat-input'
// TODO: Select and mutltiselect
]
@@ -619,6 +623,27 @@ export const customisationByComponent: Customisation[] = [
],
variables: []
},
{
components: ['chatcomponent'],
selectors: [
{
selector: '.wm-chat-container',
comment: 'Main chat container',
customCssKey: 'container'
},
{
selector: '.wm-chat-messages',
comment: 'Messages container',
customCssKey: 'messagesContainer'
},
{
selector: '.wm-chat-input',
comment: 'Input container',
customCssKey: 'inputContainer'
}
],
variables: []
},
{
components: ['mardowncomponent'],
selectors: [
@@ -649,6 +649,14 @@ export const quickStyleProperties: Record<
header: [...containerDefaultProps, typographyGrouping],
container: containerDefaultProps
},
chatcomponent: {
container: containerDefaultProps,
messagesContainer: containerDefaultProps,
userMessage: containerDefaultProps,
assistantMessage: containerDefaultProps,
input: containerDefaultProps,
button: buttonDefaultProps
},
logcomponent: {
header: [...containerDefaultProps, typographyGrouping],
container: containerDefaultProps
@@ -25,6 +25,7 @@
import EditorSettings from '$lib/components/EditorSettings.svelte'
import { userStore, workspaceStore } from '$lib/stores'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { convertManagedFieldsToEvalv2 } from '$lib/components/apps/components/componentManagedFields'
const {
runnableComponents,
@@ -127,55 +128,18 @@
fieldType: 'number'
}
}
} else if (
componentType === 'aggridinfinitecomponent' ||
componentType === 'aggridinfinitecomponentee'
) {
newFields['offset'] = {
type: 'evalv2',
expr: `${id}.params.offset`,
fieldType: 'number'
}
newFields['limit'] = {
type: 'evalv2',
expr: `${id}.params.limit`,
fieldType: 'number'
}
newFields['orderBy'] = {
type: 'evalv2',
expr: `${id}.params.orderBy`,
fieldType: 'string'
}
newFields['isDesc'] = {
type: 'evalv2',
expr: `${id}.params.isDesc`,
fieldType: 'boolean'
}
newFields['search'] = {
type: 'evalv2',
expr: `${id}.params.search`,
fieldType: 'string'
}
} else {
// Convert component-managed fields to evalv2 type using centralized utility
const convertedFields = convertManagedFieldsToEvalv2(componentType, id, newFields)
Object.assign(newFields, convertedFields)
}
}
function assertConnections(newFields) {
if (
componentType === 'aggridinfinitecomponent' ||
componentType === 'aggridinfinitecomponentee'
) {
const fields = ['offset', 'limit', 'orderBy', 'isDesc', 'search']
fields.forEach((field) => {
if (newFields[field]?.type !== 'evalv2') {
newFields[field] = {
type: 'evalv2',
expr: `${id}.params.${field}`,
fieldType: newFields[field]?.fieldType ?? 'string'
}
}
})
}
// Convert component-managed fields to evalv2 type using centralized utility
// This ensures that even if fields were somehow changed, they remain as evalv2
const convertedFields = convertManagedFieldsToEvalv2(componentType ?? '', id, newFields)
Object.assign(newFields, convertedFields)
}
async function loadSchemaAndInputsByName() {
@@ -1,5 +1,6 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { getContext } from 'svelte'
import type { AppEditorContext, AppViewerContext, GridItem, RichConfiguration } from '../../types'
import PanelSection from './common/PanelSection.svelte'
@@ -37,6 +38,7 @@
import ComponentPanelDataSource from './ComponentPanelDataSource.svelte'
import MenuItems from './MenuItems.svelte'
import DecisionTreeGraphEditor from './DecisionTreeGraphEditor.svelte'
import { getManagedFields } from '$lib/components/apps/components/componentManagedFields'
import GridAgChartsLicenseKe from './GridAgChartsLicenseKe.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import ContextVariables from './ContextVariables.svelte'
@@ -147,6 +149,10 @@
? ccomponents[item?.data?.type]?.initialData?.componentInput
: undefined
const runnableInputsInfo = item?.data?.type
? ccomponents[item.data.type]?.runnableInputsInfo
: undefined
const hasInteraction = item.data.type ? isTriggerable(item.data.type) : false
let evalV2editor: EvalV2InputEditor | undefined = $state(undefined)
@@ -355,6 +361,19 @@
parameters this component is attached to.
</Tooltip>
</div>
<!-- Generic runnable inputs validation -->
{#if runnableInputsInfo?.validate}
{@const validation = runnableInputsInfo?.validate(
item.data.componentInput.fields ?? {}
)}
{#if validation}
<Alert type={validation.type} title={validation.title} size="xs" class="my-2">
{@html validation.message}
</Alert>
{/if}
{/if}
<InputsSpecsEditor
id={component.id}
shouldCapitalize={false}
@@ -367,10 +386,7 @@
acceptSelf={component.type === 'aggridinfinitecomponent' ||
component.type === 'aggridinfinitecomponentee' ||
component.type === 'steppercomponent'}
overridenByComponent={component.type === 'aggridinfinitecomponent' ||
component.type === 'aggridinfinitecomponentee'
? ['offset', 'limit', 'orderBy', 'isDesc', 'search']
: []}
overridenByComponent={getManagedFields(component.type)}
securedContext
/>
</div>
@@ -6,6 +6,7 @@
import RunnableSelector from '../mainInput/RunnableSelector.svelte'
import SelectedRunnable from '../SelectedRunnable.svelte'
import type { AppEditorContext, AppViewerContext } from '$lib/components/apps/types'
import { convertManagedFieldsToEvalv2 } from '$lib/components/apps/components/componentManagedFields'
interface Props {
appInput: ResultAppInput
@@ -26,7 +27,14 @@
fields: Record<string, StaticAppInput>
}) {
if (appInput.type === 'runnable') {
appInput = { ...appInput, runnable, fields }
// Convert component-managed fields from static to evalv2 type
// This ensures they are properly handled at runtime and not added to force_viewer_static_fields
const convertedFields = convertManagedFieldsToEvalv2(
appComponent.type,
appComponent.id,
fields
) as Record<string, StaticAppInput>
appInput = { ...appInput, runnable, fields: convertedFields }
$selectedComponentInEditor = appComponent.id
} else {
console.warn('Cannot pick runnable for non-runnable input')
@@ -33,7 +33,7 @@ export function isFrontend(runnable: Runnable): boolean {
}
export function isTriggerable(componentType: string): boolean {
return ['buttoncomponent', 'formbuttoncomponent', 'formcomponent', 'steppercomponent'].includes(
return ['buttoncomponent', 'formbuttoncomponent', 'formcomponent', 'steppercomponent', 'chatcomponent'].includes(
componentType
)
}
@@ -55,7 +55,12 @@ export function getAllTriggerEvents(
const isTriggerableComponent = isTriggerable(appComponent.type)
if (isTriggerableComponent) {
events.push('click')
// Use 'send' for chat component, 'click' for others
if (appComponent.type === 'chatcomponent') {
events.push('send')
} else {
events.push('click')
}
if (triggerOnAppLoad) {
events.push('start')
@@ -295,6 +295,7 @@ export type AppViewerContext = {
invalidate?: (key: string, error: string) => void
validateAll?: () => void
clearFiles?: () => void
sendMessage?: (message: string) => void
showToast?: (message: string, error?: boolean) => void
recompute?: () => void
askNewResource?: () => void
@@ -345,6 +345,12 @@ declare function validateAll(id: string): void;
*/
declare function clearFiles(id: string): void;
/** Send a message to a chat component
* @param id component's id
* @param message message to send
*/
declare function sendMessage(id: string, message: string): void;
/** Display a toast message
* @param message message to display
*/
@@ -0,0 +1,87 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import { ArrowUp, Square } from 'lucide-svelte'
import autosize from '$lib/autosize'
import { createBubbler, stopPropagation } from 'svelte/legacy'
const bubble = createBubbler()
interface Props {
value: string
placeholder?: string
disabled?: boolean
onSend: () => void
onKeydown?: (e: KeyboardEvent) => void
customCss?: {
container?: { class?: string; style?: string }
input?: { class?: string; style?: string }
button?: { class?: string; style?: string }
}
bindTextarea?: HTMLTextAreaElement
showCancelButton?: boolean
onCancel?: () => void
cancelTitle?: string
sendTitle?: string
}
let {
value = $bindable(),
placeholder = 'Type your message here...',
disabled = false,
onSend,
onKeydown = undefined,
customCss = undefined,
bindTextarea = $bindable(undefined),
showCancelButton = false,
onCancel = undefined,
cancelTitle = 'Cancel execution',
sendTitle = 'Send message'
}: Props = $props()
function handleKeydown(e: KeyboardEvent) {
onKeydown?.(e)
}
</script>
<div
class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-600 bg-surface-input px-3 py-2 {customCss
?.container?.class ?? ''}"
style={customCss?.container?.style}
>
<textarea
bind:this={bindTextarea}
bind:value
use:autosize
onkeydown={handleKeydown}
onpointerdown={stopPropagation(bubble('pointerdown'))}
{placeholder}
class="flex-1 min-h-[24px] max-h-32 resize-none !border-0 text-sm placeholder-gray-400 !outline-none !ring-0 p-0 !shadow-none focus:!border-0 focus:!outline-none focus:!ring-0 focus:!shadow-none {customCss
?.input?.class ?? ''}"
style={customCss?.input?.style}
rows={3}
></textarea>
{#if showCancelButton && onCancel}
<Button
color="red"
size="xs2"
btnClasses="!rounded-full !p-1.5 {customCss?.button?.class ?? ''}"
style={customCss?.button?.style}
startIcon={{ icon: Square }}
on:click={onCancel}
iconOnly
title={cancelTitle}
/>
{:else}
<Button
color="blue"
size="xs2"
btnClasses="!rounded-full !p-1.5 {customCss?.button?.class ?? ''}"
style={customCss?.button?.style}
startIcon={{ icon: ArrowUp }}
disabled={!value.trim() || disabled}
on:click={onSend}
iconOnly
title={sendTitle}
/>
{/if}
</div>
@@ -0,0 +1,127 @@
<script lang="ts">
import { Markdown } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { Loader2, CheckCircle2, AlertTriangle } from 'lucide-svelte'
import CodeDisplay from '$lib/components/copilot/chat/script/CodeDisplay.svelte'
import LinkRenderer from '$lib/components/copilot/chat/LinkRenderer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { workspaceStore } from '$lib/stores'
interface Props {
role: 'user' | 'assistant' | 'tool' | 'system'
content: string
loading?: boolean
success?: boolean
stepName?: string
enableMarkdown?: boolean
enableS3Display?: boolean
customCss?: {
userMessage?: { class?: string; style?: string }
assistantMessage?: { class?: string; style?: string }
}
}
let {
role,
content,
loading = false,
success = undefined,
stepName = undefined,
enableMarkdown = true,
enableS3Display = true,
customCss = undefined
}: Props = $props()
// Parse S3 objects if enabled
const s3Object = $derived.by(() => {
if (!enableS3Display || role !== 'assistant' || !content) return undefined
try {
const parsed = JSON.parse(content)
if (parsed?.type === 'windmill_s3_object' && parsed?.s3) {
return parsed
}
} catch (e) {
// Not JSON
}
return undefined
})
const messageClass = $derived.by(() => {
const base = 'max-w-[90%] min-w-0 rounded-lg w-fit break-words'
if (role === 'user') {
const userClass = `${base} ml-auto bg-surface-secondary p-3`
return customCss?.userMessage?.class
? `${userClass} ${customCss.userMessage.class}`
: userClass
}
// assistant, tool, and system messages use the same styling
const assistantClass = `${base} mr-auto bg-surface border ${success !== false ? 'border-gray-200 dark:border-gray-600' : '!border-red-500'}`
return customCss?.assistantMessage?.class
? `${assistantClass} ${customCss.assistantMessage.class}`
: assistantClass
})
const messageStyle = $derived.by(() => {
if (role === 'user') {
return customCss?.userMessage?.style
}
return customCss?.assistantMessage?.style
})
</script>
<div class={messageClass} style={messageStyle}>
{#if stepName}
<div
class="bg-surface-secondary text-2xs text-tertiary mb-2 font-medium py-1 px-2 rounded-t-lg"
>
{stepName}
</div>
{/if}
{#if role === 'user'}
<p class="whitespace-pre-wrap text-sm text-right">{content}</p>
{:else if loading}
<div class="flex items-center gap-2 text-tertiary px-3 py-3">
<Loader2 size={16} class="animate-spin" />
<span>Processing...</span>
</div>
{:else if content}
{#if s3Object}
<div class="px-3 pb-3 {!stepName ? 'pt-3' : ''}">
<DisplayResult result={s3Object} workspaceId={$workspaceStore} noControls={true} />
</div>
{:else if enableMarkdown}
<div
class="flex flex-row items-center gap-2 px-3 pb-3 text-sm {!stepName
? 'pt-3'
: ''} overflow-x-auto"
>
{#if role === 'tool'}
{#if success !== false}
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
{:else}
<AlertTriangle class="w-3.5 h-3.5 text-red-500" />
{/if}
{/if}
<div class="prose prose-sm dark:prose-invert break-words prose-headings:!text-base">
<Markdown
md={content}
plugins={[
gfmPlugin(),
{
renderer: {
pre: CodeDisplay,
a: LinkRenderer
}
}
]}
/>
</div>
</div>
{:else}
<p class="whitespace-pre-wrap text-sm px-3 pb-3 {!stepName ? 'pt-3' : ''}">{content}</p>
{/if}
{:else}
<p class="text-tertiary text-sm px-3 py-3">No result</p>
{/if}
</div>
+30
View File
@@ -0,0 +1,30 @@
export function parseStreamDeltas(streamData: string): {
content: string
type?: string
success?: boolean
} {
const lines = streamData.trim().split('\n')
let content = ''
let type = 'message'
let success = true
for (const line of lines) {
if (!line.trim()) continue
try {
const parsed = JSON.parse(line)
if (parsed.type === 'tool_result') {
type = 'tool_result'
success = parsed.success
const toolName = parsed.function_name
content = success ? `Used ${toolName} tool` : `Failed to use ${toolName} tool`
}
if (parsed.type === 'token_delta' && parsed.content) {
content += parsed.content
}
} catch (e) {
console.error('Failed to parse stream line:', line, e)
}
}
return { content, type, success }
}
@@ -10,6 +10,7 @@
import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../toggleButton-v2/ToggleButton.svelte'
import { userStore } from '$lib/stores'
import Badge from '../badge/Badge.svelte'
interface Props {
open?: boolean
@@ -132,32 +133,22 @@
value={'discard'}
{item}
small
class="data-[state=on]:text-white data-[state=on]:bg-red-400 w-[54px] justify-center"
class="data-[state=on]:text-white data-[state=on]:bg-red-400 justify-center"
/>
<ToggleButton
label={!trigger.isDraft && trigger.draftConfig ? 'Update' : 'Deploy'}
value={'deploy'}
{item}
small
class="data-[state=on]:bg-marine-400 data-[state=on]:text-white data-[state=on]:dark:bg-marine-50 data-[state=on]:dark:text-primary-inverse w-[54px] justify-center"
class="data-[state=on]:bg-surface-accent-primary data-[state=on]:text-white justify-center"
/>
{/snippet}
</ToggleButtonGroup>
</div>
{:else if permission === 'admin-only'}
<div
class="text-xs font-semibold px-1.5 py-1.5 bg-red-400 text-white rounded whitespace-nowrap w-[114px] text-center"
title="Only admins can deploy http triggers"
>
Admin only
</div>
<Badge color="red">Admin only</Badge>
{:else if permission === 'invalid-config'}
<div
class="text-xs font-semibold px-1.5 py-1.5 bg-red-400 text-white rounded whitespace-nowrap w-[114px] text-center"
title="Invalid config"
>
Invalid config
</div>
<Badge color="red">Invalid config</Badge>
{/if}
</td>
</tr>
@@ -112,7 +112,7 @@
<Row
aiId={`script-run-button-${script.path}`}
aiDescription={`Button to access the form to run the script ${script.summary ?? script.path}`}
href={script.draft_only || script.kind !== 'script' || script.no_main_func
href={script.draft_only || (script.no_main_func && script.kind !== 'preprocessor')
? `${base}/scripts/edit/${script.path}`
: `${base}/scripts/get/${script.hash}?workspace=${$workspaceStore}`}
kind="script"
@@ -58,7 +58,7 @@
<div class="grow px-2 inline-flex items-center gap-4 min-w-0">
<div
class={twMerge(
'text-lg min-w-24 font-bold truncate',
'text-lg min-w-24 font-semibold text-emphasis truncate',
$userStore?.operator ? 'pl-10' : ''
)}
>
@@ -64,7 +64,8 @@
initialPathStore,
fakeInitialPath,
previewArgs,
flowInputEditorState
flowInputEditorState,
stepsInputArgs
} = getContext<FlowEditorContext>('FlowEditorContext')
const selectedId = $derived(selectionManager.getSelectedId())
@@ -115,10 +116,11 @@
{:else if selectedId === 'Trigger'}
<TriggersEditor
on:applyArgs
on:addPreprocessor={async () => {
on:addPreprocessor={async (e) => {
await insertNewPreprocessorModule(flowStore, flowStateStore, {
language: 'bun'
})
stepsInputArgs.setStepArgs('preprocessor', e.detail.args ?? {})
selectionManager.selectId('preprocessor')
}}
on:updateSchema={(e) => {
@@ -32,6 +32,7 @@
import { slide } from 'svelte/transition'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
const { previewArgs, flowStateStore, flowStore, currentEditor } =
getContext<FlowEditorContext>('FlowEditorContext')
@@ -186,6 +187,33 @@
class="whitespace-nowrap"
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Squash
<Badge
>Beta <Tooltip documentationLink="https://www.windmill.dev/docs/flows/flow_loops">
<span class="font-semibold"
>This can result in unexpected behavior, use at your own risk for now.</span
><br />
Squashing a for loop runs all iterations on the same worker, using a single runner
per step for the entire loop. This eliminates cold starts between iterations for supported
languages (Bun, Deno, and Python).
</Tooltip>
</Badge>
</div>
<Toggle
bind:checked={mod.value.squash}
on:change={({ detail }) => {
;(mod.value as ForloopFlow).squash = detail
}}
options={{
right: 'Squash'
}}
class="whitespace-nowrap"
disabled={mod.value.parallel}
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold">Run in parallel</div>
<Toggle
@@ -199,6 +227,7 @@
right: 'All iterations run in parallel'
}}
class="whitespace-nowrap"
disabled={mod.value.squash}
/>
</div>
<div class="flex-shrink-0">
@@ -14,12 +14,13 @@
import FlowModuleSleep from './FlowModuleSleep.svelte'
import FlowModuleMock from './FlowModuleMock.svelte'
import { Play } from 'lucide-svelte'
import type { FlowModule, Job } from '$lib/gen'
import type { FlowModule, Job, WhileloopFlow } from '$lib/gen'
import FlowLoopIterationPreview from '$lib/components/FlowLoopIterationPreview.svelte'
import FlowModuleDeleteAfterUse from './FlowModuleDeleteAfterUse.svelte'
import FlowModuleSkip from './FlowModuleSkip.svelte'
import TabsV2 from '$lib/components/common/tabs/TabsV2.svelte'
import { useUiIntent } from '$lib/components/copilot/chat/flow/useUiIntent'
import Badge from '$lib/components/common/badge/Badge.svelte'
const { flowStateStore } = getContext<FlowEditorContext>('FlowEditorContext')
@@ -86,7 +87,7 @@
{#if mod.value.type === 'whileloopflow'}
<div class="flex flex-row gap-8 mt-2 mb-6">
<div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Skip failures <Tooltip
documentationLink="https://www.windmill.dev/docs/flows/while_loops"
@@ -102,6 +103,32 @@
}}
/>
</div>
<div class="flex-shrink-0">
<div class="mb-2 text-sm font-bold"
>Squash
<Badge
>Beta <Tooltip documentationLink="https://www.windmill.dev/docs/flows/while_loops">
<span class="font-semibold"
>This can result in unexpected behavior, use at your own risk for now.</span
><br />
Squashing a for loop runs all iterations on the same worker, using a single runner
per step for the entire loop. This eliminates cold starts between iterations for supported
languages (Bun, Deno, and Python).
</Tooltip>
</Badge>
</div>
<Toggle
bind:checked={mod.value.squash}
on:change={({ detail }) => {
;(mod.value as WhileloopFlow).squash = detail
}}
options={{
right: 'Squash'
}}
class="whitespace-nowrap"
/>
</div>
</div>
<div class="my-2 flex flex-row gap-2 items-center">
@@ -1,8 +1,8 @@
<script lang="ts">
import { Button, Alert } from '$lib/components/common'
import { MessageCircle, Loader2, ArrowUp, Square } from 'lucide-svelte'
import autosize from '$lib/autosize'
import FlowChatMessage from './FlowChatMessage.svelte'
import { Alert } from '$lib/components/common'
import { MessageCircle, Loader2 } from 'lucide-svelte'
import ChatMessage from '$lib/components/chat/ChatMessage.svelte'
import ChatInput from '$lib/components/chat/ChatInput.svelte'
import { FlowChatManager } from './FlowChatManager.svelte'
interface Props {
@@ -36,7 +36,13 @@
{:else}
<div class="w-full space-y-4 xl:max-w-7xl mx-auto">
{#each manager.messages as message (message.id)}
<FlowChatMessage {message} />
<ChatMessage
role={message.message_type}
content={message.content}
loading={message.loading}
success={message.success}
stepName={message.step_name}
/>
{/each}
{#if manager.isWaitingForResponse}
<div class="flex items-center gap-2 text-tertiary">
@@ -50,43 +56,17 @@
<!-- Chat Input -->
<div class="flex flex-row justify-center py-2 xl:max-w-7xl mx-auto w-full">
<div
class="flex items-center gap-2 rounded-lg border border-gray-200 dark:border-gray-600 bg-surface-input w-full"
class:opacity-50={deploymentInProgress}
>
<textarea
bind:this={manager.inputElement}
<div class="w-full" class:opacity-50={deploymentInProgress}>
<ChatInput
bind:value={manager.inputMessage}
use:autosize
onkeydown={manager.handleKeyDown}
placeholder="Type your message here..."
class="flex-1 min-h-[24px] max-h-32 resize-none !border-0 text-sm placeholder-gray-400 !outline-none !ring-0 p-0 !shadow-none focus:!border-0 focus:!outline-none focus:!ring-0 focus:!shadow-none"
rows={3}
></textarea>
<div class="flex-shrink-0 pr-2 bg-surface-input">
{#if manager.isWaitingForResponse || manager.isLoading}
<Button
color="red"
size="xs2"
btnClasses="!rounded-full !p-1.5"
startIcon={{ icon: Square }}
on:click={() => manager.cancelCurrentJob()}
iconOnly
title="Cancel execution"
/>
{:else}
<Button
color="blue"
size="xs2"
btnClasses="!rounded-full !p-1.5"
startIcon={{ icon: ArrowUp }}
disabled={!manager.inputMessage?.trim() || manager.isLoading || deploymentInProgress}
on:click={() => manager.sendMessage()}
iconOnly
title={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'}
/>
{/if}
</div>
bind:bindTextarea={manager.inputElement}
disabled={manager.isLoading || deploymentInProgress}
onSend={() => manager.sendMessage()}
onKeydown={manager.handleKeyDown}
showCancelButton={manager.isWaitingForResponse || manager.isLoading}
onCancel={() => manager.cancelCurrentJob()}
sendTitle={deploymentInProgress ? 'Deployment in progress' : 'Send message (Enter)'}
/>
</div>
</div>
</div>
@@ -6,6 +6,7 @@ import { tick } from 'svelte'
import InfiniteList from '$lib/components/InfiniteList.svelte'
import { workspaceStore, userStore } from '$lib/stores'
import { get } from 'svelte/store'
import { parseStreamDeltas } from '$lib/components/chat/utils'
export interface ChatMessage extends FlowConversationMessage {
loading?: boolean
@@ -87,6 +88,7 @@ export class FlowChatManager {
}
focusInput() {
console.log('focusInput', this.inputElement)
this.inputElement?.focus()
}
@@ -320,36 +322,6 @@ export class FlowChatManager {
}
}
private parseStreamDeltas(streamData: string): {
type: string
content: string
success: boolean
} {
let type = 'message'
const lines = streamData.trim().split('\n')
let content = ''
let success = true
for (const line of lines) {
if (!line.trim()) continue
try {
const parsed = JSON.parse(line)
if (parsed.type === 'tool_result') {
type = 'tool_result'
const toolName = parsed.function_name
success = parsed.success
content = success ? `Used ${toolName} tool` : `Failed to use ${toolName} tool`
}
if (parsed.type === 'token_delta' && parsed.content) {
type = 'message'
content += parsed.content
}
} catch (e) {
console.error('Failed to parse stream line:', line, e)
}
}
return { type, content, success }
}
private async pollConversationMessages(conversationId: string, isNewConversation?: boolean) {
if (!get(workspaceStore)) return
@@ -513,7 +485,7 @@ export class FlowChatManager {
type,
content: newContent,
success
} = this.parseStreamDeltas(data.new_result_stream)
} = parseStreamDeltas(data.new_result_stream)
accumulatedContent += newContent
// Create tool message if type is tool_result
@@ -1,93 +0,0 @@
<script lang="ts">
import { Markdown } from 'svelte-exmarkdown'
import { gfmPlugin } from 'svelte-exmarkdown/gfm'
import { Loader2, CheckCircle2, AlertTriangle } from 'lucide-svelte'
import CodeDisplay from '$lib/components/copilot/chat/script/CodeDisplay.svelte'
import LinkRenderer from '$lib/components/copilot/chat/LinkRenderer.svelte'
import DisplayResult from '$lib/components/DisplayResult.svelte'
import { type ChatMessage } from './FlowChatManager.svelte'
import { workspaceStore } from '$lib/stores'
interface Props {
message: ChatMessage
}
let { message }: Props = $props()
// Parse content to detect S3 objects
const s3Object: any | undefined = $derived.by(() => {
if (message.message_type === 'assistant' && message.content) {
try {
const parsed = JSON.parse(message.content)
// Check if it's a Windmill S3 object with type discriminator
if (parsed?.type === 'windmill_s3_object' && parsed?.s3 && typeof parsed.s3 === 'string') {
return parsed
}
} catch (e) {
// Not JSON, treat as regular text
}
}
return undefined
})
const messageClass = $derived.by(() => {
const base = 'max-w-[90%] min-w-0 rounded-lg w-fit'
if (message.message_type === 'user') {
return `${base} ml-auto bg-surface-secondary p-3`
}
return `${base} mr-auto bg-surface border ${message.success !== false ? 'border-gray-200 dark:border-gray-600' : '!border-red-500'}`
})
</script>
<div class={messageClass} data-message-id={message.id}>
{#if message.step_name}
<div class="bg-surface-secondary text-2xs text-tertiary mb-2 font-medium py-1 px-2 rounded-t-lg"
>{message.step_name}</div
>
{/if}
{#if message.message_type === 'user'}
<p class="whitespace-pre-wrap text-sm break-words text-right">{message.content}</p>
{:else if message.loading}
<div class="flex items-center gap-2 text-tertiary">
<Loader2 size={16} class="animate-spin" />
<span>Processing...</span>
</div>
{:else if message.content}
{#if s3Object}
<div class="px-3 pb-3 {!message.step_name ? 'pt-3' : ''}">
<DisplayResult result={s3Object} workspaceId={$workspaceStore} noControls={true} />
</div>
{:else}
<div
class="flex flex-row items-center gap-2 px-3 pb-3 text-sm {!message.step_name
? 'pt-3'
: ''} overflow-x-auto"
>
{#if message.message_type === 'tool'}
{#if message.success !== false}
<CheckCircle2 class="w-3.5 h-3.5 text-green-500" />
{:else}
<AlertTriangle class="w-3.5 h-3.5 text-red-500" />
{/if}
{/if}
<div class="prose prose-sm dark:prose-invert break-words prose-headings:!text-base">
<Markdown
md={message.content}
plugins={[
gfmPlugin(),
{
renderer: {
pre: CodeDisplay,
a: LinkRenderer
}
}
]}
/>
</div>
</div>
{/if}
{:else}
<p class="text-tertiary text-sm">No result</p>
{/if}
</div>
@@ -11,7 +11,7 @@
let { Icon = Plus, title, id }: Props = $props()
</script>
<button class="center-center p-2 group">
<button class="center-center p-2 group" onclick={(e) => e.stopPropagation()}>
<div
{title}
{id}
@@ -164,7 +164,7 @@
mod.summary || (mod.value.type == 'forloopflow' ? 'For loop' : 'While loop')
} ${mod.value.parallel ? '(parallel)' : ''} ${
mod.value.skip_failures ? '(skip failures)' : ''
}`}
} ${mod.value.squash ? '(squash)' : ''}`}
id={mod.id}
on:changeId
on:move
@@ -12,7 +12,7 @@
const gitSyncContext = getGitSyncContext()
const repo = $derived(gitSyncContext.getRepository(idx))
let targetBranch = $state('main')
let targetBranch = $state<string | undefined>(undefined)
// Update target branch when repository changes
$effect(() => {
@@ -664,12 +664,13 @@ export function createGitSyncContext(workspace: string) {
}
// Helper to get target branch from git resource
async function getTargetBranch(repo: GitSyncRepository): Promise<string> {
async function getTargetBranch(repo: GitSyncRepository): Promise<string | undefined> {
if (!repo.git_repo_resource_path) {
return 'main'
return undefined
}
if (repo._targetBranch) {
if (repo._targetBranch === '') return undefined
return repo._targetBranch
}
@@ -681,14 +682,15 @@ export function createGitSyncContext(workspace: string) {
// Extract branch from git resource value
const resourceValue = resource.value as any
const targetBranch = resourceValue?.branch || 'main'
const targetBranch = resourceValue?.branch
// Cache the result
repo._targetBranch = targetBranch
if (targetBranch === '') return undefined
return targetBranch
} catch (error) {
console.warn('Failed to fetch git resource for branch info:', error)
return 'main'
return undefined
}
}
@@ -3,7 +3,7 @@
let { mode, targetBranch, repository } = $props<{
mode?: 'sync' | 'promotion' | null
targetBranch: string
targetBranch: string | undefined
repository?: GitSyncRepository | null
}>()
</script>
@@ -11,12 +11,15 @@
<div class="text-base">
{#if mode === 'promotion'}
<div
><span class="font-bold">Promotion:</span> Creating branches whose promotion target is {targetBranch}</div
><span class="font-bold">Promotion:</span> Creating branches whose promotion target is {targetBranch? `'${targetBranch}'` :
"the repo's default branch"}</div
>
{#if repository?.group_by_folder}
<div class="text-sm text-primary mt-1">Grouped by folder</div>
{/if}
{:else if targetBranch}
<div><span class="font-bold">Sync:</span> Syncing back to branch '{targetBranch}'</div>
{:else}
<div><span class="font-bold">Sync:</span> Syncing back to branch {targetBranch}</div>
<div><span class="font-bold">Sync:</span> Syncing back to the repo's default branch</div>
{/if}
</div>
@@ -50,7 +50,7 @@
const validation = $derived(idx !== null ? gitSyncContext.getValidation(idx) : null)
const gitSyncTestJob = $derived(idx !== null ? gitSyncContext.gitSyncTestJobs?.[idx] : null)
let confirmingDelete = $state(false)
let targetBranch = $state('main') // Default to main, will be updated when resource is available
let targetBranch = $state<string | undefined>(undefined) // Default to main, will be updated when resource is available
// Update target branch when repository changes
$effect(() => {
@@ -108,12 +108,13 @@
)
// Determine display description based on variant and mode
const targetOrDefaultBranch = $derived(targetBranch ? `'${targetBranch}'` : 'repo\'s default' )
const displayDescription = $derived(
variant === 'primary-sync' || variant === 'primary-promotion'
? mode === 'sync'
? `Changes will be committed directly to the ${targetBranch} branch`
? `Changes will be committed directly to the ${targetOrDefaultBranch} branch`
: mode === 'promotion'
? `Changes will be made to new branches whose promotion target is ${targetBranch}`
? `Changes will be made to new branches whose promotion target is the ${targetOrDefaultBranch} branch`
: null
: null
)
@@ -397,7 +397,7 @@
},
select: (modId) => {
// AI tools are not selectable by the flow. Selection has to be refactored to be simplier.
if (nodes.find((n) => n.data?.moduleId === modId)?.type === 'aiTool') {
if (nodes.find((n) => n.data?.moduleId === modId)?.type === 'aiTool' || modId === 'Trigger') {
selectionManager.selectId(modId)
}
if (!notSelectable) {
@@ -12,6 +12,7 @@
import { MessageSquare, DiffIcon } from 'lucide-svelte'
import { Button } from '$lib/components/common'
import { getGraphContext } from '../../graphContext'
import FunnelCog from '$lib/components/icons/FunnelCog.svelte'
interface Props {
data: InputN['data']
@@ -71,7 +72,11 @@
}}
>
{#snippet trigger()}
<InsertModuleButton title={`Add preprocessor step`} id={`flow-editor-add-step-0`} />
<InsertModuleButton
title={`Add preprocessor step`}
id={`flow-editor-add-step-0`}
Icon={FunnelCog}
/>
{/snippet}
</InsertModulePopover>
</div>
@@ -246,10 +246,10 @@
aiId={`trigger-button-${type}`}
aiDescription={`Trigger button for ${type}`}
class={twMerge(
'hover:bg-surface-hover rounded-md shadow-sm text-xs relative center-center cursor-pointer bg-slate-100 dark:bg-slate-700',
'dark:outline dark:outline-1 outline-tertiary/20 group',
isSelected ? 'outline-tertiary outline' : '',
small ? 'w-[23px] h-[23px] outline-[1.5px]' : 'p-2 outline-[2px]'
'hover:bg-surface-hover rounded-md text-xs relative center-center cursor-pointer border border-border-light',
'group',
isSelected ? 'bg-surface-accent-selected text-accent border-border-selected' : '',
small ? 'w-[23px] h-[23px]' : 'p-2'
)}
on:click={(e) => {
e.stopPropagation()
@@ -0,0 +1,50 @@
<script lang="ts">
interface Props {
size?: number | string
color?: string
strokeWidth?: number | string
absoluteStrokeWidth?: boolean
class?: string
style?: string
}
let {
size = 24,
color = 'currentColor',
strokeWidth = 2,
absoluteStrokeWidth = false,
class: clazz = '',
style = ''
}: Props = $props()
const computedStrokeWidth = $derived(
absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth
)
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={computedStrokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class={clazz}
{style}
>
<path
d="M14.517 12.659A2 2 0 0 0 14 14v7a1 1 0 0 1-1.447.895l-2-1A1 1 0 0 1 10 20v-6a2 2 0 0 0-.517-1.341L2.258 4.67A1 1 0 0 1 3 3h8"
/>
<path d="m15.523 4.547-.923-.383" />
<path d="m15.523 6.843-.923.383" />
<path d="m16.765 2 .382.923" />
<path d="m17.147 8.467-.383.924" />
<path d="M19.443 2.923 19.826 2" />
<path d="m19.825 9.391-.382-.924" />
<path d="m21.067 4.547.924-.383" />
<path d="m21.067 6.843.924.383" />
<circle cx="18.295" cy="5.695" r="3" />
</svg>
@@ -23,7 +23,7 @@
setDropdownWidthToButtonWidth = false,
children,
class: className,
placement = 'bottom',
placement = 'bottom-start',
isEditor = false,
onAddDraftTrigger,
onAddScheduledPoll,
@@ -14,7 +14,7 @@
import AnimatedButton from '../common/button/AnimatedButton.svelte'
import PulseButton from '../common/button/PulseButton.svelte'
import Button from '../common/button/Button.svelte'
import { CircleStop, History, Play, Loader2 } from 'lucide-svelte'
import { CircleStop, History, Play, Loader2, ExternalLink } from 'lucide-svelte'
import ConnectionIndicator, {
type ConnectionInfo
} from '../common/alert/ConnectionIndicator.svelte'
@@ -67,7 +67,7 @@
const dispatch = createEventDispatcher<{
captureToggle: { disableOnly?: boolean }
updateSchema: { payloadData: Record<string, any>; redirect: boolean; args?: boolean }
addPreprocessor: null
addPreprocessor: { args: Record<string, any> } | null
testWithArgs: Record<string, any>
applyArgs: { kind: 'main' | 'preprocessor'; args: Record<string, any> }
}>()
@@ -121,6 +121,7 @@
let lastCaptureId: number | undefined = undefined
let displayResult: DisplayResult | undefined = $state(undefined)
let toolbarLocation: 'internal' | 'external' | undefined = $state(undefined)
let showRawPayload = $state(false)
function selectCapture(capture: Capture) {
selectedCapture = capture
@@ -225,13 +226,14 @@
}
}
function getCapturePayload(capture: Capture) {
function getCapturePayload(capture: Capture | undefined, showRawPayload: boolean) {
if (!capture) return {}
let payloadData: any = {}
const preprocessor_args = isObject(capture.preprocessor_args) ? capture.preprocessor_args : {}
if ('wm_trigger' in preprocessor_args) {
// v1
payloadData =
testKind === 'preprocessor'
testKind === 'preprocessor' || showRawPayload
? {
...(typeof capture.main_args === 'object' ? capture.main_args : {}),
...preprocessor_args
@@ -239,11 +241,20 @@
: capture.main_args
} else {
// v2
payloadData = testKind === 'preprocessor' ? capture.preprocessor_args : capture.main_args
payloadData =
testKind === 'preprocessor' || showRawPayload
? capture.preprocessor_args
: capture.main_args
}
return payloadData
}
function toggleRawPayload() {
showRawPayload = !showRawPayload
}
const payloadData = $derived(getCapturePayload(selectedCapture, showRawPayload))
// Start or stop capture listening based on active state
$effect(() => {
if (captureInfo.active) {
@@ -380,16 +391,16 @@
{#if selectedCapture}
{@const SvelteComponent = triggerIconMap[captureType]}
<div
class={'min-w-16 text-secondary flex flex-row w-fit items-center gap-2 rounded-md bg-surface-secondary p-1 px-2 h-[27px]'}
class="min-w-16 text-primary flex flex-row w-fit items-center gap-2 rounded-md bg-surface-secondary p-1 px-2 h-[27px]"
>
<SvelteComponent size={12} />
<span class="text-xs text-secondary truncate">
<span class="text-xs truncate">
Capture {formatDateShort(selectedCapture?.created_at)}
</span>
</div>
{/if}
{#if selectedCapture}
{#if selectedCapture && !showRawPayload}
{@const label = isFlow && testKind === 'main' ? 'Test flow with args' : 'Apply args'}
{@const title =
isFlow && testKind === 'main'
@@ -455,26 +466,73 @@
/>
{/if}
</div>
<div class="grow min-h-0 rounded-md w-full pl-2 py-1 pb-2 overflow-auto">
{#if isLoadingBigPayload}
<Loader2 class="animate-spin" />
{:else if selectedCapture?.main_args}
<div class="bg-surface rounded-md text-sm" class:animate-highlight={newCaptureReceived}>
<DisplayResult
bind:this={displayResult}
workspaceId={undefined}
jobId={undefined}
result={getCapturePayload(selectedCapture)}
externalToolbarAvailable
on:toolbar-location-changed={({ detail }) => {
toolbarLocation = detail
}}
/>
<div class="grow min-h-0 w-full pl-2 py-1 pb-2 flex flex-col">
<div class="flex-1 min-h-0 overflow-auto">
{#if isLoadingBigPayload}
<Loader2 class="animate-spin" />
{:else if selectedCapture?.main_args}
<div class="bg-surface rounded-md text-sm" class:animate-highlight={newCaptureReceived}>
{#if showRawPayload}
<Alert type="info" title="Raw payload displayed" size="xs" class="mt-2 mb-2">
<div class="flex flex-col gap-2">
<div>
Add a preprocessor to use the raw payload data in the flow.
<a
href="https://www.windmill.dev/docs/core_concepts/preprocessors"
target="_blank"
rel="noopener noreferrer"
class="underline"
>
Learn more about preprocessors
<ExternalLink size={12} class="inline-block" />
</a>
</div>
<div class="flex justify-end">
<Button
size="xs"
variant="accent"
wrapperClasses="w-fit"
onclick={() => {
if (selectedCapture) {
dispatch('addPreprocessor', {
args: $state.snapshot(payloadData)
})
} else {
dispatch('addPreprocessor')
}
}}
>
Add preprocessor
</Button>
</div>
</div>
</Alert>
{/if}
<DisplayResult
bind:this={displayResult}
workspaceId={undefined}
jobId={undefined}
result={payloadData}
externalToolbarAvailable
on:toolbar-location-changed={({ detail }) => {
toolbarLocation = detail
}}
/>
</div>
{:else}
<div class="text-center text-primary p-4 bg-surface rounded-md"
>No captures to show yet.</div
>
{/if}
</div>
{#if !hasPreprocessor && !isLoadingBigPayload && selectedCapture?.main_args}
<div class="mt-2 text-2xs text-secondary flex-shrink-0 text-right px-2">
{showRawPayload ? '' : 'Need more data about the trigger event?'}
<button onclick={toggleRawPayload} class="text-accent cursor-pointer">
{showRawPayload ? 'Show processed payload' : 'Show raw payload'}
</button>
</div>
{:else}
<div class="text-center text-primary p-4 bg-surface rounded-md"
>No captures to show yet.</div
>
{/if}
</div>
</Pane>
@@ -71,7 +71,7 @@
kind: 'main' | 'preprocessor'
args: Record<string, any> | undefined
}
addPreprocessor: null
addPreprocessor: { args: Record<string, any> } | undefined
updateSchema: {
payloadData: any
redirect: boolean
@@ -39,12 +39,12 @@
</ConfirmationModal>
<Button
size="xs"
unifiedSize={small ? 'xs' : 'sm'}
variant="subtle"
destructive
startIcon={{ icon: Trash }}
iconOnly
color={'light'}
on:click={() => {
confirmationModalOpen = true
}}
btnClasses={twMerge(small ? 'px-1 py-1' : '', 'bg-transparent hover:bg-red-500 hover:text-white')}
/>

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