/* * Author: Ruben Fiszel * Copyright: Windmill Labs, Inc 2022 * This file and its contents are licensed under the AGPLv3 License. * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ use std::collections::HashMap; use axum::response::IntoResponse; use axum::{ extract::{Extension, Path, Query}, routing::{delete, get, post}, Json, Router, }; use windmill_api_auth::{ auth::{list_tokens_internal, TruncatedTokenWithEmail}, build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, }; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, utils::HTTP_CLIENT, webhook::{WebhookMessage, WebhookShared}, DB, }; use windmill_queue::schedule::clear_schedule; use hyper::StatusCode; use serde::{Deserialize, Serialize}; use sql_builder::prelude::*; use sqlx::{FromRow, Postgres, Transaction}; use windmill_audit::audit_oss::{audit_log, AuditAuthorable}; use windmill_audit::ActionKind; use windmill_common::assets::{clear_static_asset_usage, AssetUsageKind}; use windmill_common::flows::FlowModule; use windmill_common::min_version::{ MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2, MIN_VERSION_SUPPORTS_NODE_DEBOUNCING, }; use windmill_common::runnable_settings::RunnableSettingsTrait; use windmill_common::utils::query_elems_from_hub; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; use windmill_common::HUB_BASE_URL; use windmill_common::{ db::UserDB, error::{self, to_anyhow, Error, JsonResult, Result}, flows::{Flow, FlowWithStarred, ListFlowQuery, ListableFlow, NewFlow}, jobs::JobPayload, schedule::Schedule, utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, }; use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; use windmill_queue::WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT; use windmill_queue::{push, schedule::push_scheduled_job, PushIsolationLevel}; pub fn workspaced_service() -> Router { Router::new() .route("/list", get(list_flows)) .route("/list_search", get(list_search_flows)) .route("/create", post(create_flow)) .route("/update/{*path}", post(update_flow)) .route("/archive/{*path}", post(archive_flow_by_path)) .route("/delete/{*path}", delete(delete_flow_by_path)) .route("/list_tokens/{*path}", get(list_tokens)) .route("/get/{*path}", get(get_flow_by_path)) .route("/deployment_status/p/{*path}", get(get_deployment_status)) .route("/exists/{*path}", get(exists_flow_by_path)) .route("/list_paths", get(list_paths)) .route("/history/p/{*path}", get(get_flow_history)) .route("/get_latest_version/{*path}", get(get_latest_version)) .route( "/list_paths_from_workspace_runnable/{runnable_kind}/{*path}", get(list_paths_from_workspace_runnable), ) .route("/history_update/v/{version}", post(update_flow_history)) .route("/get/v/{version}", get(get_flow_version_by_id)) .route("/get/v/{version}/p/{*path}", get(get_flow_version)) .route( "/toggle_workspace_error_handler/{*path}", post(toggle_workspace_error_handler), ) } pub fn global_service() -> Router { Router::new() .route("/hub/list", get(list_hub_flows)) .route("/hub/get/{id}", get(get_hub_flow_by_id)) } #[derive(Serialize, FromRow)] pub struct SearchFlow { path: String, value: sqlx::types::Json>, } async fn list_search_flows( authed: ApiAuthed, Path(w_id): Path, Extension(user_db): Extension, ) -> JsonResult> { #[cfg(feature = "enterprise")] let n = 1000; #[cfg(not(feature = "enterprise"))] let n = 3; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "flows", "read"); let rows = sqlx::query_as::<_, SearchFlow>( "SELECT flow.path, flow_version.value FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 LIMIT $2", ) .bind(&w_id) .bind(n) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; Ok(Json(rows)) } async fn list_flows( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path(w_id): Path, Query(pagination): Query, Query(lq): Query, ) -> JsonResult> { let (per_page, offset) = paginate(pagination); let mut sqlb = SqlBuilder::select_from("flow as o") .fields(&[ "o.workspace_id", "o.path", "summary", if !lq.without_description.unwrap_or(false) { "description" } else { "NULL as description" }, "fv.created_by as edited_by", "fv.created_at as edited_at", "archived", "extra_perms", "favorite.path IS NOT NULL as starred", "ws_error_handler_muted", "o.labels", "draft.email IS NOT NULL as is_draft", // Per-path draft owners as a JSON array; see scripts.rs for the rationale // (non-member superadmin identity fallback via `password`, legacy NULL-email row). "(SELECT json_agg(json_build_object('username', COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END)) ORDER BY COALESCE(u.username, p.username, CASE WHEN p.email IS NOT NULL THEN d.email END) NULLS LAST) \ FROM draft d \ LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email \ LEFT JOIN password p ON p.email = d.email AND p.super_admin = true \ WHERE d.workspace_id = o.workspace_id AND d.path = o.path AND d.typ = 'flow') as draft_users", "folder_labels(o.workspace_id, o.path) as inherited_labels" ]) .left() .join("favorite") .on( "favorite.favorite_kind = 'flow' AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?" .bind(&authed.username), ) .left() .join("draft") .on( "draft.path = o.path AND draft.workspace_id = o.workspace_id AND draft.typ = 'flow' AND draft.email = ?" .bind(&authed.email), ) .left() .join("flow_version fv") .on( "fv.id = o.versions[array_upper(o.versions, 1)]" ) .order_desc("favorite.path IS NOT NULL") .order_by("fv.created_at", lq.order_desc.unwrap_or(true)) .and_where("o.workspace_id = ?".bind(&w_id)) .offset(offset) .limit(per_page) .clone(); sqlb.and_where_eq("archived", lq.show_archived.unwrap_or(false)); if let Some(ps) = &lq.path_start { sqlb.and_where_like_left("o.path", ps); } if let Some(p) = &lq.path_exact { sqlb.and_where_eq("o.path", "?".bind(p)); } if let Some(cb) = &lq.edited_by { sqlb.and_where_eq("fv.created_by", "?".bind(cb)); } if lq.starred_only.unwrap_or(false) { sqlb.and_where_is_not_null("favorite.path"); } if let Some(dw) = &lq.dedicated_worker { sqlb.and_where_eq("dedicated_worker", dw); } if let Some(label) = &lq.label { for l in label.split(',') { sqlb.and_where( "(o.labels @> ARRAY[?] OR folder_labels(o.workspace_id, o.path) @> ARRAY[?])" .bind(&l.trim()) .bind(&l.trim()), ); } } if lq.with_deployment_msg.unwrap_or(false) { sqlb.join("deployment_metadata dm") .left() .on("dm.flow_version = o.versions[array_upper(o.versions, 1)]") .fields(&["dm.deployment_msg"]); } let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let allowed = build_scope_path_predicate(&authed, "flows", "read"); let mut rows = sqlx::query_as::<_, ListableFlow>(&sql) .fetch_all(&mut *tx) .await? .into_iter() .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; // Append the authed user's drafts at paths with no deployed flow; see scripts.rs. if lq.include_draft_only.unwrap_or(false) && !authed.is_operator && offset == 0 && lq.path_start.is_none() && lq.path_exact.is_none() && lq.edited_by.is_none() && lq.dedicated_worker.is_none() && lq.label.is_none() && !lq.starred_only.unwrap_or(false) && !lq.show_archived.unwrap_or(false) { // `(email = $2 OR email IS NULL)` + `DISTINCT ON (path)` ordered NULL-last; see scripts.rs. let draft_only_rows = sqlx::query!( r#"SELECT DISTINCT ON (path) path, value as "value!: sqlx::types::Json>", created_at FROM draft WHERE workspace_id = $1 AND typ = 'flow' AND (email = $2 OR email IS NULL) AND NOT EXISTS ( SELECT 1 FROM flow f WHERE f.workspace_id = draft.workspace_id AND f.path = draft.path ) ORDER BY path, (email IS NULL)"#, &w_id, &authed.email, ) .fetch_all(&db) .await?; for row in draft_only_rows { let v: serde_json::Value = serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); // The Path widget binds `$pathStore` one-way (`flow.path → $pathStore`), // so the editor writes a separate `draft_path` field only when the typed // path differs from the deployed one. `None` = unchanged. let draft_path = v .get("draft_path") .and_then(|s| s.as_str()) .filter(|s| !s.is_empty() && *s != row.path.as_str()) .map(|s| s.to_string()); rows.push(ListableFlow { workspace_id: w_id.clone(), path: row.path, summary: v .get("summary") .and_then(|s| s.as_str()) .unwrap_or("") .to_string(), description: v .get("description") .and_then(|s| s.as_str()) .map(|s| s.to_string()), edited_by: Some(authed.email.clone()), edited_at: Some(row.created_at), archived: false, extra_perms: serde_json::Value::Object(serde_json::Map::new()), starred: false, draft_only: Some(true), ws_error_handler_muted: None, deployment_msg: None, labels: None, // No deployed row to inherit folder labels from. inherited_labels: None, is_draft: true, draft_path, // Synthesized rows are the authed user's own draft. draft_users: Some(sqlx::types::Json(vec![DraftUserRef { username: Some(authed.username.clone()), }])), }); } } Ok(Json(rows)) } async fn list_hub_flows(Extension(db): Extension) -> impl IntoResponse { let (status_code, headers, response) = query_elems_from_hub( &HTTP_CLIENT, &format!("{}/searchFlowData?approved=true", **HUB_BASE_URL.load()), None, &db, ) .await?; Ok::<_, Error>((status_code, headers, response)) } async fn list_paths( authed: ApiAuthed, Extension(user_db): Extension, Path(w_id): Path, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; let flows = sqlx::query_scalar!( "SELECT distinct(path) FROM flow WHERE workspace_id = $1", w_id ) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(Json(flows)) } pub async fn get_hub_flow_by_id( Path(id): Path, Extension(db): Extension, ) -> JsonResult> { let value = http_get_from_hub( &HTTP_CLIENT, &format!("{}/flows/{}/json", **HUB_BASE_URL.load(), id), false, None, Some(&db), ) .await? .json() .await .map_err(to_anyhow)?; Ok(Json(value)) } #[derive(Deserialize)] pub struct ToggleWorkspaceErrorHandler { #[cfg(feature = "enterprise")] pub muted: Option, } #[cfg(not(feature = "enterprise"))] async fn toggle_workspace_error_handler( _authed: ApiAuthed, Extension(_user_db): Extension, Path((_w_id, _path)): Path<(String, StripPath)>, Json(_req): Json, ) -> Result { return Err(Error::BadRequest( "Muting the error handler for certain flow is only available in enterprise version" .to_string(), )); } #[cfg(feature = "enterprise")] async fn toggle_workspace_error_handler( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, path)): Path<(String, StripPath)>, Json(req): Json, ) -> Result { let mut tx = user_db.begin(&authed).await?; let error_handler_maybe: Option = sqlx::query_scalar!( r#" SELECT error_handler->>'path' FROM workspace_settings WHERE workspace_id = $1 "#, w_id ) .fetch_optional(&mut *tx) .await? .unwrap_or(None); let response = match error_handler_maybe { Some(_) => { sqlx::query_scalar!( r#" UPDATE flow SET ws_error_handler_muted = $3 WHERE path = $1 AND workspace_id = $2 "#, path.to_path(), w_id, req.muted, ) .execute(&mut *tx) .await?; Ok("".to_string()) } None => Err(Error::BadRequest( "Workspace error handler needs to be defined".to_string(), )), }; tx.commit().await?; return response; } async fn check_path_conflict<'c>( tx: &mut Transaction<'c, Postgres>, w_id: &str, path: &str, ) -> Result<()> { let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)", path, w_id ) .fetch_one(&mut **tx) .await? .unwrap_or(false); if exists { return Err(Error::BadRequest(format!("Flow {} already exists", path))); } return Ok(()); } #[derive(Deserialize)] struct ListPathsFromWorkspaceRunnableQuery { match_path_start: Option, } async fn list_paths_from_workspace_runnable( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, runnable_kind, path)): Path<(String, RunnableKind, StripPath)>, Query(query): Query, ) -> JsonResult> { let path = path.to_path(); check_scopes(&authed, || { format!("flows:read:{}", format!("{}/{}", runnable_kind, path)) })?; let mut tx = user_db.begin(&authed).await?; let runnables = if query.match_path_start.unwrap_or(false) { sqlx::query_scalar!( r#"SELECT DISTINCT f.path FROM workspace_runnable_dependencies wru JOIN flow f ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id WHERE wru.runnable_path LIKE $1 || '%' AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#, path, matches!(runnable_kind, RunnableKind::Flow), w_id ) .fetch_all(&mut *tx) .await? } else { sqlx::query_scalar!( r#"SELECT f.path FROM workspace_runnable_dependencies wru JOIN flow f ON wru.flow_path = f.path AND wru.workspace_id = f.workspace_id WHERE wru.runnable_path = $1 AND wru.runnable_is_flow = $2 AND wru.workspace_id = $3"#, path, matches!(runnable_kind, RunnableKind::Flow), w_id ) .fetch_all(&mut *tx) .await? }; tx.commit().await?; Ok(Json(runnables)) } async fn validate_flow(new_flow: &NewFlow) -> error::Result<()> { #[cfg(not(feature = "enterprise"))] if new_flow.ws_error_handler_muted.is_some_and(|val| val) { return Err(Error::BadRequest( "Muting the error handler for certain flow is only available in enterprise version" .to_string(), )); } guard_flow_from_debounce_data(new_flow).await?; return Ok(()); } async fn create_flow( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Extension(webhook): Extension, Path(w_id): Path, Json(mut nf): Json, ) -> Result<(StatusCode, String)> { if authed.is_operator { return Err(Error::NotAuthorized( "Operators cannot create flows for security reasons".to_string(), )); } check_scopes(&authed, || format!("flows:write:{}", nf.path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, &db, ) .await? { return Err(Error::PermissionDenied(msg)); } validate_flow(&nf).await?; if *CLOUD_HOSTED { let nb_flows = sqlx::query_scalar!("SELECT COUNT(*) FROM flow WHERE workspace_id = $1", &w_id) .fetch_one(&db) .await?; if nb_flows.unwrap_or(0) >= 1000 { return Err(Error::BadRequest( "You have reached the maximum number of flows (1000) on cloud. Check your usage in Workspace Settings > General > Cloud Quotas. Contact support@windmill.dev to increase the limit" .to_string(), )); } if nf.summary.len() > 300 { return Err(Error::BadRequest( "Summary must be less than 300 characters on cloud".to_string(), )); } if nf .description .as_ref() .is_some_and(|desc| desc.len() > 3000) { return Err(Error::BadRequest( "Description must be less than 3000 characters on cloud".to_string(), )); } } // cron::Schedule::from_str(&ns.schedule).map_err(|e| error::Error::BadRequest(e.to_string()))?; let authed = maybe_refresh_folders(&nf.path, &w_id, authed, &db).await; // Apply folder default_permissioned_as on create when the caller did not // explicitly preserve a value and the user can preserve. let explicit_preserve = nf.on_behalf_of_email.is_some() && nf.preserve_on_behalf_of.unwrap_or(false) && windmill_common::can_preserve_on_behalf_of(&authed); if !explicit_preserve && windmill_common::can_preserve_on_behalf_of(&authed) { if let Some(default_email) = windmill_common::folders::resolve_folder_default_on_behalf_of_email( &db, &w_id, &nf.path, ) .await? { nf.on_behalf_of_email = Some(default_email); nf.preserve_on_behalf_of = Some(true); } } let mut tx = user_db.clone().begin(&authed).await?; check_path_conflict(&mut tx, &w_id, &nf.path).await?; check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; let schema_str = nf.schema.and_then(|x| serde_json::to_string(&x.0).ok()); sqlx::query!( r#"INSERT INTO flow ( workspace_id, path, summary, description, dependency_job, lock_error_logs, tag, dedicated_worker, visible_to_runner_only, on_behalf_of_email, ws_error_handler_muted, value, schema, edited_by, edited_at, labels ) VALUES ( $1, $2, $3, $4, NULL, '', $5, $6, $7, $8, $9, $10, $11::text::json, $12, now(), $13 )"#, w_id, nf.path, nf.summary, nf.description.as_deref().unwrap_or(""), nf.tag, nf.dedicated_worker, nf.visible_to_runner_only.unwrap_or(false), windmill_common::resolve_on_behalf_of_email( nf.on_behalf_of_email.as_deref(), nf.preserve_on_behalf_of.unwrap_or(false), &authed, ), nf.ws_error_handler_muted.unwrap_or(false), sqlx::types::Json(&nf.value) as _, schema_str, &authed.username, nf.labels.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await?; let version = sqlx::query_scalar!( "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) VALUES ($1, $2, $3, $4::text::json, $5) RETURNING id", w_id, nf.path, sqlx::types::Json(nf.value) as _, schema_str, &authed.username, ) .fetch_one(&mut *tx) .await?; sqlx::query!( "UPDATE flow SET versions = array_append(versions, $1) WHERE path = $2 AND workspace_id = $3", version, nf.path, w_id ).execute(&mut *tx).await?; // CLI / git-sync deploys ask us to preserve any existing user draft at this // path instead of wiping it as part of the deploy. Only wipe the deployer's // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ AND (email = $3 OR email IS NULL)", nf.path, &w_id, &authed.email, ) .execute(&mut *tx) .await?; } audit_log( &mut *tx, &authed, "flows.create", ActionKind::Create, &w_id, Some(&nf.path.to_string()), Some( [Some(("flow", nf.path.as_str()))] .into_iter() .flatten() .collect(), ), ) .await?; if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation( nf.on_behalf_of_email.as_deref(), nf.preserve_on_behalf_of.unwrap_or(false), &authed, &authed.email, ) { audit_log( &mut *tx, &authed, "flows.on_behalf_of", ActionKind::Create, &w_id, Some(&nf.path), Some( [ ("on_behalf_of", on_behalf_of.as_str()), ("action", "create"), ] .into(), ), ) .await?; } let mut args: HashMap> = HashMap::new(); if let Some(dm) = nf.deployment_message { args.insert("deployment_message".to_string(), to_raw_value(&dm)); } let tx = PushIsolationLevel::Transaction(tx); let (dependency_job_uuid, mut new_tx) = push( &db, tx, &w_id, JobPayload::FlowDependencies { path: nf.path.clone(), dedicated_worker: nf.dedicated_worker, version: version, debouncing_settings: Default::default(), }, windmill_queue::PushArgs { args: &args, extra: None }, &authed.username, &authed.email, windmill_common::users::username_to_permissioned_as(&authed.username), authed.token_prefix.as_deref(), None, None, None, None, None, None, false, false, None, true, None, None, None, None, Some(&authed.clone().into()), false, None, None, None, ) .await?; sqlx::query!( "UPDATE flow SET dependency_job = $1 WHERE path = $2 AND workspace_id = $3", dependency_job_uuid, nf.path, w_id ) .execute(&mut *new_tx) .await?; // Store the job_id in deployment_metadata for this flow deployment sqlx::query!( "INSERT INTO deployment_metadata (workspace_id, path, flow_version, job_id) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL DO UPDATE SET job_id = EXCLUDED.job_id", w_id, nf.path, version, dependency_job_uuid ) .execute(&mut *new_tx) .await?; new_tx.commit().await?; webhook.send_message( w_id.clone(), WebhookMessage::CreateFlow { workspace: w_id.clone(), path: nf.path.clone() }, ); Ok((StatusCode::CREATED, nf.path.to_string())) } async fn check_schedule_conflict<'c>( tx: &mut Transaction<'c, Postgres>, w_id: &str, path: &str, ) -> error::Result<()> { let exists_flow = sqlx::query_scalar!( "SELECT EXISTS (SELECT 1 FROM schedule WHERE path = $1 AND workspace_id = $2 AND path != \ script_path)", path, w_id ) .fetch_one(&mut **tx) .await? .unwrap_or(false); if exists_flow { return Err(error::Error::BadConfig(format!( "A flow cannot have the same path as a schedule if the schedule does not trigger that \ same flow: {path}", ))); }; Ok(()) } #[derive(Serialize)] pub struct FlowVersion { pub id: i64, pub created_at: chrono::DateTime, #[serde(skip_serializing_if = "Option::is_none")] pub deployment_msg: Option, } async fn get_flow_history( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> JsonResult> { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let flows = sqlx::query_as!( FlowVersion, "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 ORDER BY flow_version.created_at DESC", path, w_id ) .fetch_all(&mut *tx) .await?; tx.commit().await?; Ok(Json(flows)) } async fn get_latest_version( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> JsonResult> { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let version = sqlx::query_as!( FlowVersion, "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 ORDER BY flow_version.created_at DESC", path, w_id ) .fetch_optional(&mut *tx) .await?; tx.commit().await?; Ok(Json(version)) } async fn get_flow_version( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, version, path)): Path<(String, i64, StripPath)>, ) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let flow = sqlx::query_as::<_, Flow>( "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow LEFT JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow.path = $1 AND flow.workspace_id = $2 AND flow_version.id = $3", ) .bind(path) .bind(w_id) .bind(version) .fetch_optional(&mut *tx) .await?; tx.commit().await?; let flow = not_found_if_none(flow, "Flow version", version.to_string())?; Ok(Json(flow)) } async fn get_flow_version_by_id( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, version)): Path<(String, i64)>, ) -> JsonResult { let mut tx = user_db.begin(&authed).await?; // First, fetch the path to perform authorization check early let path: Option = sqlx::query_scalar("SELECT path FROM flow_version WHERE id = $1 AND workspace_id = $2") .bind(version) .bind(&w_id) .fetch_optional(&mut *tx) .await?; let path = not_found_if_none( path, "Flow version", format!("{} in workspace {}", version, w_id), )?; // Perform authorization check before fetching full data check_scopes(&authed, || format!("flows:read:{}", path))?; // Now fetch the full flow data with INNER JOIN to ensure flow exists let flow = sqlx::query_as::<_, Flow>( "SELECT flow.workspace_id, flow.path, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, flow_version.schema, flow_version.value, flow_version.created_at as edited_at, flow_version.created_by as edited_by FROM flow INNER JOIN flow_version ON flow_version.path = flow.path AND flow_version.workspace_id = flow.workspace_id WHERE flow_version.id = $1 AND flow.workspace_id = $2", ) .bind(version) .bind(&w_id) .fetch_optional(&mut *tx) .await?; tx.commit().await?; let flow = not_found_if_none( flow, "Flow", format!("for version {} (flow may have been deleted)", version), )?; Ok(Json(flow)) } #[derive(Deserialize)] pub struct FlowHistoryUpdate { pub deployment_msg: String, } async fn update_flow_history( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, version)): Path<(String, i64)>, Json(history_update): Json, ) -> Result<()> { let mut tx = user_db.begin(&authed).await?; // Fetch path and perform authorization check early let path: Option = sqlx::query_scalar("SELECT path FROM flow_version WHERE workspace_id = $1 AND id = $2") .bind(&w_id) .bind(version) .fetch_optional(&mut *tx) .await?; let path = not_found_if_none( path, "Flow version", format!("{} in workspace {}", version, w_id), )?; // Perform authorization check before any modifications check_scopes(&authed, || format!("flows:write:{}", path))?; // Insert or update deployment metadata sqlx::query!( "INSERT INTO deployment_metadata (workspace_id, path, flow_version, deployment_msg) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL DO UPDATE SET deployment_msg = EXCLUDED.deployment_msg", &w_id, path, version, history_update.deployment_msg, ) .fetch_optional(&mut *tx) .await?; tx.commit().await?; Ok(()) } async fn update_flow( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Extension(webhook): Extension, Path((w_id, flow_path)): Path<(String, StripPath)>, Json(nf): Json, ) -> Result { if authed.is_operator { return Err(Error::NotAuthorized( "Operators cannot update flows for security reasons".to_string(), )); } let flow_path = flow_path.to_path(); check_scopes(&authed, || format!("flows:write:{}", flow_path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, &db, ) .await? { return Err(Error::PermissionDenied(msg)); } validate_flow(&nf).await?; let authed = maybe_refresh_folders(&flow_path, &w_id, authed, &db).await; let mut tx = user_db.clone().begin(&authed).await?; check_schedule_conflict(&mut tx, &w_id, flow_path).await?; let schema = nf.schema.map(|x| x.0); let old_dep_job = sqlx::query_scalar!( "SELECT dependency_job FROM flow WHERE path = $1 AND workspace_id = $2", flow_path, w_id ) .fetch_optional(&mut *tx) .await?; let old_dep_job = not_found_if_none(old_dep_job, "Flow", flow_path)?; let is_new_path = nf.path != flow_path; let schema_str = schema.and_then(|x| serde_json::to_string(&x).ok()); sqlx::query!( " UPDATE flow SET path = $1, summary = $2, description = $3, dependency_job = NULL, lock_error_logs = '', tag = $4, dedicated_worker = $5, visible_to_runner_only = $6, on_behalf_of_email = $7, ws_error_handler_muted = $8, value = $9, schema = $10::text::json, edited_by = $11, edited_at = now(), labels = COALESCE($14, labels) WHERE path = $12 AND workspace_id = $13", if is_new_path { flow_path } else { &nf.path }, nf.summary, nf.description.as_deref().unwrap_or(""), nf.tag, nf.dedicated_worker, nf.visible_to_runner_only.unwrap_or(false), windmill_common::resolve_on_behalf_of_email( nf.on_behalf_of_email.as_deref(), nf.preserve_on_behalf_of.unwrap_or(false), &authed, ), nf.ws_error_handler_muted.unwrap_or(false), sqlx::types::Json(&nf.value) as _, schema_str, authed.username, flow_path, w_id, nf.labels.as_deref() as Option<&[String]>, ) .execute(&mut *tx) .await .map_err(|e| { error::Error::internal_err(format!("Error updating flow due to flow update: {e:#}")) })?; if is_new_path { // if new path, must clone flow to new path and delete old flow for flow_version foreign key constraint sqlx::query!( "INSERT INTO flow (workspace_id, path, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels) SELECT workspace_id, $1, summary, description, archived, extra_perms, dependency_job, tag, ws_error_handler_muted, dedicated_worker, timeout, visible_to_runner_only, on_behalf_of_email, concurrency_key, versions, value, schema, edited_by, edited_at, labels FROM flow WHERE path = $2 AND workspace_id = $3", nf.path, flow_path, w_id ) .execute(&mut *tx) .await .map_err(|e| { error::Error::internal_err(format!("Error updating flow due to create new flow: {e:#}")) })?; sqlx::query!( "UPDATE flow_version SET path = $1 WHERE path = $2 AND workspace_id = $3", nf.path, flow_path, w_id ) .execute(&mut *tx) .await .map_err(|e| { error::Error::internal_err(format!( "Error updating flow due to updating flow history path: {e:#}" )) })?; sqlx::query!( "DELETE FROM flow WHERE path = $1 AND workspace_id = $2", flow_path, w_id ) .execute(&mut *tx) .await .map_err(|e| { error::Error::internal_err(format!( "Error updating flow due to deleting old flow: {e:#}" )) })?; sqlx::query!( "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE", nf.path, flow_path, w_id ) .execute(&mut *tx) .await?; sqlx::query!( "UPDATE capture SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS TRUE", nf.path, flow_path, w_id ) .execute(&mut *tx) .await?; // Update ci_test_reference when a tested flow is renamed sqlx::query!( "UPDATE ci_test_reference SET tested_item_path = $1 WHERE tested_item_path = $2 AND workspace_id = $3 AND tested_item_kind = 'flow'", nf.path, flow_path, w_id ) .execute(&mut *tx) .await?; } // tracing::error!("Updating flow: {:?}", nf.value.get()); // This will lock anyone who is trying to iterate on flow_versions with given path and parameters. let version = sqlx::query_scalar!( "INSERT INTO flow_version (workspace_id, path, value, schema, created_by) VALUES ($1, $2, $3, $4::text::json, $5) RETURNING id", w_id, nf.path, sqlx::types::Json(nf.value) as _, schema_str, &authed.username, ) .fetch_one(&mut *tx) .await .map_err(|e| { error::Error::internal_err(format!( "Error updating flow due to flow history insert: {e:#}" )) })?; // TODO: This should happen only after we are done with dependency job. sqlx::query!( "UPDATE flow SET versions = array_append(versions, $1) WHERE path = $2 AND workspace_id = $3", version, nf.path, w_id ).execute(&mut *tx).await?; if is_new_path { check_schedule_conflict(&mut tx, &w_id, &nf.path).await?; if !authed.is_admin { require_owner_of_path(&authed, flow_path)?; } } let mut schedulables: Vec = sqlx::query_as::<_, Schedule>( "UPDATE schedule SET script_path = $1 WHERE script_path = $2 AND path != $2 AND workspace_id = $3 AND is_flow IS true RETURNING *") .bind(&nf.path) .bind(&flow_path) .bind(&w_id) .fetch_all(&mut *tx) .await.map_err(|e| error::Error::internal_err(format!("Error updating flow due to related schedules update: {e:#}")))?; let schedule = sqlx::query_as::<_, Schedule>( "UPDATE schedule SET path = $1, script_path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS true RETURNING *") .bind(&nf.path) .bind(&flow_path) .bind(&w_id) .fetch_optional(&mut *tx) .await.map_err(|e| error::Error::internal_err(format!("Error updating flow due to related schedule update: {e:#}")))?; if let Some(schedule) = schedule { clear_schedule(&mut tx, &flow_path, &w_id).await?; schedulables.push(schedule); } for schedule in schedulables.into_iter() { clear_schedule(&mut tx, &schedule.path, &w_id).await?; if schedule.enabled { tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; } } if is_new_path { windmill_common::triggers::update_triggers_script_path( &mut tx, &nf.path, &flow_path, &w_id, true, ) .await .map_err(|e| { error::Error::internal_err(format!( "Error updating triggers due to runnable path change: {e:#}" )) })?; } // CLI / git-sync deploys ask us to preserve any existing user draft at this // path instead of wiping it as part of the deploy. Only wipe the deployer's // own draft (plus the legacy NULL-email row); see scripts.rs. if !nf.skip_draft_deletion.unwrap_or(false) { sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow' \ AND (email = $3 OR email IS NULL)", flow_path, &w_id, &authed.email, ) .execute(&mut *tx) .await?; } audit_log( &mut *tx, &authed, "flows.update", ActionKind::Create, &w_id, Some(&nf.path.to_string()), Some( [Some(("flow", nf.path.as_str()))] .into_iter() .flatten() .collect(), ), ) .await?; if let Some(on_behalf_of) = windmill_common::check_on_behalf_of_preservation( nf.on_behalf_of_email.as_deref(), nf.preserve_on_behalf_of.unwrap_or(false), &authed, &authed.email, ) { audit_log( &mut *tx, &authed, "flows.on_behalf_of", ActionKind::Update, &w_id, Some(&nf.path), Some( [ ("on_behalf_of", on_behalf_of.as_str()), ("action", "update"), ] .into(), ), ) .await?; } webhook.send_message( w_id.clone(), WebhookMessage::UpdateFlow { workspace: w_id.clone(), old_path: flow_path.to_owned(), new_path: nf.path.clone(), }, ); let tx = PushIsolationLevel::Transaction(tx); let mut args: HashMap> = HashMap::new(); if let Some(dm) = nf.deployment_message { args.insert("deployment_message".to_string(), to_raw_value(&dm)); } args.insert("parent_path".to_string(), to_raw_value(&flow_path)); let (dependency_job_uuid, mut new_tx) = push( &db, tx, &w_id, JobPayload::FlowDependencies { path: nf.path.clone(), dedicated_worker: nf.dedicated_worker, version, debouncing_settings: Default::default(), }, windmill_queue::PushArgs { args: &args, extra: None }, &authed.username, &authed.email, windmill_common::users::username_to_permissioned_as(&authed.username), authed.token_prefix.as_deref(), None, None, None, None, None, None, false, false, None, true, None, None, None, None, Some(&authed.clone().into()), false, None, None, None, ) .await?; sqlx::query!( "UPDATE flow SET dependency_job = $1 WHERE path = $2 AND workspace_id = $3", dependency_job_uuid, nf.path, w_id ) .execute(&mut *new_tx) .await .map_err(|e| { error::Error::internal_err(format!( "Error updating flow due to updating dependency job field: {e:#}" )) })?; // Store the job_id in deployment_metadata for this flow deployment sqlx::query!( "INSERT INTO deployment_metadata (workspace_id, path, flow_version, job_id) VALUES ($1, $2, $3, $4) ON CONFLICT (workspace_id, path, flow_version) WHERE flow_version IS NOT NULL DO UPDATE SET job_id = EXCLUDED.job_id", w_id, nf.path, version, dependency_job_uuid ) .execute(&mut *new_tx) .await .map_err(|e| { error::Error::internal_err(format!( "Error updating deployment_metadata with job_id: {e:#}" )) })?; if let Some(old_dep_job) = old_dep_job { sqlx::query!( "UPDATE v2_job_queue SET canceled_by = $2, canceled_reason = 're-deployment' WHERE id = $1", old_dep_job, &authed.username ) .execute(&mut *new_tx) .await .map_err(|e| { error::Error::internal_err(format!( "Error updating flow due to cancelling dependency job: {e:#}" )) })?; } new_tx.commit().await?; // Trigger CI tests for items that reference this flow { let db2 = db.clone(); let w_id2 = w_id.clone(); let flow_path2 = nf.path.clone(); let email2 = authed.email.clone(); let username2 = authed.username.clone(); tokio::spawn(async move { if let Err(e) = windmill_dep_map::ci_tests::trigger_ci_tests_for_item( &db2, &w_id2, &flow_path2, "flow", &email2, &username2, ) .await { tracing::error!(%e, "error triggering CI tests after flow deploy"); } }); } Ok(nf.path.to_string()) } async fn list_tokens( authed: ApiAuthed, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> JsonResult> { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; list_tokens_internal(&db, &w_id, &path, true).await } #[derive(Serialize)] struct DeploymentStatus { lock_error_logs: Option, job_id: Option, } async fn get_deployment_status( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> JsonResult { let path = path.to_path(); let mut tx = db.begin().await?; let status_o = sqlx::query!( "SELECT f.lock_error_logs, dm.job_id FROM flow f LEFT JOIN deployment_metadata dm ON f.versions[array_upper(f.versions, 1)] = dm.flow_version AND f.workspace_id = dm.workspace_id AND f.path = dm.path WHERE f.path = $1 AND f.workspace_id = $2", path, w_id, ) .fetch_optional(&mut *tx) .await?; let status = not_found_if_none(status_o, "DeploymentStatus", path)?; let deployment_status = DeploymentStatus { lock_error_logs: status.lock_error_logs, job_id: status.job_id }; tx.commit().await?; Ok(Json(deployment_status)) } // Fields inlined rather than flattened (axum query bool quirk); see GetScriptByPathQuery in scripts.rs. #[derive(Deserialize)] struct GetFlowByPathQuery { with_starred_info: Option, #[serde(default)] get_draft: bool, } async fn get_flow_by_path( authed: ApiAuthed, Extension(user_db): Extension, Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, Query(query): Query, ) -> JsonResult { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let flow_o = if query.with_starred_info.unwrap_or(false) { sqlx::query_as::<_, FlowWithStarred>( r#" SELECT flow.workspace_id, flow.path, flow.lock_error_logs, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, flow_version.value, flow_version.created_at AS edited_at, flow_version.created_by AS edited_by, favorite.path IS NOT NULL AS starred FROM flow LEFT JOIN favorite ON favorite.favorite_kind = 'flow' AND favorite.workspace_id = flow.workspace_id AND favorite.path = flow.path AND favorite.usr = $3 LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.path = $1 AND flow.workspace_id = $2 "#, ) .bind(path) .bind(&w_id) .bind(&authed.username) .fetch_optional(&mut *tx) .await? } else { sqlx::query_as::<_, FlowWithStarred>( r#" SELECT flow.workspace_id, flow.path, flow.lock_error_logs, flow.summary, flow.description, flow.archived, flow.extra_perms, flow.dedicated_worker, flow.tag, flow.ws_error_handler_muted, flow.timeout, flow.visible_to_runner_only, flow.on_behalf_of_email, flow.labels, folder_labels(flow.workspace_id, flow.path) AS inherited_labels, flow_version.id AS version_id, flow_version.schema, flow_version.value, flow_version.created_at AS edited_at, flow_version.created_by AS edited_by, NULL AS starred 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 "#, ) .bind(path) .bind(&w_id) .fetch_optional(&mut *tx) .await? }; tx.commit().await?; // No deployed row + `get_draft`: fall back to the draft table; see scripts.rs. let overlay = overlay_or_draft_only( &db, &w_id, &authed.email, UserDraftItemKind::Flow, path, query.get_draft, flow_o, || windmill_common::error::Error::NotFound(format!("Flow not found at path {path}")), ) .await?; Ok(Json(overlay)) } async fn exists_flow_by_path( Extension(db): Extension, Path((w_id, path)): Path<(String, StripPath)>, ) -> JsonResult { let path = path.to_path(); let exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)", path, w_id ) .fetch_one(&db) .await? .unwrap_or(false); Ok(Json(exists)) } #[derive(Deserialize)] struct Archived { archived: Option, } async fn archive_flow_by_path( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, Json(archived): Json, ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, &db, ) .await? { return Err(Error::PermissionDenied(msg)); } let mut tx = user_db.begin(&authed).await?; sqlx::query!( "UPDATE flow SET archived = $1 WHERE path = $2 AND workspace_id = $3", archived.archived.unwrap_or(true), path, &w_id ) .execute(&mut *tx) .await?; clear_static_asset_usage(&mut *tx, &w_id, path, AssetUsageKind::Flow).await?; audit_log( &mut *tx, &authed, "flows.archive", ActionKind::Delete, &w_id, Some(path), Some([("workspace", w_id.as_str())].into()), ) .await?; ScopedDependencyMap::clear_map_for_item(path, &w_id, "flow", tx, &None) .await .commit() .await?; handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Flow { path: path.to_string(), parent_path: Some(path.to_string()), version: 0, // dummy version as it will not get inserted in db }, Some(format!( "Flow '{}' {}", path, if archived.archived.unwrap_or(true) { "archived" } else { "unarchived" } )), true, None, ) .await?; webhook.send_message( w_id.clone(), WebhookMessage::ArchiveFlow { workspace: w_id, path: path.to_owned() }, ); Ok(format!("Flow {path} archived")) } /// Validates that flow debouncing configuration is supported by all workers /// Returns an error if debouncing is configured but workers are behind required version async fn guard_flow_from_debounce_data(nf: &NewFlow) -> Result<()> { let flow_value = nf.parse_flow_value()?; if !MIN_VERSION_SUPPORTS_DEBOUNCING.met().await && !flow_value.debouncing_settings.is_default() { tracing::warn!( "Flow debouncing configuration rejected: workers are behind minimum required version for debouncing feature" ); return Err(Error::WorkersAreBehind { feature: "Debouncing".into(), min_version: "1.566.0".into(), }); } if !MIN_VERSION_SUPPORTS_DEBOUNCING_V2.met().await && !flow_value.debouncing_settings.is_legacy_compatible() && !*WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT { tracing::warn!( "Flow debouncing configuration rejected: workers are behind minimum required version for debouncing feature" ); return Err(Error::WorkersAreBehind { feature: "V2 Debouncing".into(), min_version: "1.597.0".into(), }); } // Check node-level debouncing on all modules (including nested branches/loops) let mut has_node_debouncing = false; let check_result = FlowModule::traverse_modules(&flow_value.modules, &mut |m| { if m.debouncing .as_ref() .is_some_and(|d| d.debounce_delay_s.is_some_and(|s| s > 0)) { has_node_debouncing = true; } Ok(()) }); if let Err(e) = check_result { tracing::warn!("Failed to traverse flow modules for debounce guard: {e}"); } if has_node_debouncing && !MIN_VERSION_SUPPORTS_NODE_DEBOUNCING.met().await { return Err(Error::WorkersAreBehind { feature: "Flow node debouncing".into(), min_version: "1.658.0".into(), }); } Ok(()) } #[derive(Deserialize)] struct DeleteFlowQuery { keep_captures: Option, } async fn delete_flow_by_path( authed: ApiAuthed, Extension(db): Extension, Extension(user_db): Extension, Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, Query(query): Query, ) -> Result { let path = path.to_path(); check_scopes(&authed, || format!("flows:write:{}", path))?; if let RuleCheckResult::Blocked(msg) = check_deploy_rules( &w_id, AuditAuthorable::username(&authed), &authed.groups, authed.is_admin, &db, ) .await? { return Err(Error::PermissionDenied(msg)); } let mut tx = user_db.begin(&authed).await?; // Capture all related data for trashbin before deleting (CASCADE will remove flow_version, flow_node) let trash_flow: Option = sqlx::query_scalar("SELECT to_jsonb(t) FROM flow t WHERE path = $1 AND workspace_id = $2") .bind(path) .bind(&w_id) .fetch_optional(&mut *tx) .await?; let trash_flow_versions: Vec = sqlx::query_scalar( "SELECT to_jsonb(t) FROM flow_version t WHERE path = $1 AND workspace_id = $2", ) .bind(path) .bind(&w_id) .fetch_all(&mut *tx) .await?; let trash_flow_nodes: Vec = sqlx::query_scalar( "SELECT to_jsonb(t) FROM flow_node t WHERE path = $1 AND workspace_id = $2", ) .bind(path) .bind(&w_id) .fetch_all(&mut *tx) .await?; let trash_drafts: Vec = sqlx::query_scalar( "SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", ) .bind(path) .bind(&w_id) .fetch_all(&mut *tx) .await?; sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", path, &w_id ) .execute(&mut *tx) .await?; sqlx::query!( "DELETE FROM flow WHERE path = $1 AND workspace_id = $2", path, &w_id ) .execute(&mut *tx) .await?; if let Some(flow_data) = trash_flow { let mut trash_data = serde_json::json!({"row": flow_data}); if !trash_flow_versions.is_empty() { trash_data["flow_versions"] = serde_json::Value::Array(trash_flow_versions); } if !trash_flow_nodes.is_empty() { trash_data["flow_nodes"] = serde_json::Value::Array(trash_flow_nodes); } if !trash_drafts.is_empty() { trash_data["drafts"] = serde_json::Value::Array(trash_drafts); } windmill_common::trashbin::move_to_trash( &mut *tx, &w_id, "flow", path, trash_data, &authed.username, ) .await?; } if !query.keep_captures.unwrap_or(false) { sqlx::query!( "DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE", path, &w_id ) .execute(&mut *tx) .await?; sqlx::query!( "DELETE FROM capture WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE", path, &w_id ) .execute(&mut *tx) .await?; } audit_log( &mut *tx, &authed, "flows.delete", ActionKind::Delete, &w_id, Some(path), Some([("workspace", w_id.as_str())].into()), ) .await?; tx.commit().await?; handle_deployment_metadata( &authed.email, &authed.username, &db, &w_id, DeployedObject::Flow { path: path.to_string(), parent_path: Some(path.to_string()), version: 0, // dummy version as it will not get inserted in db }, Some(format!("Flow '{}' deleted", path)), true, None, ) .await?; sqlx::query!( "DELETE FROM deployment_metadata WHERE path = $1 AND workspace_id = $2 AND script_hash IS NULL and app_version IS NULL", path, w_id ) .execute(&db) .await .map_err(|e| { Error::internal_err(format!( "error deleting deployment metadata for script with path {path} in workspace {w_id}: {e:#}" )) })?; webhook.send_message( w_id.clone(), WebhookMessage::DeleteFlow { workspace: w_id, path: path.to_owned() }, ); Ok(format!("Flow {path} deleted")) } #[cfg(test)] mod tests { use std::{collections::HashMap, time::Duration}; use windmill_common::{ flows::{ ConstantDelay, ExponentialDelay, FlowModule, FlowModuleValue, FlowValue, InputTransform, Retry, StopAfterIf, }, runnable_settings::{ ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings, }, scripts, }; const SECOND: Duration = Duration::from_secs(1); #[test] fn flowmodule_serde() { let fv = FlowValue { modules: vec![ FlowModule { id: "a".to_string(), value: windmill_common::worker::to_raw_value(&FlowModuleValue::Script { path: "test".to_string(), input_transforms: [( "test".to_string(), InputTransform::Static { value: windmill_common::worker::to_raw_value(&"test2".to_string()), }, )] .into(), hash: None, tag_override: None, is_trigger: None, pass_flow_input_directly: None, }), stop_after_if: None, stop_after_all_iters_if: None, summary: None, suspend: Default::default(), retry: None, sleep: None, cache_ttl: None, cache_ignore_s3_path: None, mock: None, timeout: None, priority: None, delete_after_use: None, delete_after_secs: None, continue_on_error: None, skip_if: None, apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, }, FlowModule { id: "b".to_string(), value: windmill_common::worker::to_raw_value(&FlowModuleValue::RawScript { input_transforms: HashMap::new(), content: "test".to_string(), language: scripts::ScriptLang::Deno, path: None, lock: None, tag: None, is_trigger: None, assets: None, concurrency_settings: ConcurrencySettingsWithCustom::default(), }), stop_after_if: Some(StopAfterIf { expr: "foo = 'bar'".to_string(), ..Default::default() }), stop_after_all_iters_if: None, summary: None, suspend: Default::default(), retry: None, sleep: None, cache_ttl: None, cache_ignore_s3_path: None, mock: None, timeout: None, priority: None, delete_after_use: None, delete_after_secs: None, continue_on_error: None, skip_if: None, apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, }, FlowModule { id: "c".to_string(), value: windmill_common::worker::to_raw_value(&FlowModuleValue::ForloopFlow { iterator: InputTransform::Static { value: windmill_common::worker::to_raw_value(&[1, 2, 3]), }, modules: vec![], modules_node: None, skip_failures: true, parallel: false, parallelism: None, squash: None, }), stop_after_if: Some(StopAfterIf { expr: "previous.isEmpty()".to_string(), ..Default::default() }), stop_after_all_iters_if: None, summary: None, suspend: Default::default(), retry: None, sleep: None, cache_ttl: None, cache_ignore_s3_path: None, mock: None, timeout: None, priority: None, delete_after_use: None, delete_after_secs: None, continue_on_error: None, skip_if: None, apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, }, ], failure_module: Some(Box::new(FlowModule { id: "d".to_string(), value: FlowModuleValue::Script { path: "test".to_string(), input_transforms: HashMap::new(), hash: None, tag_override: None, is_trigger: None, pass_flow_input_directly: None, } .into(), stop_after_if: Some(StopAfterIf { expr: "previous.isEmpty()".to_string(), ..Default::default() }), stop_after_all_iters_if: None, summary: None, suspend: Default::default(), retry: None, sleep: None, cache_ttl: None, cache_ignore_s3_path: None, mock: None, timeout: None, priority: None, delete_after_use: None, delete_after_secs: None, continue_on_error: None, skip_if: None, apply_preprocessor: None, pass_flow_input_directly: None, debouncing: None, })), preprocessor_module: None, same_worker: false, preserve_step_tags: false, skip_expr: None, cache_ttl: None, cache_ignore_s3_path: None, priority: None, early_return: None, chat_input_enabled: None, flow_env: None, delete_after_use: None, delete_after_secs: None, concurrency_settings: ConcurrencySettings::default(), debouncing_settings: DebouncingSettings::default(), }; let expect = serde_json::json!({ "modules": [ { "id": "a", "value": { "input_transforms": { "test": { "type": "static", "value": "test2" } }, "type": "script", "path": "test", }, }, { "id": "b", "value": { "input_transforms": {}, "type": "rawscript", "content": "test", "language": "deno" }, "stop_after_if": { "expr": "foo = 'bar'", "skip_if_stopped": false } }, { "id": "c", "value": { "type": "forloopflow", "iterator": { "type": "static", "value": [ 1, 2, 3 ] }, "parallel": false, "skip_failures": true, "modules": [] }, "stop_after_if": { "expr": "previous.isEmpty()", "skip_if_stopped": false } } ], "failure_module": { "id": "d", "value": { "input_transforms": {}, "type": "script", "path": "test", }, "stop_after_if": { "expr": "previous.isEmpty()", "skip_if_stopped": false } }, }); assert_eq!(dbg!(serde_json::json!(fv)), dbg!(expect)); } #[test] fn retry_serde() { assert_eq!(Retry::default(), serde_json::from_str(r#"{}"#).unwrap()); assert_eq!( Retry::default(), serde_json::from_str( r#" { "constant": { "seconds": 0 }, "exponential": { "multiplier": 1, "seconds": 0 }, "retry_if": null } "# ) .unwrap() ); assert_eq!( Retry { constant: Default::default(), exponential: ExponentialDelay { attempts: 0, multiplier: 1, seconds: 123, random_factor: None }, retry_if: None }, serde_json::from_str( r#" { "constant": {}, "exponential": { "seconds": 123 }, "retry_if" : null } "# ) .unwrap() ); } #[test] fn retry_exponential() { let retry = Retry { constant: ConstantDelay::default(), exponential: ExponentialDelay { attempts: 3, multiplier: 4, seconds: 3, random_factor: None, }, retry_if: None, }; assert_eq!( vec![ Some(12 * SECOND), Some(36 * SECOND), Some(108 * SECOND), None ], (0..4) .map(|previous_attempts| retry.interval(previous_attempts, false)) .collect::>() ); assert_eq!(Some(108 * SECOND), retry.max_interval()); } #[test] fn retry_both() { let retry = Retry { constant: ConstantDelay { attempts: 2, seconds: 4 }, exponential: ExponentialDelay { attempts: 2, multiplier: 1, seconds: 3, random_factor: None, }, retry_if: None, }; assert_eq!( vec![ Some(4 * SECOND), Some(4 * SECOND), Some(27 * SECOND), Some(81 * SECOND), None, ], (0..5) .map(|previous_attempts| retry.interval(previous_attempts, false)) .collect::>() ); assert_eq!(Some(81 * SECOND), retry.max_interval()); } }